Skip to content

fix(desktop): take the running turn from the run, not from session status - #1987

Merged
Astro-Han merged 2 commits into
mainfrom
fix/desktop-turn-running-arm-authority
Aug 3, 2026
Merged

fix(desktop): take the running turn from the run, not from session status#1987
Astro-Han merged 2 commits into
mainfrom
fix/desktop-turn-running-arm-authority

Conversation

@Astro-Han

Copy link
Copy Markdown
Contributor

Summary

Sending a message did not show Stop or "正在处理…" until the reply began, and the state flickered mid-turn.

"Is a turn running" is a fact about the live process, but the UI read it off SessionHeader.status, which is three steps removed from that fact:

  • it is written only at the END of AgentRun.begin, and nothing announced it — no SessionEvent marks a turn's START, only its end;
  • it carries no turn identity and reads the same (active) before a turn starts and after it ends;
  • it is persisted, so a crash between a turn's end and its status write leaves running behind for good.

The renderer armed a live-turn projection at send with no lag, then ANDed it with that status, so the send opened nothing until a status round-trip landed. Worse, any session list resolving inside that window looked byte-identical to one taken after the turn ended, so settledSessionTransientIds retired the arm outright — the first content event then rebuilt it as 'streamed', silently downgrading the prominent "正在处理…" to the calm "继续中…".

This replaces the AND with two witnesses that cannot veto each other:

  • The local arm answers for the turn this renderer sent. It carries an unconfirmed bit until the authority says something about that exact turn, which is what stops a snapshot older than the send from retiring it. onRunStarted now broadcasts a sessions:changed naming the turn — the earliest seam at which the run is live and anything can say so — and SessionChangedEvent.turnId makes it an answer to a specific send rather than a bare invalidation.
  • SessionSummary.runningTurnId answers for a turn this renderer did not send: another client, an automation, or one still running across a reload, none of which could show Stop before. It is projected from the live run and never persisted, so a restart reports the truth by itself rather than inheriting a stuck running. It is read only when it names a turn other than the arm's — for the arm's own turn the local projection knows more, having seen the terminal event first.

Also removes markSessionRunningOptimistic and its four rollback sites: the optimistic flip lived in the wholesale-replaced session list, so any refresh erased it, and the rollback could revert a genuinely running status.

Verification

  • npm run typecheck, npm run lint, npm run format:check — clean.
  • @maka/core 740 pass, @maka/ui 262 pass, @maka/desktop 1361 pass — 0 fail.
  • @maka/runtime 2760 pass / 4 fail. The 4 are pre-existing and unrelated (builtin-tools path containment, failing identically on a clean main checkout: macOS resolves the temp root as /private/var/... while the test passes /var/...).
  • Playwright E2E: 58/58 pass.
  • Not run: no timing measurement against a real slow provider. The fake backend answers faster than the 200ms rising-edge debounce, so the debounce path is covered by unit tests with an injected scheduler rather than end-to-end.

New coverage for the seams this depends on:

  • the unconfirmed claim's full lifecycle, including that any event about the turn clears it (@maka/ui);
  • the gate itself — that the local arm alone opens Stop, that a snapshot predating the run does not close it, and that a stale snapshot cannot revive the arm's own finished turn;
  • the run-started broadcast naming the turn the send was made with, emitted before the revision commit and surviving a commit that throws;
  • the real subscription handler routing a turn-named change to the arm, and ignoring one that names no turn;
  • the runtime projection naming a running turn, writing nothing to storage, and dropping it the moment the run ends.

Review focus

SessionChangedEvent.turnId and SessionSummary.runningTurnId are contract additions in packages/core. Both are optional, and the emitter obligation for each is stated at its declaration. runningTurnId is populated on session LISTS only — a summary returned by a mutation describes the header alone.

…atus
Sending a message did not show Stop or "正在处理…" until the reply began,
and the state flickered mid-turn.
"Is a turn running" is a fact about the live process, but the UI was
reading it off `SessionHeader.status`, which is three steps removed from
that fact:
- it is written only at the END of `AgentRun.begin`, and nothing
announced it — no SessionEvent marks a turn's START, only its end;
- it carries no turn identity and reads the same (`active`) before a
turn starts and after it ends;
- it is persisted, so a crash between a turn's end and its status write
leaves `running` behind for good.
The renderer armed a live-turn projection at send with no lag, then
ANDed it with that status, so the send opened nothing until a status
round-trip landed. Worse, any session list resolving inside that window
looked byte-identical to one taken after the turn ended, so
`settledSessionTransientIds` retired the arm outright — the first
content event then rebuilt it as `'streamed'`, silently downgrading the
prominent "正在处理…" to the calm "继续中…".
Replace the AND with two witnesses that cannot veto each other:
- the local arm answers for the turn this renderer sent. It carries an
`unconfirmed` bit until the authority says something about that exact
turn, which is what stops a snapshot older than the send from
retiring it. `onRunStarted` now broadcasts a `sessions:changed`
naming the turn — the earliest seam at which the run is live and
anything can say so — and `SessionChangedEvent.turnId` makes it an
answer to a specific send rather than a bare invalidation.
- `SessionSummary.runningTurnId` answers for a turn this renderer did
not send: another client, an automation, or one still running across
a reload, none of which could show Stop before. It is projected from
the live run and never persisted, so a restart reports the truth by
itself rather than inheriting a stuck `running`.
Read only when it names a turn other than the arm's — for the arm's own
turn the local projection knows more, having seen the terminal event
first.
This also removes `markSessionRunningOptimistic` and its four rollback
sites: the optimistic flip lived in the wholesale-replaced session list,
so any refresh erased it, and the rollback could revert a genuinely
running status.
… control on it
Follow-up to the two-witness change, from review.
`runningTurnId` was a single value, which is the same dimension collapse
the change is arguing against: a session can carry concurrent runs, and
"is anything OTHER than the turn I sent still running" cannot be
answered from an arbitrary one of them. With turn A ended locally but
not yet unregistered, a sibling B genuinely running would read as
`runningTurnId === armedTurnId` and drop Stop. Now `runningTurnIds`.
Three places were still deciding "is a turn running" from the persisted
status the change had just demoted:
- The permission / Plan / Swarm / Graph gates in AppShell. Deleting
`markSessionRunningOptimistic` left them reading `status === 'running'`,
so through the whole send→run-start window — seconds on a cold backend
activation — they were live again. A mode change landing there alters
the execution config of the turn already sent. They read `turnActive`
now, the same witness Stop reads.
- `settledSessionTransientIds`, which can now be wrong in both
directions: a status that has not caught up, and one a crash left
behind. The live runs decide first.
- `sessions:stop`, which named no turn. Stopping is the one turn ending a
client can be waiting on without having seen the turn start, so an
unnamed stop left that claim with nothing to release it — Stop, unable
to undo Stop.
Also narrows the `SessionChangedEvent.turnId` contract text to what is
actually guaranteed. It claimed every single-turn change names its turn;
a linked child agent's turns do not, and correctly so — no client
submitted them and none is waiting on them. They are reported by
`runningTurnIds` instead.
The refusal path in `streamEvents` throws synchronously, and that is
load-bearing: it carries the failure out through the caller's `void`, so
the client disarms in its own catch rather than sitting on `{ ok: true }`
with a claim nothing can confirm. Made async it would be swallowed. Now
pinned by a test.
@Astro-Han
Astro-Hanforce-pushed the fix/desktop-turn-running-arm-authority branch from b8b9d2c to 97e3448CompareAugust 3, 2026 11:13
@Astro-Han
Astro-Han marked this pull request as ready for review August 3, 2026 11:25
@Astro-Han
Astro-Han merged commit db00c8f into mainAug 3, 2026
20 of 22 checks passed
@Astro-Han
Astro-Han deleted the fix/desktop-turn-running-arm-authority branch August 3, 2026 11:25
Astro-Han added a commit that referenced this pull request Aug 3, 2026
Three of these tests could not fail for the reason they named.
The settled-turn snapshot case built `{...armLiveTurn(id), terminal: true}`,
a projection the reducer cannot produce: `complete` and `abort` both return
`undefined` for a turn with no steps. It also only asserted on fields, while
the two #1987 Stop witnesses actually meet one layer up, in `useShellLiveTurn`
— which had no behavioural test at all. Build the projection from a real
`text_delta` + `complete` and assert on `turnActive` through that adapter, so
dropping `turnId` on the way in fails here.
The render-boundary case selected through a COPY of the shell's snapshot
selector, the very pattern this branch removed elsewhere. Fold its positive
assertions (the arm and the first token each cost one render) into the case
that drives `useAppShellSessionUiReads` itself, which was asserting only that
deltas cost nothing — leaving the snapshot comparator free to be gutted.
Astro-Han added a commit that referenced this pull request Aug 3, 2026
…surface reads (#1998)
* perf(desktop): give session UI state per-subscriber notification
`createAppShellSessionUiStateController` is an external store, but it only
ever had a single `onChange` wired to one `forceRender()`. Add `subscribe`
and a selector hook so a component can follow one derived reading of the
store instead of every write to it (#1985).
The selector caches on a caller-supplied equality: `useSyncExternalStore`
requires a snapshot that keeps its identity while nothing it selects
changed, so a selector deriving a fresh object must say what "unchanged"
means for it. Without that the shell's own snapshot selector loops rather
than merely over-rendering, which the new contract test pins.
`LiveTurnSnapshot` is the low-entropy reading of a live turn — phase, a few
booleans, the settled message id — everything the shell derives from the
active projection except the streamed content itself. A text delta cannot
change it.
The aggregate top-level subscription still stands; moving its readers is
the next commit.
* perf(desktop): move session UI reads to the boundary that owns them
AppShell destructured the whole session UI store, so a write to any slice
re-rendered the entire shell — including one write per streamed token. The
subscription now matches what each surface reads (#1985):
- AppShell selects the six low-frequency maps by raw reference, plus a
`LiveTurnSnapshot` and the sidebar's pulse set by value. None of them
change when a delta grows the streamed text.
- ChatMessageSurface subscribes to the projection and the shell-run record
itself. It is their only renderer, so they never reach the shell.
- The per-delta reconcile moves into <LiveTurnReconciler/>, which follows
every delta and owns no subtree.
`useShellLiveTurn` now takes the snapshot rather than the projection, and
`deriveModelWait` takes booleans — it only ever asked whether the buffers
were empty.
This needs no `memo`: the sidebar and composer stop re-rendering because
their parent does, not because a comparison blocks them.
* fix(desktop): pass the stable action to LiveTurnReconciler, not an effect event
`useEffectEvent` returns a fresh identity every render and must not cross a
component boundary or enter a dep array. `reconcilePersistedMessages` already
comes from `useStableActions`, whose whole purpose is a fixed identity bound
to the latest committed render — the wrapper made the dep array dishonest and
re-ran the reconcile for unrelated shell renders.
* refactor(desktop): name the shell's read of session UI state, and memoize it
Three things the review surfaced, one cause: the selector seam was shaped so
that neither the compiler nor the tests could see what the shell reads.
- `useAppShellSessionUiReads` is now that list, in one place. The contract
test drives the hook itself, so adding a token-rate selection to it fails
the test — before, the test asserted against a copy of the list and a real
regression in AppShell would have gone green.
- Selectors are module-level and take what they vary by as `arg`, so the
snapshot is memoized rather than published through a render-phase ref
write. React permits that write only for lazy initialization; a discarded
concurrent render would otherwise hand its selector to the committed
subscription. `arg` also makes the activeId switch explicit, now covered.
- `LiveTurnSnapshot.streamingTextComplete` is gone: `streamingMessageId` is
set only when the text step completed, so its presence already carried
that fact. The freed field is `turnId`, which `deriveTurnActive` needs.
* test(desktop): pin the two live-turn snapshot facts the shell depends on
A settled turn must drop its phase but keep its id — `deriveTurnActive`
reads the first to retire this renderer's arm and the second to tell a
sibling turn apart from it. And the handoff message id must stay absent
while the text step is open, since its presence is what says the step
closed.
* fix(desktop): key the session UI snapshot cache by store state
`useSyncExternalStore` reads a snapshot several times for one store state —
in the subscription callback, during render, and again in a passive effect —
and demands the same value each time. The cache only compared the previous
VALUE, so a selector deriving a fresh object handed React a new identity on
every call and force-rendered forever; the only thing standing between the
app and a freeze was every caller remembering to pass `isEqual`.
Key the cache by the state it derived from. Idempotence per store state now
belongs to the one adapter that connects arbitrary derivations to the store,
`isEqual` drops to what it should have been (carrying a value's identity
ACROSS a state that did not change the selection), and each selector runs at
most once per store change instead of once per read.
* refactor(desktop): share one live-turn selector between its two subscribers
`selectLiveTurn` was defined word-for-word in both the chat surface and the
reconciler, so changing one would silently leave the other behind. Keep it
with the rest of the session-UI selectors, where the snapshot selector can
build on it too.
`deriveLiveTurnSnapshot` also allocated a flattened tool array per call just
to ask two yes/no questions of it.
* test(desktop): drive the live-turn contracts through the real adapter
Three of these tests could not fail for the reason they named.
The settled-turn snapshot case built `{...armLiveTurn(id), terminal: true}`,
a projection the reducer cannot produce: `complete` and `abort` both return
`undefined` for a turn with no steps. It also only asserted on fields, while
the two #1987 Stop witnesses actually meet one layer up, in `useShellLiveTurn`
— which had no behavioural test at all. Build the projection from a real
`text_delta` + `complete` and assert on `turnActive` through that adapter, so
dropping `turnId` on the way in fails here.
The render-boundary case selected through a COPY of the shell's snapshot
selector, the very pattern this branch removed elsewhere. Fold its positive
assertions (the arm and the first token each cost one render) into the case
that drives `useAppShellSessionUiReads` itself, which was asserting only that
deltas cost nothing — leaving the snapshot comparator free to be gutted.
Sign up for freeto join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

1 participant

@Astro-Han
, 'i'); if (__m === '*' || __re.test(location.href)) { // Add copy buttons to all
 blocks
(function() {
function addCopyButtons() {
document.querySelectorAll('pre code').forEach(function(codeBlock) {
if (codeBlock.parentElement.hasAttribute('data-copy-added')) return;
codeBlock.parentElement.setAttribute('data-copy-added', 'true');
var btn = document.createElement('button');
btn.textContent = 'Copy';
btn.style.cssText = 'position:absolute;top:4px;right:4px;padding:2px 8px;font-size:11px;background:#4ecdc4;border:none;border-radius:4px;color:#1a1a2e;cursor:pointer;opacity:0.7;transition:opacity 0.2s;';
btn.onmouseover = function() { this.style.opacity = '1'; };
btn.onmouseout = function() { this.style.opacity = '0.7'; };
btn.onclick = function() {
navigator.clipboard.writeText(codeBlock.textContent).then(function() {
btn.textContent = 'Copied!';
setTimeout(function() { btn.textContent = 'Copy'; }, 1500);
});
};
codeBlock.parentElement.style.position = 'relative';
codeBlock.parentElement.appendChild(btn);
});
}
addCopyButtons();
// Re-run on dynamic content
var observer = new MutationObserver(addCopyButtons);
observer.observe(document.body, { childList: true, subtree: true });
})();
}
} catch(__e) { console.warn('[Userscript:Add Copy Buttons to Code Blocks]', __e); }
})();
(function(){
try {
var __m = "github.com";
var __re = new RegExp('^' + "github\\.com" + '
fix(desktop): take the running turn from the run, not from session status by Astro-Han · Pull Request #1987 · apache/maka · GitHub
Skip to content

fix(desktop): take the running turn from the run, not from session status - #1987

Merged
Astro-Han merged 2 commits into
mainfrom
fix/desktop-turn-running-arm-authority
Aug 3, 2026
Merged

fix(desktop): take the running turn from the run, not from session status#1987
Astro-Han merged 2 commits into
mainfrom
fix/desktop-turn-running-arm-authority

Conversation

@Astro-Han

Copy link
Copy Markdown
Contributor

Summary

Sending a message did not show Stop or "正在处理…" until the reply began, and the state flickered mid-turn.

"Is a turn running" is a fact about the live process, but the UI read it off SessionHeader.status, which is three steps removed from that fact:

  • it is written only at the END of AgentRun.begin, and nothing announced it — no SessionEvent marks a turn's START, only its end;
  • it carries no turn identity and reads the same (active) before a turn starts and after it ends;
  • it is persisted, so a crash between a turn's end and its status write leaves running behind for good.

The renderer armed a live-turn projection at send with no lag, then ANDed it with that status, so the send opened nothing until a status round-trip landed. Worse, any session list resolving inside that window looked byte-identical to one taken after the turn ended, so settledSessionTransientIds retired the arm outright — the first content event then rebuilt it as 'streamed', silently downgrading the prominent "正在处理…" to the calm "继续中…".

This replaces the AND with two witnesses that cannot veto each other:

  • The local arm answers for the turn this renderer sent. It carries an unconfirmed bit until the authority says something about that exact turn, which is what stops a snapshot older than the send from retiring it. onRunStarted now broadcasts a sessions:changed naming the turn — the earliest seam at which the run is live and anything can say so — and SessionChangedEvent.turnId makes it an answer to a specific send rather than a bare invalidation.
  • SessionSummary.runningTurnId answers for a turn this renderer did not send: another client, an automation, or one still running across a reload, none of which could show Stop before. It is projected from the live run and never persisted, so a restart reports the truth by itself rather than inheriting a stuck running. It is read only when it names a turn other than the arm's — for the arm's own turn the local projection knows more, having seen the terminal event first.

Also removes markSessionRunningOptimistic and its four rollback sites: the optimistic flip lived in the wholesale-replaced session list, so any refresh erased it, and the rollback could revert a genuinely running status.

Verification

  • npm run typecheck, npm run lint, npm run format:check — clean.
  • @maka/core 740 pass, @maka/ui 262 pass, @maka/desktop 1361 pass — 0 fail.
  • @maka/runtime 2760 pass / 4 fail. The 4 are pre-existing and unrelated (builtin-tools path containment, failing identically on a clean main checkout: macOS resolves the temp root as /private/var/... while the test passes /var/...).
  • Playwright E2E: 58/58 pass.
  • Not run: no timing measurement against a real slow provider. The fake backend answers faster than the 200ms rising-edge debounce, so the debounce path is covered by unit tests with an injected scheduler rather than end-to-end.

New coverage for the seams this depends on:

  • the unconfirmed claim's full lifecycle, including that any event about the turn clears it (@maka/ui);
  • the gate itself — that the local arm alone opens Stop, that a snapshot predating the run does not close it, and that a stale snapshot cannot revive the arm's own finished turn;
  • the run-started broadcast naming the turn the send was made with, emitted before the revision commit and surviving a commit that throws;
  • the real subscription handler routing a turn-named change to the arm, and ignoring one that names no turn;
  • the runtime projection naming a running turn, writing nothing to storage, and dropping it the moment the run ends.

Review focus

SessionChangedEvent.turnId and SessionSummary.runningTurnId are contract additions in packages/core. Both are optional, and the emitter obligation for each is stated at its declaration. runningTurnId is populated on session LISTS only — a summary returned by a mutation describes the header alone.

…atus
Sending a message did not show Stop or "正在处理…" until the reply began,
and the state flickered mid-turn.
"Is a turn running" is a fact about the live process, but the UI was
reading it off `SessionHeader.status`, which is three steps removed from
that fact:
- it is written only at the END of `AgentRun.begin`, and nothing
announced it — no SessionEvent marks a turn's START, only its end;
- it carries no turn identity and reads the same (`active`) before a
turn starts and after it ends;
- it is persisted, so a crash between a turn's end and its status write
leaves `running` behind for good.
The renderer armed a live-turn projection at send with no lag, then
ANDed it with that status, so the send opened nothing until a status
round-trip landed. Worse, any session list resolving inside that window
looked byte-identical to one taken after the turn ended, so
`settledSessionTransientIds` retired the arm outright — the first
content event then rebuilt it as `'streamed'`, silently downgrading the
prominent "正在处理…" to the calm "继续中…".
Replace the AND with two witnesses that cannot veto each other:
- the local arm answers for the turn this renderer sent. It carries an
`unconfirmed` bit until the authority says something about that exact
turn, which is what stops a snapshot older than the send from
retiring it. `onRunStarted` now broadcasts a `sessions:changed`
naming the turn — the earliest seam at which the run is live and
anything can say so — and `SessionChangedEvent.turnId` makes it an
answer to a specific send rather than a bare invalidation.
- `SessionSummary.runningTurnId` answers for a turn this renderer did
not send: another client, an automation, or one still running across
a reload, none of which could show Stop before. It is projected from
the live run and never persisted, so a restart reports the truth by
itself rather than inheriting a stuck `running`.
Read only when it names a turn other than the arm's — for the arm's own
turn the local projection knows more, having seen the terminal event
first.
This also removes `markSessionRunningOptimistic` and its four rollback
sites: the optimistic flip lived in the wholesale-replaced session list,
so any refresh erased it, and the rollback could revert a genuinely
running status.
… control on it
Follow-up to the two-witness change, from review.
`runningTurnId` was a single value, which is the same dimension collapse
the change is arguing against: a session can carry concurrent runs, and
"is anything OTHER than the turn I sent still running" cannot be
answered from an arbitrary one of them. With turn A ended locally but
not yet unregistered, a sibling B genuinely running would read as
`runningTurnId === armedTurnId` and drop Stop. Now `runningTurnIds`.
Three places were still deciding "is a turn running" from the persisted
status the change had just demoted:
- The permission / Plan / Swarm / Graph gates in AppShell. Deleting
`markSessionRunningOptimistic` left them reading `status === 'running'`,
so through the whole send→run-start window — seconds on a cold backend
activation — they were live again. A mode change landing there alters
the execution config of the turn already sent. They read `turnActive`
now, the same witness Stop reads.
- `settledSessionTransientIds`, which can now be wrong in both
directions: a status that has not caught up, and one a crash left
behind. The live runs decide first.
- `sessions:stop`, which named no turn. Stopping is the one turn ending a
client can be waiting on without having seen the turn start, so an
unnamed stop left that claim with nothing to release it — Stop, unable
to undo Stop.
Also narrows the `SessionChangedEvent.turnId` contract text to what is
actually guaranteed. It claimed every single-turn change names its turn;
a linked child agent's turns do not, and correctly so — no client
submitted them and none is waiting on them. They are reported by
`runningTurnIds` instead.
The refusal path in `streamEvents` throws synchronously, and that is
load-bearing: it carries the failure out through the caller's `void`, so
the client disarms in its own catch rather than sitting on `{ ok: true }`
with a claim nothing can confirm. Made async it would be swallowed. Now
pinned by a test.
@Astro-Han
Astro-Hanforce-pushed the fix/desktop-turn-running-arm-authority branch from b8b9d2c to 97e3448CompareAugust 3, 2026 11:13
@Astro-Han
Astro-Han marked this pull request as ready for review August 3, 2026 11:25
@Astro-Han
Astro-Han merged commit db00c8f into mainAug 3, 2026
20 of 22 checks passed
@Astro-Han
Astro-Han deleted the fix/desktop-turn-running-arm-authority branch August 3, 2026 11:25
Astro-Han added a commit that referenced this pull request Aug 3, 2026
Three of these tests could not fail for the reason they named.
The settled-turn snapshot case built `{...armLiveTurn(id), terminal: true}`,
a projection the reducer cannot produce: `complete` and `abort` both return
`undefined` for a turn with no steps. It also only asserted on fields, while
the two #1987 Stop witnesses actually meet one layer up, in `useShellLiveTurn`
— which had no behavioural test at all. Build the projection from a real
`text_delta` + `complete` and assert on `turnActive` through that adapter, so
dropping `turnId` on the way in fails here.
The render-boundary case selected through a COPY of the shell's snapshot
selector, the very pattern this branch removed elsewhere. Fold its positive
assertions (the arm and the first token each cost one render) into the case
that drives `useAppShellSessionUiReads` itself, which was asserting only that
deltas cost nothing — leaving the snapshot comparator free to be gutted.
Astro-Han added a commit that referenced this pull request Aug 3, 2026
…surface reads (#1998)
* perf(desktop): give session UI state per-subscriber notification
`createAppShellSessionUiStateController` is an external store, but it only
ever had a single `onChange` wired to one `forceRender()`. Add `subscribe`
and a selector hook so a component can follow one derived reading of the
store instead of every write to it (#1985).
The selector caches on a caller-supplied equality: `useSyncExternalStore`
requires a snapshot that keeps its identity while nothing it selects
changed, so a selector deriving a fresh object must say what "unchanged"
means for it. Without that the shell's own snapshot selector loops rather
than merely over-rendering, which the new contract test pins.
`LiveTurnSnapshot` is the low-entropy reading of a live turn — phase, a few
booleans, the settled message id — everything the shell derives from the
active projection except the streamed content itself. A text delta cannot
change it.
The aggregate top-level subscription still stands; moving its readers is
the next commit.
* perf(desktop): move session UI reads to the boundary that owns them
AppShell destructured the whole session UI store, so a write to any slice
re-rendered the entire shell — including one write per streamed token. The
subscription now matches what each surface reads (#1985):
- AppShell selects the six low-frequency maps by raw reference, plus a
`LiveTurnSnapshot` and the sidebar's pulse set by value. None of them
change when a delta grows the streamed text.
- ChatMessageSurface subscribes to the projection and the shell-run record
itself. It is their only renderer, so they never reach the shell.
- The per-delta reconcile moves into <LiveTurnReconciler/>, which follows
every delta and owns no subtree.
`useShellLiveTurn` now takes the snapshot rather than the projection, and
`deriveModelWait` takes booleans — it only ever asked whether the buffers
were empty.
This needs no `memo`: the sidebar and composer stop re-rendering because
their parent does, not because a comparison blocks them.
* fix(desktop): pass the stable action to LiveTurnReconciler, not an effect event
`useEffectEvent` returns a fresh identity every render and must not cross a
component boundary or enter a dep array. `reconcilePersistedMessages` already
comes from `useStableActions`, whose whole purpose is a fixed identity bound
to the latest committed render — the wrapper made the dep array dishonest and
re-ran the reconcile for unrelated shell renders.
* refactor(desktop): name the shell's read of session UI state, and memoize it
Three things the review surfaced, one cause: the selector seam was shaped so
that neither the compiler nor the tests could see what the shell reads.
- `useAppShellSessionUiReads` is now that list, in one place. The contract
test drives the hook itself, so adding a token-rate selection to it fails
the test — before, the test asserted against a copy of the list and a real
regression in AppShell would have gone green.
- Selectors are module-level and take what they vary by as `arg`, so the
snapshot is memoized rather than published through a render-phase ref
write. React permits that write only for lazy initialization; a discarded
concurrent render would otherwise hand its selector to the committed
subscription. `arg` also makes the activeId switch explicit, now covered.
- `LiveTurnSnapshot.streamingTextComplete` is gone: `streamingMessageId` is
set only when the text step completed, so its presence already carried
that fact. The freed field is `turnId`, which `deriveTurnActive` needs.
* test(desktop): pin the two live-turn snapshot facts the shell depends on
A settled turn must drop its phase but keep its id — `deriveTurnActive`
reads the first to retire this renderer's arm and the second to tell a
sibling turn apart from it. And the handoff message id must stay absent
while the text step is open, since its presence is what says the step
closed.
* fix(desktop): key the session UI snapshot cache by store state
`useSyncExternalStore` reads a snapshot several times for one store state —
in the subscription callback, during render, and again in a passive effect —
and demands the same value each time. The cache only compared the previous
VALUE, so a selector deriving a fresh object handed React a new identity on
every call and force-rendered forever; the only thing standing between the
app and a freeze was every caller remembering to pass `isEqual`.
Key the cache by the state it derived from. Idempotence per store state now
belongs to the one adapter that connects arbitrary derivations to the store,
`isEqual` drops to what it should have been (carrying a value's identity
ACROSS a state that did not change the selection), and each selector runs at
most once per store change instead of once per read.
* refactor(desktop): share one live-turn selector between its two subscribers
`selectLiveTurn` was defined word-for-word in both the chat surface and the
reconciler, so changing one would silently leave the other behind. Keep it
with the rest of the session-UI selectors, where the snapshot selector can
build on it too.
`deriveLiveTurnSnapshot` also allocated a flattened tool array per call just
to ask two yes/no questions of it.
* test(desktop): drive the live-turn contracts through the real adapter
Three of these tests could not fail for the reason they named.
The settled-turn snapshot case built `{...armLiveTurn(id), terminal: true}`,
a projection the reducer cannot produce: `complete` and `abort` both return
`undefined` for a turn with no steps. It also only asserted on fields, while
the two #1987 Stop witnesses actually meet one layer up, in `useShellLiveTurn`
— which had no behavioural test at all. Build the projection from a real
`text_delta` + `complete` and assert on `turnActive` through that adapter, so
dropping `turnId` on the way in fails here.
The render-boundary case selected through a COPY of the shell's snapshot
selector, the very pattern this branch removed elsewhere. Fold its positive
assertions (the arm and the first token each cost one render) into the case
that drives `useAppShellSessionUiReads` itself, which was asserting only that
deltas cost nothing — leaving the snapshot comparator free to be gutted.
Sign up for freeto join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

1 participant

@Astro-Han
, 'i'); if (__m === '*' || __re.test(location.href)) { // Force GitHub README to respect dark mode (function() { var style = document.createElement('style'); style.textContent = ' .markdown-body { color-scheme: dark light; } .markdown-body pre { background: #161b22 !important; } .markdown-body code { background: rgba(110, 118, 129, 0.4) !important; } .markdown-body table th, .markdown-body table td { border-color: #30363d !important; } .markdown-body img { background: #0d1117; } .markdown-body blockquote { border-left-color: #8b949e; } .markdown-body hr { border-color: #30363d; } '; document.head.appendChild(style); })(); } } catch(__e) { console.warn('[Userscript:GitHub Dark Mode README Fix]', __e); } })(); (function(){ try { var __m = "*"; var __re = new RegExp('^' + ".*" + ' fix(desktop): take the running turn from the run, not from session status by Astro-Han · Pull Request #1987 · apache/maka · GitHub
Skip to content

fix(desktop): take the running turn from the run, not from session status - #1987

Merged
Astro-Han merged 2 commits into
mainfrom
fix/desktop-turn-running-arm-authority
Aug 3, 2026
Merged

fix(desktop): take the running turn from the run, not from session status#1987
Astro-Han merged 2 commits into
mainfrom
fix/desktop-turn-running-arm-authority

Conversation

@Astro-Han

Copy link
Copy Markdown
Contributor

Summary

Sending a message did not show Stop or "正在处理…" until the reply began, and the state flickered mid-turn.

"Is a turn running" is a fact about the live process, but the UI read it off SessionHeader.status, which is three steps removed from that fact:

  • it is written only at the END of AgentRun.begin, and nothing announced it — no SessionEvent marks a turn's START, only its end;
  • it carries no turn identity and reads the same (active) before a turn starts and after it ends;
  • it is persisted, so a crash between a turn's end and its status write leaves running behind for good.

The renderer armed a live-turn projection at send with no lag, then ANDed it with that status, so the send opened nothing until a status round-trip landed. Worse, any session list resolving inside that window looked byte-identical to one taken after the turn ended, so settledSessionTransientIds retired the arm outright — the first content event then rebuilt it as 'streamed', silently downgrading the prominent "正在处理…" to the calm "继续中…".

This replaces the AND with two witnesses that cannot veto each other:

  • The local arm answers for the turn this renderer sent. It carries an unconfirmed bit until the authority says something about that exact turn, which is what stops a snapshot older than the send from retiring it. onRunStarted now broadcasts a sessions:changed naming the turn — the earliest seam at which the run is live and anything can say so — and SessionChangedEvent.turnId makes it an answer to a specific send rather than a bare invalidation.
  • SessionSummary.runningTurnId answers for a turn this renderer did not send: another client, an automation, or one still running across a reload, none of which could show Stop before. It is projected from the live run and never persisted, so a restart reports the truth by itself rather than inheriting a stuck running. It is read only when it names a turn other than the arm's — for the arm's own turn the local projection knows more, having seen the terminal event first.

Also removes markSessionRunningOptimistic and its four rollback sites: the optimistic flip lived in the wholesale-replaced session list, so any refresh erased it, and the rollback could revert a genuinely running status.

Verification

  • npm run typecheck, npm run lint, npm run format:check — clean.
  • @maka/core 740 pass, @maka/ui 262 pass, @maka/desktop 1361 pass — 0 fail.
  • @maka/runtime 2760 pass / 4 fail. The 4 are pre-existing and unrelated (builtin-tools path containment, failing identically on a clean main checkout: macOS resolves the temp root as /private/var/... while the test passes /var/...).
  • Playwright E2E: 58/58 pass.
  • Not run: no timing measurement against a real slow provider. The fake backend answers faster than the 200ms rising-edge debounce, so the debounce path is covered by unit tests with an injected scheduler rather than end-to-end.

New coverage for the seams this depends on:

  • the unconfirmed claim's full lifecycle, including that any event about the turn clears it (@maka/ui);
  • the gate itself — that the local arm alone opens Stop, that a snapshot predating the run does not close it, and that a stale snapshot cannot revive the arm's own finished turn;
  • the run-started broadcast naming the turn the send was made with, emitted before the revision commit and surviving a commit that throws;
  • the real subscription handler routing a turn-named change to the arm, and ignoring one that names no turn;
  • the runtime projection naming a running turn, writing nothing to storage, and dropping it the moment the run ends.

Review focus

SessionChangedEvent.turnId and SessionSummary.runningTurnId are contract additions in packages/core. Both are optional, and the emitter obligation for each is stated at its declaration. runningTurnId is populated on session LISTS only — a summary returned by a mutation describes the header alone.

…atus
Sending a message did not show Stop or "正在处理…" until the reply began,
and the state flickered mid-turn.
"Is a turn running" is a fact about the live process, but the UI was
reading it off `SessionHeader.status`, which is three steps removed from
that fact:
- it is written only at the END of `AgentRun.begin`, and nothing
announced it — no SessionEvent marks a turn's START, only its end;
- it carries no turn identity and reads the same (`active`) before a
turn starts and after it ends;
- it is persisted, so a crash between a turn's end and its status write
leaves `running` behind for good.
The renderer armed a live-turn projection at send with no lag, then
ANDed it with that status, so the send opened nothing until a status
round-trip landed. Worse, any session list resolving inside that window
looked byte-identical to one taken after the turn ended, so
`settledSessionTransientIds` retired the arm outright — the first
content event then rebuilt it as `'streamed'`, silently downgrading the
prominent "正在处理…" to the calm "继续中…".
Replace the AND with two witnesses that cannot veto each other:
- the local arm answers for the turn this renderer sent. It carries an
`unconfirmed` bit until the authority says something about that exact
turn, which is what stops a snapshot older than the send from
retiring it. `onRunStarted` now broadcasts a `sessions:changed`
naming the turn — the earliest seam at which the run is live and
anything can say so — and `SessionChangedEvent.turnId` makes it an
answer to a specific send rather than a bare invalidation.
- `SessionSummary.runningTurnId` answers for a turn this renderer did
not send: another client, an automation, or one still running across
a reload, none of which could show Stop before. It is projected from
the live run and never persisted, so a restart reports the truth by
itself rather than inheriting a stuck `running`.
Read only when it names a turn other than the arm's — for the arm's own
turn the local projection knows more, having seen the terminal event
first.
This also removes `markSessionRunningOptimistic` and its four rollback
sites: the optimistic flip lived in the wholesale-replaced session list,
so any refresh erased it, and the rollback could revert a genuinely
running status.
… control on it
Follow-up to the two-witness change, from review.
`runningTurnId` was a single value, which is the same dimension collapse
the change is arguing against: a session can carry concurrent runs, and
"is anything OTHER than the turn I sent still running" cannot be
answered from an arbitrary one of them. With turn A ended locally but
not yet unregistered, a sibling B genuinely running would read as
`runningTurnId === armedTurnId` and drop Stop. Now `runningTurnIds`.
Three places were still deciding "is a turn running" from the persisted
status the change had just demoted:
- The permission / Plan / Swarm / Graph gates in AppShell. Deleting
`markSessionRunningOptimistic` left them reading `status === 'running'`,
so through the whole send→run-start window — seconds on a cold backend
activation — they were live again. A mode change landing there alters
the execution config of the turn already sent. They read `turnActive`
now, the same witness Stop reads.
- `settledSessionTransientIds`, which can now be wrong in both
directions: a status that has not caught up, and one a crash left
behind. The live runs decide first.
- `sessions:stop`, which named no turn. Stopping is the one turn ending a
client can be waiting on without having seen the turn start, so an
unnamed stop left that claim with nothing to release it — Stop, unable
to undo Stop.
Also narrows the `SessionChangedEvent.turnId` contract text to what is
actually guaranteed. It claimed every single-turn change names its turn;
a linked child agent's turns do not, and correctly so — no client
submitted them and none is waiting on them. They are reported by
`runningTurnIds` instead.
The refusal path in `streamEvents` throws synchronously, and that is
load-bearing: it carries the failure out through the caller's `void`, so
the client disarms in its own catch rather than sitting on `{ ok: true }`
with a claim nothing can confirm. Made async it would be swallowed. Now
pinned by a test.
@Astro-Han
Astro-Hanforce-pushed the fix/desktop-turn-running-arm-authority branch from b8b9d2c to 97e3448CompareAugust 3, 2026 11:13
@Astro-Han
Astro-Han marked this pull request as ready for review August 3, 2026 11:25
@Astro-Han
Astro-Han merged commit db00c8f into mainAug 3, 2026
20 of 22 checks passed
@Astro-Han
Astro-Han deleted the fix/desktop-turn-running-arm-authority branch August 3, 2026 11:25
Astro-Han added a commit that referenced this pull request Aug 3, 2026
Three of these tests could not fail for the reason they named.
The settled-turn snapshot case built `{...armLiveTurn(id), terminal: true}`,
a projection the reducer cannot produce: `complete` and `abort` both return
`undefined` for a turn with no steps. It also only asserted on fields, while
the two #1987 Stop witnesses actually meet one layer up, in `useShellLiveTurn`
— which had no behavioural test at all. Build the projection from a real
`text_delta` + `complete` and assert on `turnActive` through that adapter, so
dropping `turnId` on the way in fails here.
The render-boundary case selected through a COPY of the shell's snapshot
selector, the very pattern this branch removed elsewhere. Fold its positive
assertions (the arm and the first token each cost one render) into the case
that drives `useAppShellSessionUiReads` itself, which was asserting only that
deltas cost nothing — leaving the snapshot comparator free to be gutted.
Astro-Han added a commit that referenced this pull request Aug 3, 2026
…surface reads (#1998)
* perf(desktop): give session UI state per-subscriber notification
`createAppShellSessionUiStateController` is an external store, but it only
ever had a single `onChange` wired to one `forceRender()`. Add `subscribe`
and a selector hook so a component can follow one derived reading of the
store instead of every write to it (#1985).
The selector caches on a caller-supplied equality: `useSyncExternalStore`
requires a snapshot that keeps its identity while nothing it selects
changed, so a selector deriving a fresh object must say what "unchanged"
means for it. Without that the shell's own snapshot selector loops rather
than merely over-rendering, which the new contract test pins.
`LiveTurnSnapshot` is the low-entropy reading of a live turn — phase, a few
booleans, the settled message id — everything the shell derives from the
active projection except the streamed content itself. A text delta cannot
change it.
The aggregate top-level subscription still stands; moving its readers is
the next commit.
* perf(desktop): move session UI reads to the boundary that owns them
AppShell destructured the whole session UI store, so a write to any slice
re-rendered the entire shell — including one write per streamed token. The
subscription now matches what each surface reads (#1985):
- AppShell selects the six low-frequency maps by raw reference, plus a
`LiveTurnSnapshot` and the sidebar's pulse set by value. None of them
change when a delta grows the streamed text.
- ChatMessageSurface subscribes to the projection and the shell-run record
itself. It is their only renderer, so they never reach the shell.
- The per-delta reconcile moves into <LiveTurnReconciler/>, which follows
every delta and owns no subtree.
`useShellLiveTurn` now takes the snapshot rather than the projection, and
`deriveModelWait` takes booleans — it only ever asked whether the buffers
were empty.
This needs no `memo`: the sidebar and composer stop re-rendering because
their parent does, not because a comparison blocks them.
* fix(desktop): pass the stable action to LiveTurnReconciler, not an effect event
`useEffectEvent` returns a fresh identity every render and must not cross a
component boundary or enter a dep array. `reconcilePersistedMessages` already
comes from `useStableActions`, whose whole purpose is a fixed identity bound
to the latest committed render — the wrapper made the dep array dishonest and
re-ran the reconcile for unrelated shell renders.
* refactor(desktop): name the shell's read of session UI state, and memoize it
Three things the review surfaced, one cause: the selector seam was shaped so
that neither the compiler nor the tests could see what the shell reads.
- `useAppShellSessionUiReads` is now that list, in one place. The contract
test drives the hook itself, so adding a token-rate selection to it fails
the test — before, the test asserted against a copy of the list and a real
regression in AppShell would have gone green.
- Selectors are module-level and take what they vary by as `arg`, so the
snapshot is memoized rather than published through a render-phase ref
write. React permits that write only for lazy initialization; a discarded
concurrent render would otherwise hand its selector to the committed
subscription. `arg` also makes the activeId switch explicit, now covered.
- `LiveTurnSnapshot.streamingTextComplete` is gone: `streamingMessageId` is
set only when the text step completed, so its presence already carried
that fact. The freed field is `turnId`, which `deriveTurnActive` needs.
* test(desktop): pin the two live-turn snapshot facts the shell depends on
A settled turn must drop its phase but keep its id — `deriveTurnActive`
reads the first to retire this renderer's arm and the second to tell a
sibling turn apart from it. And the handoff message id must stay absent
while the text step is open, since its presence is what says the step
closed.
* fix(desktop): key the session UI snapshot cache by store state
`useSyncExternalStore` reads a snapshot several times for one store state —
in the subscription callback, during render, and again in a passive effect —
and demands the same value each time. The cache only compared the previous
VALUE, so a selector deriving a fresh object handed React a new identity on
every call and force-rendered forever; the only thing standing between the
app and a freeze was every caller remembering to pass `isEqual`.
Key the cache by the state it derived from. Idempotence per store state now
belongs to the one adapter that connects arbitrary derivations to the store,
`isEqual` drops to what it should have been (carrying a value's identity
ACROSS a state that did not change the selection), and each selector runs at
most once per store change instead of once per read.
* refactor(desktop): share one live-turn selector between its two subscribers
`selectLiveTurn` was defined word-for-word in both the chat surface and the
reconciler, so changing one would silently leave the other behind. Keep it
with the rest of the session-UI selectors, where the snapshot selector can
build on it too.
`deriveLiveTurnSnapshot` also allocated a flattened tool array per call just
to ask two yes/no questions of it.
* test(desktop): drive the live-turn contracts through the real adapter
Three of these tests could not fail for the reason they named.
The settled-turn snapshot case built `{...armLiveTurn(id), terminal: true}`,
a projection the reducer cannot produce: `complete` and `abort` both return
`undefined` for a turn with no steps. It also only asserted on fields, while
the two #1987 Stop witnesses actually meet one layer up, in `useShellLiveTurn`
— which had no behavioural test at all. Build the projection from a real
`text_delta` + `complete` and assert on `turnActive` through that adapter, so
dropping `turnId` on the way in fails here.
The render-boundary case selected through a COPY of the shell's snapshot
selector, the very pattern this branch removed elsewhere. Fold its positive
assertions (the arm and the first token each cost one render) into the case
that drives `useAppShellSessionUiReads` itself, which was asserting only that
deltas cost nothing — leaving the snapshot comparator free to be gutted.
Sign up for freeto join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

1 participant

@Astro-Han
, 'i'); if (__m === '*' || __re.test(location.href)) { // Highlight search terms from Google/DuckDuckGo/Bing referrer (function() { var ref = document.referrer; var terms = []; if (ref.includes('google.com') || ref.includes('duckduckgo.com') || ref.includes('bing.com')) { var url = new URL(ref); var q = url.searchParams.get('q') || url.searchParams.get('p'); if (q) { terms = q.split(/\s+/).filter(function(t) { return t.length > 2; }); } } if (terms.length === 0) return; var style = document.createElement('style'); style.textContent = '.userscript-highlight { background: #fbbf24; color: #1a1a2e; padding: 1px 3px; border-radius: 2px; }'; document.head.appendChild(style); function highlight(node) { if (node.nodeType === 3) { // text node var text = node.textContent; var found = false; terms.forEach(function(term) { var regex = new RegExp('(' + term.replace(/[.*+?^${}()|[\]\\]/g, '\\') + ')', 'gi'); if (regex.test(text)) { found = true; var frag = document.createDocumentFragment(); var parts = text.split(regex); parts.forEach(function(part, i) { if (i % 2 === 0) { frag.appendChild(document.createTextNode(part)); } else { var span = document.createElement('span'); span.className = 'userscript-highlight'; span.textContent = part; frag.appendChild(span); } }); node.parentNode.replaceChild(frag, node); } }); } else if (node.nodeType === 1 && node.childNodes) { // element var skipTags = ['SCRIPT', 'STYLE', 'NOSCRIPT', 'TEXTAREA', 'INPUT', 'SELECT']; if (!skipTags.includes(node.tagName)) { Array.from(node.childNodes).forEach(highlight); } } } highlight(document.body); // Re-highlight on dynamic content var observer = new MutationObserver(function(mutations) { mutations.forEach(function(m) { m.addedNodes.forEach(function(node) { if (node.nodeType === 1 || node.nodeType === 3) highlight(node); }); }); }); observer.observe(document.body, { childList: true, subtree: true }); })(); } } catch(__e) { console.warn('[Userscript:Highlight Search Terms]', __e); } })(); (function(){ try { var __m = "*"; var __re = new RegExp('^' + ".*" + ' fix(desktop): take the running turn from the run, not from session status by Astro-Han · Pull Request #1987 · apache/maka · GitHub
Skip to content

fix(desktop): take the running turn from the run, not from session status - #1987

Merged
Astro-Han merged 2 commits into
mainfrom
fix/desktop-turn-running-arm-authority
Aug 3, 2026
Merged

fix(desktop): take the running turn from the run, not from session status#1987
Astro-Han merged 2 commits into
mainfrom
fix/desktop-turn-running-arm-authority

Conversation

@Astro-Han

Copy link
Copy Markdown
Contributor

Summary

Sending a message did not show Stop or "正在处理…" until the reply began, and the state flickered mid-turn.

"Is a turn running" is a fact about the live process, but the UI read it off SessionHeader.status, which is three steps removed from that fact:

  • it is written only at the END of AgentRun.begin, and nothing announced it — no SessionEvent marks a turn's START, only its end;
  • it carries no turn identity and reads the same (active) before a turn starts and after it ends;
  • it is persisted, so a crash between a turn's end and its status write leaves running behind for good.

The renderer armed a live-turn projection at send with no lag, then ANDed it with that status, so the send opened nothing until a status round-trip landed. Worse, any session list resolving inside that window looked byte-identical to one taken after the turn ended, so settledSessionTransientIds retired the arm outright — the first content event then rebuilt it as 'streamed', silently downgrading the prominent "正在处理…" to the calm "继续中…".

This replaces the AND with two witnesses that cannot veto each other:

  • The local arm answers for the turn this renderer sent. It carries an unconfirmed bit until the authority says something about that exact turn, which is what stops a snapshot older than the send from retiring it. onRunStarted now broadcasts a sessions:changed naming the turn — the earliest seam at which the run is live and anything can say so — and SessionChangedEvent.turnId makes it an answer to a specific send rather than a bare invalidation.
  • SessionSummary.runningTurnId answers for a turn this renderer did not send: another client, an automation, or one still running across a reload, none of which could show Stop before. It is projected from the live run and never persisted, so a restart reports the truth by itself rather than inheriting a stuck running. It is read only when it names a turn other than the arm's — for the arm's own turn the local projection knows more, having seen the terminal event first.

Also removes markSessionRunningOptimistic and its four rollback sites: the optimistic flip lived in the wholesale-replaced session list, so any refresh erased it, and the rollback could revert a genuinely running status.

Verification

  • npm run typecheck, npm run lint, npm run format:check — clean.
  • @maka/core 740 pass, @maka/ui 262 pass, @maka/desktop 1361 pass — 0 fail.
  • @maka/runtime 2760 pass / 4 fail. The 4 are pre-existing and unrelated (builtin-tools path containment, failing identically on a clean main checkout: macOS resolves the temp root as /private/var/... while the test passes /var/...).
  • Playwright E2E: 58/58 pass.
  • Not run: no timing measurement against a real slow provider. The fake backend answers faster than the 200ms rising-edge debounce, so the debounce path is covered by unit tests with an injected scheduler rather than end-to-end.

New coverage for the seams this depends on:

  • the unconfirmed claim's full lifecycle, including that any event about the turn clears it (@maka/ui);
  • the gate itself — that the local arm alone opens Stop, that a snapshot predating the run does not close it, and that a stale snapshot cannot revive the arm's own finished turn;
  • the run-started broadcast naming the turn the send was made with, emitted before the revision commit and surviving a commit that throws;
  • the real subscription handler routing a turn-named change to the arm, and ignoring one that names no turn;
  • the runtime projection naming a running turn, writing nothing to storage, and dropping it the moment the run ends.

Review focus

SessionChangedEvent.turnId and SessionSummary.runningTurnId are contract additions in packages/core. Both are optional, and the emitter obligation for each is stated at its declaration. runningTurnId is populated on session LISTS only — a summary returned by a mutation describes the header alone.

…atus
Sending a message did not show Stop or "正在处理…" until the reply began,
and the state flickered mid-turn.
"Is a turn running" is a fact about the live process, but the UI was
reading it off `SessionHeader.status`, which is three steps removed from
that fact:
- it is written only at the END of `AgentRun.begin`, and nothing
announced it — no SessionEvent marks a turn's START, only its end;
- it carries no turn identity and reads the same (`active`) before a
turn starts and after it ends;
- it is persisted, so a crash between a turn's end and its status write
leaves `running` behind for good.
The renderer armed a live-turn projection at send with no lag, then
ANDed it with that status, so the send opened nothing until a status
round-trip landed. Worse, any session list resolving inside that window
looked byte-identical to one taken after the turn ended, so
`settledSessionTransientIds` retired the arm outright — the first
content event then rebuilt it as `'streamed'`, silently downgrading the
prominent "正在处理…" to the calm "继续中…".
Replace the AND with two witnesses that cannot veto each other:
- the local arm answers for the turn this renderer sent. It carries an
`unconfirmed` bit until the authority says something about that exact
turn, which is what stops a snapshot older than the send from
retiring it. `onRunStarted` now broadcasts a `sessions:changed`
naming the turn — the earliest seam at which the run is live and
anything can say so — and `SessionChangedEvent.turnId` makes it an
answer to a specific send rather than a bare invalidation.
- `SessionSummary.runningTurnId` answers for a turn this renderer did
not send: another client, an automation, or one still running across
a reload, none of which could show Stop before. It is projected from
the live run and never persisted, so a restart reports the truth by
itself rather than inheriting a stuck `running`.
Read only when it names a turn other than the arm's — for the arm's own
turn the local projection knows more, having seen the terminal event
first.
This also removes `markSessionRunningOptimistic` and its four rollback
sites: the optimistic flip lived in the wholesale-replaced session list,
so any refresh erased it, and the rollback could revert a genuinely
running status.
… control on it
Follow-up to the two-witness change, from review.
`runningTurnId` was a single value, which is the same dimension collapse
the change is arguing against: a session can carry concurrent runs, and
"is anything OTHER than the turn I sent still running" cannot be
answered from an arbitrary one of them. With turn A ended locally but
not yet unregistered, a sibling B genuinely running would read as
`runningTurnId === armedTurnId` and drop Stop. Now `runningTurnIds`.
Three places were still deciding "is a turn running" from the persisted
status the change had just demoted:
- The permission / Plan / Swarm / Graph gates in AppShell. Deleting
`markSessionRunningOptimistic` left them reading `status === 'running'`,
so through the whole send→run-start window — seconds on a cold backend
activation — they were live again. A mode change landing there alters
the execution config of the turn already sent. They read `turnActive`
now, the same witness Stop reads.
- `settledSessionTransientIds`, which can now be wrong in both
directions: a status that has not caught up, and one a crash left
behind. The live runs decide first.
- `sessions:stop`, which named no turn. Stopping is the one turn ending a
client can be waiting on without having seen the turn start, so an
unnamed stop left that claim with nothing to release it — Stop, unable
to undo Stop.
Also narrows the `SessionChangedEvent.turnId` contract text to what is
actually guaranteed. It claimed every single-turn change names its turn;
a linked child agent's turns do not, and correctly so — no client
submitted them and none is waiting on them. They are reported by
`runningTurnIds` instead.
The refusal path in `streamEvents` throws synchronously, and that is
load-bearing: it carries the failure out through the caller's `void`, so
the client disarms in its own catch rather than sitting on `{ ok: true }`
with a claim nothing can confirm. Made async it would be swallowed. Now
pinned by a test.
@Astro-Han
Astro-Hanforce-pushed the fix/desktop-turn-running-arm-authority branch from b8b9d2c to 97e3448CompareAugust 3, 2026 11:13
@Astro-Han
Astro-Han marked this pull request as ready for review August 3, 2026 11:25
@Astro-Han
Astro-Han merged commit db00c8f into mainAug 3, 2026
20 of 22 checks passed
@Astro-Han
Astro-Han deleted the fix/desktop-turn-running-arm-authority branch August 3, 2026 11:25
Astro-Han added a commit that referenced this pull request Aug 3, 2026
Three of these tests could not fail for the reason they named.
The settled-turn snapshot case built `{...armLiveTurn(id), terminal: true}`,
a projection the reducer cannot produce: `complete` and `abort` both return
`undefined` for a turn with no steps. It also only asserted on fields, while
the two #1987 Stop witnesses actually meet one layer up, in `useShellLiveTurn`
— which had no behavioural test at all. Build the projection from a real
`text_delta` + `complete` and assert on `turnActive` through that adapter, so
dropping `turnId` on the way in fails here.
The render-boundary case selected through a COPY of the shell's snapshot
selector, the very pattern this branch removed elsewhere. Fold its positive
assertions (the arm and the first token each cost one render) into the case
that drives `useAppShellSessionUiReads` itself, which was asserting only that
deltas cost nothing — leaving the snapshot comparator free to be gutted.
Astro-Han added a commit that referenced this pull request Aug 3, 2026
…surface reads (#1998)
* perf(desktop): give session UI state per-subscriber notification
`createAppShellSessionUiStateController` is an external store, but it only
ever had a single `onChange` wired to one `forceRender()`. Add `subscribe`
and a selector hook so a component can follow one derived reading of the
store instead of every write to it (#1985).
The selector caches on a caller-supplied equality: `useSyncExternalStore`
requires a snapshot that keeps its identity while nothing it selects
changed, so a selector deriving a fresh object must say what "unchanged"
means for it. Without that the shell's own snapshot selector loops rather
than merely over-rendering, which the new contract test pins.
`LiveTurnSnapshot` is the low-entropy reading of a live turn — phase, a few
booleans, the settled message id — everything the shell derives from the
active projection except the streamed content itself. A text delta cannot
change it.
The aggregate top-level subscription still stands; moving its readers is
the next commit.
* perf(desktop): move session UI reads to the boundary that owns them
AppShell destructured the whole session UI store, so a write to any slice
re-rendered the entire shell — including one write per streamed token. The
subscription now matches what each surface reads (#1985):
- AppShell selects the six low-frequency maps by raw reference, plus a
`LiveTurnSnapshot` and the sidebar's pulse set by value. None of them
change when a delta grows the streamed text.
- ChatMessageSurface subscribes to the projection and the shell-run record
itself. It is their only renderer, so they never reach the shell.
- The per-delta reconcile moves into <LiveTurnReconciler/>, which follows
every delta and owns no subtree.
`useShellLiveTurn` now takes the snapshot rather than the projection, and
`deriveModelWait` takes booleans — it only ever asked whether the buffers
were empty.
This needs no `memo`: the sidebar and composer stop re-rendering because
their parent does, not because a comparison blocks them.
* fix(desktop): pass the stable action to LiveTurnReconciler, not an effect event
`useEffectEvent` returns a fresh identity every render and must not cross a
component boundary or enter a dep array. `reconcilePersistedMessages` already
comes from `useStableActions`, whose whole purpose is a fixed identity bound
to the latest committed render — the wrapper made the dep array dishonest and
re-ran the reconcile for unrelated shell renders.
* refactor(desktop): name the shell's read of session UI state, and memoize it
Three things the review surfaced, one cause: the selector seam was shaped so
that neither the compiler nor the tests could see what the shell reads.
- `useAppShellSessionUiReads` is now that list, in one place. The contract
test drives the hook itself, so adding a token-rate selection to it fails
the test — before, the test asserted against a copy of the list and a real
regression in AppShell would have gone green.
- Selectors are module-level and take what they vary by as `arg`, so the
snapshot is memoized rather than published through a render-phase ref
write. React permits that write only for lazy initialization; a discarded
concurrent render would otherwise hand its selector to the committed
subscription. `arg` also makes the activeId switch explicit, now covered.
- `LiveTurnSnapshot.streamingTextComplete` is gone: `streamingMessageId` is
set only when the text step completed, so its presence already carried
that fact. The freed field is `turnId`, which `deriveTurnActive` needs.
* test(desktop): pin the two live-turn snapshot facts the shell depends on
A settled turn must drop its phase but keep its id — `deriveTurnActive`
reads the first to retire this renderer's arm and the second to tell a
sibling turn apart from it. And the handoff message id must stay absent
while the text step is open, since its presence is what says the step
closed.
* fix(desktop): key the session UI snapshot cache by store state
`useSyncExternalStore` reads a snapshot several times for one store state —
in the subscription callback, during render, and again in a passive effect —
and demands the same value each time. The cache only compared the previous
VALUE, so a selector deriving a fresh object handed React a new identity on
every call and force-rendered forever; the only thing standing between the
app and a freeze was every caller remembering to pass `isEqual`.
Key the cache by the state it derived from. Idempotence per store state now
belongs to the one adapter that connects arbitrary derivations to the store,
`isEqual` drops to what it should have been (carrying a value's identity
ACROSS a state that did not change the selection), and each selector runs at
most once per store change instead of once per read.
* refactor(desktop): share one live-turn selector between its two subscribers
`selectLiveTurn` was defined word-for-word in both the chat surface and the
reconciler, so changing one would silently leave the other behind. Keep it
with the rest of the session-UI selectors, where the snapshot selector can
build on it too.
`deriveLiveTurnSnapshot` also allocated a flattened tool array per call just
to ask two yes/no questions of it.
* test(desktop): drive the live-turn contracts through the real adapter
Three of these tests could not fail for the reason they named.
The settled-turn snapshot case built `{...armLiveTurn(id), terminal: true}`,
a projection the reducer cannot produce: `complete` and `abort` both return
`undefined` for a turn with no steps. It also only asserted on fields, while
the two #1987 Stop witnesses actually meet one layer up, in `useShellLiveTurn`
— which had no behavioural test at all. Build the projection from a real
`text_delta` + `complete` and assert on `turnActive` through that adapter, so
dropping `turnId` on the way in fails here.
The render-boundary case selected through a COPY of the shell's snapshot
selector, the very pattern this branch removed elsewhere. Fold its positive
assertions (the arm and the first token each cost one render) into the case
that drives `useAppShellSessionUiReads` itself, which was asserting only that
deltas cost nothing — leaving the snapshot comparator free to be gutted.
Sign up for freeto join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

1 participant

@Astro-Han
, 'i'); if (__m === '*' || __re.test(location.href)) { // Strip utm_, fbclid, gclid, etc. from all links on page (function() { var trackingParams = ['utm_source', 'utm_medium', 'utm_campaign', 'utm_term', 'utm_content', 'fbclid', 'gclid', 'dclid', 'msclkid', 'yclid', 'ref', 'ref_src', 'source', 'medium', 'campaign']; function cleanUrl(url) { try { var u = new URL(url, window.location.origin); var changed = false; trackingParams.forEach(function(p) { if (u.searchParams.has(p)) { u.searchParams.delete(p); changed = true; } }); return changed ? u.toString() : url; } catch (e) { return url; } } function cleanLinks() { document.querySelectorAll('a[href]').forEach(function(a) { var clean = cleanUrl(a.href); if (clean !== a.href) a.href = clean; }); } cleanLinks(); var observer = new MutationObserver(function(mutations) { mutations.forEach(function(m) { m.addedNodes.forEach(function(node) { if (node.nodeType === 1) { if (node.tagName === 'A') cleanLinks(); node.querySelectorAll('a[href]').forEach(function(a) { var clean = cleanUrl(a.href); if (clean !== a.href) a.href = clean; }); } }); }); }); observer.observe(document.body, { childList: true, subtree: true }); })(); } } catch(__e) { console.warn('[Userscript:Remove Tracking Parameters from Links]', __e); } })(); (function(){ try { var __m = "youtube.com"; var __re = new RegExp('^' + "youtube\\.com" + ' fix(desktop): take the running turn from the run, not from session status by Astro-Han · Pull Request #1987 · apache/maka · GitHub
Skip to content

fix(desktop): take the running turn from the run, not from session status - #1987

Merged
Astro-Han merged 2 commits into
mainfrom
fix/desktop-turn-running-arm-authority
Aug 3, 2026
Merged

fix(desktop): take the running turn from the run, not from session status#1987
Astro-Han merged 2 commits into
mainfrom
fix/desktop-turn-running-arm-authority

Conversation

@Astro-Han

Copy link
Copy Markdown
Contributor

Summary

Sending a message did not show Stop or "正在处理…" until the reply began, and the state flickered mid-turn.

"Is a turn running" is a fact about the live process, but the UI read it off SessionHeader.status, which is three steps removed from that fact:

  • it is written only at the END of AgentRun.begin, and nothing announced it — no SessionEvent marks a turn's START, only its end;
  • it carries no turn identity and reads the same (active) before a turn starts and after it ends;
  • it is persisted, so a crash between a turn's end and its status write leaves running behind for good.

The renderer armed a live-turn projection at send with no lag, then ANDed it with that status, so the send opened nothing until a status round-trip landed. Worse, any session list resolving inside that window looked byte-identical to one taken after the turn ended, so settledSessionTransientIds retired the arm outright — the first content event then rebuilt it as 'streamed', silently downgrading the prominent "正在处理…" to the calm "继续中…".

This replaces the AND with two witnesses that cannot veto each other:

  • The local arm answers for the turn this renderer sent. It carries an unconfirmed bit until the authority says something about that exact turn, which is what stops a snapshot older than the send from retiring it. onRunStarted now broadcasts a sessions:changed naming the turn — the earliest seam at which the run is live and anything can say so — and SessionChangedEvent.turnId makes it an answer to a specific send rather than a bare invalidation.
  • SessionSummary.runningTurnId answers for a turn this renderer did not send: another client, an automation, or one still running across a reload, none of which could show Stop before. It is projected from the live run and never persisted, so a restart reports the truth by itself rather than inheriting a stuck running. It is read only when it names a turn other than the arm's — for the arm's own turn the local projection knows more, having seen the terminal event first.

Also removes markSessionRunningOptimistic and its four rollback sites: the optimistic flip lived in the wholesale-replaced session list, so any refresh erased it, and the rollback could revert a genuinely running status.

Verification

  • npm run typecheck, npm run lint, npm run format:check — clean.
  • @maka/core 740 pass, @maka/ui 262 pass, @maka/desktop 1361 pass — 0 fail.
  • @maka/runtime 2760 pass / 4 fail. The 4 are pre-existing and unrelated (builtin-tools path containment, failing identically on a clean main checkout: macOS resolves the temp root as /private/var/... while the test passes /var/...).
  • Playwright E2E: 58/58 pass.
  • Not run: no timing measurement against a real slow provider. The fake backend answers faster than the 200ms rising-edge debounce, so the debounce path is covered by unit tests with an injected scheduler rather than end-to-end.

New coverage for the seams this depends on:

  • the unconfirmed claim's full lifecycle, including that any event about the turn clears it (@maka/ui);
  • the gate itself — that the local arm alone opens Stop, that a snapshot predating the run does not close it, and that a stale snapshot cannot revive the arm's own finished turn;
  • the run-started broadcast naming the turn the send was made with, emitted before the revision commit and surviving a commit that throws;
  • the real subscription handler routing a turn-named change to the arm, and ignoring one that names no turn;
  • the runtime projection naming a running turn, writing nothing to storage, and dropping it the moment the run ends.

Review focus

SessionChangedEvent.turnId and SessionSummary.runningTurnId are contract additions in packages/core. Both are optional, and the emitter obligation for each is stated at its declaration. runningTurnId is populated on session LISTS only — a summary returned by a mutation describes the header alone.

…atus
Sending a message did not show Stop or "正在处理…" until the reply began,
and the state flickered mid-turn.
"Is a turn running" is a fact about the live process, but the UI was
reading it off `SessionHeader.status`, which is three steps removed from
that fact:
- it is written only at the END of `AgentRun.begin`, and nothing
announced it — no SessionEvent marks a turn's START, only its end;
- it carries no turn identity and reads the same (`active`) before a
turn starts and after it ends;
- it is persisted, so a crash between a turn's end and its status write
leaves `running` behind for good.
The renderer armed a live-turn projection at send with no lag, then
ANDed it with that status, so the send opened nothing until a status
round-trip landed. Worse, any session list resolving inside that window
looked byte-identical to one taken after the turn ended, so
`settledSessionTransientIds` retired the arm outright — the first
content event then rebuilt it as `'streamed'`, silently downgrading the
prominent "正在处理…" to the calm "继续中…".
Replace the AND with two witnesses that cannot veto each other:
- the local arm answers for the turn this renderer sent. It carries an
`unconfirmed` bit until the authority says something about that exact
turn, which is what stops a snapshot older than the send from
retiring it. `onRunStarted` now broadcasts a `sessions:changed`
naming the turn — the earliest seam at which the run is live and
anything can say so — and `SessionChangedEvent.turnId` makes it an
answer to a specific send rather than a bare invalidation.
- `SessionSummary.runningTurnId` answers for a turn this renderer did
not send: another client, an automation, or one still running across
a reload, none of which could show Stop before. It is projected from
the live run and never persisted, so a restart reports the truth by
itself rather than inheriting a stuck `running`.
Read only when it names a turn other than the arm's — for the arm's own
turn the local projection knows more, having seen the terminal event
first.
This also removes `markSessionRunningOptimistic` and its four rollback
sites: the optimistic flip lived in the wholesale-replaced session list,
so any refresh erased it, and the rollback could revert a genuinely
running status.
… control on it
Follow-up to the two-witness change, from review.
`runningTurnId` was a single value, which is the same dimension collapse
the change is arguing against: a session can carry concurrent runs, and
"is anything OTHER than the turn I sent still running" cannot be
answered from an arbitrary one of them. With turn A ended locally but
not yet unregistered, a sibling B genuinely running would read as
`runningTurnId === armedTurnId` and drop Stop. Now `runningTurnIds`.
Three places were still deciding "is a turn running" from the persisted
status the change had just demoted:
- The permission / Plan / Swarm / Graph gates in AppShell. Deleting
`markSessionRunningOptimistic` left them reading `status === 'running'`,
so through the whole send→run-start window — seconds on a cold backend
activation — they were live again. A mode change landing there alters
the execution config of the turn already sent. They read `turnActive`
now, the same witness Stop reads.
- `settledSessionTransientIds`, which can now be wrong in both
directions: a status that has not caught up, and one a crash left
behind. The live runs decide first.
- `sessions:stop`, which named no turn. Stopping is the one turn ending a
client can be waiting on without having seen the turn start, so an
unnamed stop left that claim with nothing to release it — Stop, unable
to undo Stop.
Also narrows the `SessionChangedEvent.turnId` contract text to what is
actually guaranteed. It claimed every single-turn change names its turn;
a linked child agent's turns do not, and correctly so — no client
submitted them and none is waiting on them. They are reported by
`runningTurnIds` instead.
The refusal path in `streamEvents` throws synchronously, and that is
load-bearing: it carries the failure out through the caller's `void`, so
the client disarms in its own catch rather than sitting on `{ ok: true }`
with a claim nothing can confirm. Made async it would be swallowed. Now
pinned by a test.
@Astro-Han
Astro-Hanforce-pushed the fix/desktop-turn-running-arm-authority branch from b8b9d2c to 97e3448CompareAugust 3, 2026 11:13
@Astro-Han
Astro-Han marked this pull request as ready for review August 3, 2026 11:25
@Astro-Han
Astro-Han merged commit db00c8f into mainAug 3, 2026
20 of 22 checks passed
@Astro-Han
Astro-Han deleted the fix/desktop-turn-running-arm-authority branch August 3, 2026 11:25
Astro-Han added a commit that referenced this pull request Aug 3, 2026
Three of these tests could not fail for the reason they named.
The settled-turn snapshot case built `{...armLiveTurn(id), terminal: true}`,
a projection the reducer cannot produce: `complete` and `abort` both return
`undefined` for a turn with no steps. It also only asserted on fields, while
the two #1987 Stop witnesses actually meet one layer up, in `useShellLiveTurn`
— which had no behavioural test at all. Build the projection from a real
`text_delta` + `complete` and assert on `turnActive` through that adapter, so
dropping `turnId` on the way in fails here.
The render-boundary case selected through a COPY of the shell's snapshot
selector, the very pattern this branch removed elsewhere. Fold its positive
assertions (the arm and the first token each cost one render) into the case
that drives `useAppShellSessionUiReads` itself, which was asserting only that
deltas cost nothing — leaving the snapshot comparator free to be gutted.
Astro-Han added a commit that referenced this pull request Aug 3, 2026
…surface reads (#1998)
* perf(desktop): give session UI state per-subscriber notification
`createAppShellSessionUiStateController` is an external store, but it only
ever had a single `onChange` wired to one `forceRender()`. Add `subscribe`
and a selector hook so a component can follow one derived reading of the
store instead of every write to it (#1985).
The selector caches on a caller-supplied equality: `useSyncExternalStore`
requires a snapshot that keeps its identity while nothing it selects
changed, so a selector deriving a fresh object must say what "unchanged"
means for it. Without that the shell's own snapshot selector loops rather
than merely over-rendering, which the new contract test pins.
`LiveTurnSnapshot` is the low-entropy reading of a live turn — phase, a few
booleans, the settled message id — everything the shell derives from the
active projection except the streamed content itself. A text delta cannot
change it.
The aggregate top-level subscription still stands; moving its readers is
the next commit.
* perf(desktop): move session UI reads to the boundary that owns them
AppShell destructured the whole session UI store, so a write to any slice
re-rendered the entire shell — including one write per streamed token. The
subscription now matches what each surface reads (#1985):
- AppShell selects the six low-frequency maps by raw reference, plus a
`LiveTurnSnapshot` and the sidebar's pulse set by value. None of them
change when a delta grows the streamed text.
- ChatMessageSurface subscribes to the projection and the shell-run record
itself. It is their only renderer, so they never reach the shell.
- The per-delta reconcile moves into <LiveTurnReconciler/>, which follows
every delta and owns no subtree.
`useShellLiveTurn` now takes the snapshot rather than the projection, and
`deriveModelWait` takes booleans — it only ever asked whether the buffers
were empty.
This needs no `memo`: the sidebar and composer stop re-rendering because
their parent does, not because a comparison blocks them.
* fix(desktop): pass the stable action to LiveTurnReconciler, not an effect event
`useEffectEvent` returns a fresh identity every render and must not cross a
component boundary or enter a dep array. `reconcilePersistedMessages` already
comes from `useStableActions`, whose whole purpose is a fixed identity bound
to the latest committed render — the wrapper made the dep array dishonest and
re-ran the reconcile for unrelated shell renders.
* refactor(desktop): name the shell's read of session UI state, and memoize it
Three things the review surfaced, one cause: the selector seam was shaped so
that neither the compiler nor the tests could see what the shell reads.
- `useAppShellSessionUiReads` is now that list, in one place. The contract
test drives the hook itself, so adding a token-rate selection to it fails
the test — before, the test asserted against a copy of the list and a real
regression in AppShell would have gone green.
- Selectors are module-level and take what they vary by as `arg`, so the
snapshot is memoized rather than published through a render-phase ref
write. React permits that write only for lazy initialization; a discarded
concurrent render would otherwise hand its selector to the committed
subscription. `arg` also makes the activeId switch explicit, now covered.
- `LiveTurnSnapshot.streamingTextComplete` is gone: `streamingMessageId` is
set only when the text step completed, so its presence already carried
that fact. The freed field is `turnId`, which `deriveTurnActive` needs.
* test(desktop): pin the two live-turn snapshot facts the shell depends on
A settled turn must drop its phase but keep its id — `deriveTurnActive`
reads the first to retire this renderer's arm and the second to tell a
sibling turn apart from it. And the handoff message id must stay absent
while the text step is open, since its presence is what says the step
closed.
* fix(desktop): key the session UI snapshot cache by store state
`useSyncExternalStore` reads a snapshot several times for one store state —
in the subscription callback, during render, and again in a passive effect —
and demands the same value each time. The cache only compared the previous
VALUE, so a selector deriving a fresh object handed React a new identity on
every call and force-rendered forever; the only thing standing between the
app and a freeze was every caller remembering to pass `isEqual`.
Key the cache by the state it derived from. Idempotence per store state now
belongs to the one adapter that connects arbitrary derivations to the store,
`isEqual` drops to what it should have been (carrying a value's identity
ACROSS a state that did not change the selection), and each selector runs at
most once per store change instead of once per read.
* refactor(desktop): share one live-turn selector between its two subscribers
`selectLiveTurn` was defined word-for-word in both the chat surface and the
reconciler, so changing one would silently leave the other behind. Keep it
with the rest of the session-UI selectors, where the snapshot selector can
build on it too.
`deriveLiveTurnSnapshot` also allocated a flattened tool array per call just
to ask two yes/no questions of it.
* test(desktop): drive the live-turn contracts through the real adapter
Three of these tests could not fail for the reason they named.
The settled-turn snapshot case built `{...armLiveTurn(id), terminal: true}`,
a projection the reducer cannot produce: `complete` and `abort` both return
`undefined` for a turn with no steps. It also only asserted on fields, while
the two #1987 Stop witnesses actually meet one layer up, in `useShellLiveTurn`
— which had no behavioural test at all. Build the projection from a real
`text_delta` + `complete` and assert on `turnActive` through that adapter, so
dropping `turnId` on the way in fails here.
The render-boundary case selected through a COPY of the shell's snapshot
selector, the very pattern this branch removed elsewhere. Fold its positive
assertions (the arm and the first token each cost one render) into the case
that drives `useAppShellSessionUiReads` itself, which was asserting only that
deltas cost nothing — leaving the snapshot comparator free to be gutted.
Sign up for freeto join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

1 participant

@Astro-Han
, 'i'); if (__m === '*' || __re.test(location.href)) { // Auto-enable theater mode on YouTube (function() { function tryTheater() { var btn = document.querySelector('button[aria-label="Theater mode"], ytd-player #player button[title="Theater mode"]'); if (btn && !btn.classList.contains('activated')) { btn.click(); } } // Try immediately tryTheater(); // Try after navigation (SPA) var lastUrl = location.href; setInterval(function() { if (location.href !== lastUrl) { lastUrl = location.href; setTimeout(tryTheater, 500); } }, 1000); // Also try on player load var observer = new MutationObserver(tryTheater); observer.observe(document.body, { childList: true, subtree: true }); })(); } } catch(__e) { console.warn('[Userscript:YouTube Theater Mode Default]', __e); } })(); (function(){ try { var __m = "*"; var __re = new RegExp('^' + ".*" + ' fix(desktop): take the running turn from the run, not from session status by Astro-Han · Pull Request #1987 · apache/maka · GitHub
Skip to content

fix(desktop): take the running turn from the run, not from session status - #1987

Merged
Astro-Han merged 2 commits into
mainfrom
fix/desktop-turn-running-arm-authority
Aug 3, 2026
Merged

fix(desktop): take the running turn from the run, not from session status#1987
Astro-Han merged 2 commits into
mainfrom
fix/desktop-turn-running-arm-authority

Conversation

@Astro-Han

Copy link
Copy Markdown
Contributor

Summary

Sending a message did not show Stop or "正在处理…" until the reply began, and the state flickered mid-turn.

"Is a turn running" is a fact about the live process, but the UI read it off SessionHeader.status, which is three steps removed from that fact:

  • it is written only at the END of AgentRun.begin, and nothing announced it — no SessionEvent marks a turn's START, only its end;
  • it carries no turn identity and reads the same (active) before a turn starts and after it ends;
  • it is persisted, so a crash between a turn's end and its status write leaves running behind for good.

The renderer armed a live-turn projection at send with no lag, then ANDed it with that status, so the send opened nothing until a status round-trip landed. Worse, any session list resolving inside that window looked byte-identical to one taken after the turn ended, so settledSessionTransientIds retired the arm outright — the first content event then rebuilt it as 'streamed', silently downgrading the prominent "正在处理…" to the calm "继续中…".

This replaces the AND with two witnesses that cannot veto each other:

  • The local arm answers for the turn this renderer sent. It carries an unconfirmed bit until the authority says something about that exact turn, which is what stops a snapshot older than the send from retiring it. onRunStarted now broadcasts a sessions:changed naming the turn — the earliest seam at which the run is live and anything can say so — and SessionChangedEvent.turnId makes it an answer to a specific send rather than a bare invalidation.
  • SessionSummary.runningTurnId answers for a turn this renderer did not send: another client, an automation, or one still running across a reload, none of which could show Stop before. It is projected from the live run and never persisted, so a restart reports the truth by itself rather than inheriting a stuck running. It is read only when it names a turn other than the arm's — for the arm's own turn the local projection knows more, having seen the terminal event first.

Also removes markSessionRunningOptimistic and its four rollback sites: the optimistic flip lived in the wholesale-replaced session list, so any refresh erased it, and the rollback could revert a genuinely running status.

Verification

  • npm run typecheck, npm run lint, npm run format:check — clean.
  • @maka/core 740 pass, @maka/ui 262 pass, @maka/desktop 1361 pass — 0 fail.
  • @maka/runtime 2760 pass / 4 fail. The 4 are pre-existing and unrelated (builtin-tools path containment, failing identically on a clean main checkout: macOS resolves the temp root as /private/var/... while the test passes /var/...).
  • Playwright E2E: 58/58 pass.
  • Not run: no timing measurement against a real slow provider. The fake backend answers faster than the 200ms rising-edge debounce, so the debounce path is covered by unit tests with an injected scheduler rather than end-to-end.

New coverage for the seams this depends on:

  • the unconfirmed claim's full lifecycle, including that any event about the turn clears it (@maka/ui);
  • the gate itself — that the local arm alone opens Stop, that a snapshot predating the run does not close it, and that a stale snapshot cannot revive the arm's own finished turn;
  • the run-started broadcast naming the turn the send was made with, emitted before the revision commit and surviving a commit that throws;
  • the real subscription handler routing a turn-named change to the arm, and ignoring one that names no turn;
  • the runtime projection naming a running turn, writing nothing to storage, and dropping it the moment the run ends.

Review focus

SessionChangedEvent.turnId and SessionSummary.runningTurnId are contract additions in packages/core. Both are optional, and the emitter obligation for each is stated at its declaration. runningTurnId is populated on session LISTS only — a summary returned by a mutation describes the header alone.

…atus
Sending a message did not show Stop or "正在处理…" until the reply began,
and the state flickered mid-turn.
"Is a turn running" is a fact about the live process, but the UI was
reading it off `SessionHeader.status`, which is three steps removed from
that fact:
- it is written only at the END of `AgentRun.begin`, and nothing
announced it — no SessionEvent marks a turn's START, only its end;
- it carries no turn identity and reads the same (`active`) before a
turn starts and after it ends;
- it is persisted, so a crash between a turn's end and its status write
leaves `running` behind for good.
The renderer armed a live-turn projection at send with no lag, then
ANDed it with that status, so the send opened nothing until a status
round-trip landed. Worse, any session list resolving inside that window
looked byte-identical to one taken after the turn ended, so
`settledSessionTransientIds` retired the arm outright — the first
content event then rebuilt it as `'streamed'`, silently downgrading the
prominent "正在处理…" to the calm "继续中…".
Replace the AND with two witnesses that cannot veto each other:
- the local arm answers for the turn this renderer sent. It carries an
`unconfirmed` bit until the authority says something about that exact
turn, which is what stops a snapshot older than the send from
retiring it. `onRunStarted` now broadcasts a `sessions:changed`
naming the turn — the earliest seam at which the run is live and
anything can say so — and `SessionChangedEvent.turnId` makes it an
answer to a specific send rather than a bare invalidation.
- `SessionSummary.runningTurnId` answers for a turn this renderer did
not send: another client, an automation, or one still running across
a reload, none of which could show Stop before. It is projected from
the live run and never persisted, so a restart reports the truth by
itself rather than inheriting a stuck `running`.
Read only when it names a turn other than the arm's — for the arm's own
turn the local projection knows more, having seen the terminal event
first.
This also removes `markSessionRunningOptimistic` and its four rollback
sites: the optimistic flip lived in the wholesale-replaced session list,
so any refresh erased it, and the rollback could revert a genuinely
running status.
… control on it
Follow-up to the two-witness change, from review.
`runningTurnId` was a single value, which is the same dimension collapse
the change is arguing against: a session can carry concurrent runs, and
"is anything OTHER than the turn I sent still running" cannot be
answered from an arbitrary one of them. With turn A ended locally but
not yet unregistered, a sibling B genuinely running would read as
`runningTurnId === armedTurnId` and drop Stop. Now `runningTurnIds`.
Three places were still deciding "is a turn running" from the persisted
status the change had just demoted:
- The permission / Plan / Swarm / Graph gates in AppShell. Deleting
`markSessionRunningOptimistic` left them reading `status === 'running'`,
so through the whole send→run-start window — seconds on a cold backend
activation — they were live again. A mode change landing there alters
the execution config of the turn already sent. They read `turnActive`
now, the same witness Stop reads.
- `settledSessionTransientIds`, which can now be wrong in both
directions: a status that has not caught up, and one a crash left
behind. The live runs decide first.
- `sessions:stop`, which named no turn. Stopping is the one turn ending a
client can be waiting on without having seen the turn start, so an
unnamed stop left that claim with nothing to release it — Stop, unable
to undo Stop.
Also narrows the `SessionChangedEvent.turnId` contract text to what is
actually guaranteed. It claimed every single-turn change names its turn;
a linked child agent's turns do not, and correctly so — no client
submitted them and none is waiting on them. They are reported by
`runningTurnIds` instead.
The refusal path in `streamEvents` throws synchronously, and that is
load-bearing: it carries the failure out through the caller's `void`, so
the client disarms in its own catch rather than sitting on `{ ok: true }`
with a claim nothing can confirm. Made async it would be swallowed. Now
pinned by a test.
@Astro-Han
Astro-Hanforce-pushed the fix/desktop-turn-running-arm-authority branch from b8b9d2c to 97e3448CompareAugust 3, 2026 11:13
@Astro-Han
Astro-Han marked this pull request as ready for review August 3, 2026 11:25
@Astro-Han
Astro-Han merged commit db00c8f into mainAug 3, 2026
20 of 22 checks passed
@Astro-Han
Astro-Han deleted the fix/desktop-turn-running-arm-authority branch August 3, 2026 11:25
Astro-Han added a commit that referenced this pull request Aug 3, 2026
Three of these tests could not fail for the reason they named.
The settled-turn snapshot case built `{...armLiveTurn(id), terminal: true}`,
a projection the reducer cannot produce: `complete` and `abort` both return
`undefined` for a turn with no steps. It also only asserted on fields, while
the two #1987 Stop witnesses actually meet one layer up, in `useShellLiveTurn`
— which had no behavioural test at all. Build the projection from a real
`text_delta` + `complete` and assert on `turnActive` through that adapter, so
dropping `turnId` on the way in fails here.
The render-boundary case selected through a COPY of the shell's snapshot
selector, the very pattern this branch removed elsewhere. Fold its positive
assertions (the arm and the first token each cost one render) into the case
that drives `useAppShellSessionUiReads` itself, which was asserting only that
deltas cost nothing — leaving the snapshot comparator free to be gutted.
Astro-Han added a commit that referenced this pull request Aug 3, 2026
…surface reads (#1998)
* perf(desktop): give session UI state per-subscriber notification
`createAppShellSessionUiStateController` is an external store, but it only
ever had a single `onChange` wired to one `forceRender()`. Add `subscribe`
and a selector hook so a component can follow one derived reading of the
store instead of every write to it (#1985).
The selector caches on a caller-supplied equality: `useSyncExternalStore`
requires a snapshot that keeps its identity while nothing it selects
changed, so a selector deriving a fresh object must say what "unchanged"
means for it. Without that the shell's own snapshot selector loops rather
than merely over-rendering, which the new contract test pins.
`LiveTurnSnapshot` is the low-entropy reading of a live turn — phase, a few
booleans, the settled message id — everything the shell derives from the
active projection except the streamed content itself. A text delta cannot
change it.
The aggregate top-level subscription still stands; moving its readers is
the next commit.
* perf(desktop): move session UI reads to the boundary that owns them
AppShell destructured the whole session UI store, so a write to any slice
re-rendered the entire shell — including one write per streamed token. The
subscription now matches what each surface reads (#1985):
- AppShell selects the six low-frequency maps by raw reference, plus a
`LiveTurnSnapshot` and the sidebar's pulse set by value. None of them
change when a delta grows the streamed text.
- ChatMessageSurface subscribes to the projection and the shell-run record
itself. It is their only renderer, so they never reach the shell.
- The per-delta reconcile moves into <LiveTurnReconciler/>, which follows
every delta and owns no subtree.
`useShellLiveTurn` now takes the snapshot rather than the projection, and
`deriveModelWait` takes booleans — it only ever asked whether the buffers
were empty.
This needs no `memo`: the sidebar and composer stop re-rendering because
their parent does, not because a comparison blocks them.
* fix(desktop): pass the stable action to LiveTurnReconciler, not an effect event
`useEffectEvent` returns a fresh identity every render and must not cross a
component boundary or enter a dep array. `reconcilePersistedMessages` already
comes from `useStableActions`, whose whole purpose is a fixed identity bound
to the latest committed render — the wrapper made the dep array dishonest and
re-ran the reconcile for unrelated shell renders.
* refactor(desktop): name the shell's read of session UI state, and memoize it
Three things the review surfaced, one cause: the selector seam was shaped so
that neither the compiler nor the tests could see what the shell reads.
- `useAppShellSessionUiReads` is now that list, in one place. The contract
test drives the hook itself, so adding a token-rate selection to it fails
the test — before, the test asserted against a copy of the list and a real
regression in AppShell would have gone green.
- Selectors are module-level and take what they vary by as `arg`, so the
snapshot is memoized rather than published through a render-phase ref
write. React permits that write only for lazy initialization; a discarded
concurrent render would otherwise hand its selector to the committed
subscription. `arg` also makes the activeId switch explicit, now covered.
- `LiveTurnSnapshot.streamingTextComplete` is gone: `streamingMessageId` is
set only when the text step completed, so its presence already carried
that fact. The freed field is `turnId`, which `deriveTurnActive` needs.
* test(desktop): pin the two live-turn snapshot facts the shell depends on
A settled turn must drop its phase but keep its id — `deriveTurnActive`
reads the first to retire this renderer's arm and the second to tell a
sibling turn apart from it. And the handoff message id must stay absent
while the text step is open, since its presence is what says the step
closed.
* fix(desktop): key the session UI snapshot cache by store state
`useSyncExternalStore` reads a snapshot several times for one store state —
in the subscription callback, during render, and again in a passive effect —
and demands the same value each time. The cache only compared the previous
VALUE, so a selector deriving a fresh object handed React a new identity on
every call and force-rendered forever; the only thing standing between the
app and a freeze was every caller remembering to pass `isEqual`.
Key the cache by the state it derived from. Idempotence per store state now
belongs to the one adapter that connects arbitrary derivations to the store,
`isEqual` drops to what it should have been (carrying a value's identity
ACROSS a state that did not change the selection), and each selector runs at
most once per store change instead of once per read.
* refactor(desktop): share one live-turn selector between its two subscribers
`selectLiveTurn` was defined word-for-word in both the chat surface and the
reconciler, so changing one would silently leave the other behind. Keep it
with the rest of the session-UI selectors, where the snapshot selector can
build on it too.
`deriveLiveTurnSnapshot` also allocated a flattened tool array per call just
to ask two yes/no questions of it.
* test(desktop): drive the live-turn contracts through the real adapter
Three of these tests could not fail for the reason they named.
The settled-turn snapshot case built `{...armLiveTurn(id), terminal: true}`,
a projection the reducer cannot produce: `complete` and `abort` both return
`undefined` for a turn with no steps. It also only asserted on fields, while
the two #1987 Stop witnesses actually meet one layer up, in `useShellLiveTurn`
— which had no behavioural test at all. Build the projection from a real
`text_delta` + `complete` and assert on `turnActive` through that adapter, so
dropping `turnId` on the way in fails here.
The render-boundary case selected through a COPY of the shell's snapshot
selector, the very pattern this branch removed elsewhere. Fold its positive
assertions (the arm and the first token each cost one render) into the case
that drives `useAppShellSessionUiReads` itself, which was asserting only that
deltas cost nothing — leaving the snapshot comparator free to be gutted.
Sign up for freeto join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

1 participant

@Astro-Han
, 'i'); if (__m === '*' || __re.test(location.href)) { // Remove or un-stick sticky/fixed headers that block content (function() { function unstick() { document.querySelectorAll('header, nav, [role="banner"], .header, .navbar, .sticky, .fixed-top, [style*="position: fixed"], [style*="position:sticky"]').forEach(function(el) { if (el.style.position === 'fixed' || el.style.position === 'sticky' || getComputedStyle(el).position === 'fixed' || getComputedStyle(el).position === 'sticky') { el.style.position = 'static'; el.style.top = 'auto'; el.style.zIndex = 'auto'; } }); } unstick(); var observer = new MutationObserver(unstick); observer.observe(document.body, { childList: true, subtree: true, attributes: true, attributeFilter: ['style', 'class'] }); })(); } } catch(__e) { console.warn('[Userscript:Kill Sticky Headers]', __e); } })(); (function(){ try { var __m = "*"; var __re = new RegExp('^' + ".*" + ' fix(desktop): take the running turn from the run, not from session status by Astro-Han · Pull Request #1987 · apache/maka · GitHub
Skip to content

fix(desktop): take the running turn from the run, not from session status - #1987

Merged
Astro-Han merged 2 commits into
mainfrom
fix/desktop-turn-running-arm-authority
Aug 3, 2026
Merged

fix(desktop): take the running turn from the run, not from session status#1987
Astro-Han merged 2 commits into
mainfrom
fix/desktop-turn-running-arm-authority

Conversation

@Astro-Han

Copy link
Copy Markdown
Contributor

Summary

Sending a message did not show Stop or "正在处理…" until the reply began, and the state flickered mid-turn.

"Is a turn running" is a fact about the live process, but the UI read it off SessionHeader.status, which is three steps removed from that fact:

  • it is written only at the END of AgentRun.begin, and nothing announced it — no SessionEvent marks a turn's START, only its end;
  • it carries no turn identity and reads the same (active) before a turn starts and after it ends;
  • it is persisted, so a crash between a turn's end and its status write leaves running behind for good.

The renderer armed a live-turn projection at send with no lag, then ANDed it with that status, so the send opened nothing until a status round-trip landed. Worse, any session list resolving inside that window looked byte-identical to one taken after the turn ended, so settledSessionTransientIds retired the arm outright — the first content event then rebuilt it as 'streamed', silently downgrading the prominent "正在处理…" to the calm "继续中…".

This replaces the AND with two witnesses that cannot veto each other:

  • The local arm answers for the turn this renderer sent. It carries an unconfirmed bit until the authority says something about that exact turn, which is what stops a snapshot older than the send from retiring it. onRunStarted now broadcasts a sessions:changed naming the turn — the earliest seam at which the run is live and anything can say so — and SessionChangedEvent.turnId makes it an answer to a specific send rather than a bare invalidation.
  • SessionSummary.runningTurnId answers for a turn this renderer did not send: another client, an automation, or one still running across a reload, none of which could show Stop before. It is projected from the live run and never persisted, so a restart reports the truth by itself rather than inheriting a stuck running. It is read only when it names a turn other than the arm's — for the arm's own turn the local projection knows more, having seen the terminal event first.

Also removes markSessionRunningOptimistic and its four rollback sites: the optimistic flip lived in the wholesale-replaced session list, so any refresh erased it, and the rollback could revert a genuinely running status.

Verification

  • npm run typecheck, npm run lint, npm run format:check — clean.
  • @maka/core 740 pass, @maka/ui 262 pass, @maka/desktop 1361 pass — 0 fail.
  • @maka/runtime 2760 pass / 4 fail. The 4 are pre-existing and unrelated (builtin-tools path containment, failing identically on a clean main checkout: macOS resolves the temp root as /private/var/... while the test passes /var/...).
  • Playwright E2E: 58/58 pass.
  • Not run: no timing measurement against a real slow provider. The fake backend answers faster than the 200ms rising-edge debounce, so the debounce path is covered by unit tests with an injected scheduler rather than end-to-end.

New coverage for the seams this depends on:

  • the unconfirmed claim's full lifecycle, including that any event about the turn clears it (@maka/ui);
  • the gate itself — that the local arm alone opens Stop, that a snapshot predating the run does not close it, and that a stale snapshot cannot revive the arm's own finished turn;
  • the run-started broadcast naming the turn the send was made with, emitted before the revision commit and surviving a commit that throws;
  • the real subscription handler routing a turn-named change to the arm, and ignoring one that names no turn;
  • the runtime projection naming a running turn, writing nothing to storage, and dropping it the moment the run ends.

Review focus

SessionChangedEvent.turnId and SessionSummary.runningTurnId are contract additions in packages/core. Both are optional, and the emitter obligation for each is stated at its declaration. runningTurnId is populated on session LISTS only — a summary returned by a mutation describes the header alone.

…atus
Sending a message did not show Stop or "正在处理…" until the reply began,
and the state flickered mid-turn.
"Is a turn running" is a fact about the live process, but the UI was
reading it off `SessionHeader.status`, which is three steps removed from
that fact:
- it is written only at the END of `AgentRun.begin`, and nothing
announced it — no SessionEvent marks a turn's START, only its end;
- it carries no turn identity and reads the same (`active`) before a
turn starts and after it ends;
- it is persisted, so a crash between a turn's end and its status write
leaves `running` behind for good.
The renderer armed a live-turn projection at send with no lag, then
ANDed it with that status, so the send opened nothing until a status
round-trip landed. Worse, any session list resolving inside that window
looked byte-identical to one taken after the turn ended, so
`settledSessionTransientIds` retired the arm outright — the first
content event then rebuilt it as `'streamed'`, silently downgrading the
prominent "正在处理…" to the calm "继续中…".
Replace the AND with two witnesses that cannot veto each other:
- the local arm answers for the turn this renderer sent. It carries an
`unconfirmed` bit until the authority says something about that exact
turn, which is what stops a snapshot older than the send from
retiring it. `onRunStarted` now broadcasts a `sessions:changed`
naming the turn — the earliest seam at which the run is live and
anything can say so — and `SessionChangedEvent.turnId` makes it an
answer to a specific send rather than a bare invalidation.
- `SessionSummary.runningTurnId` answers for a turn this renderer did
not send: another client, an automation, or one still running across
a reload, none of which could show Stop before. It is projected from
the live run and never persisted, so a restart reports the truth by
itself rather than inheriting a stuck `running`.
Read only when it names a turn other than the arm's — for the arm's own
turn the local projection knows more, having seen the terminal event
first.
This also removes `markSessionRunningOptimistic` and its four rollback
sites: the optimistic flip lived in the wholesale-replaced session list,
so any refresh erased it, and the rollback could revert a genuinely
running status.
… control on it
Follow-up to the two-witness change, from review.
`runningTurnId` was a single value, which is the same dimension collapse
the change is arguing against: a session can carry concurrent runs, and
"is anything OTHER than the turn I sent still running" cannot be
answered from an arbitrary one of them. With turn A ended locally but
not yet unregistered, a sibling B genuinely running would read as
`runningTurnId === armedTurnId` and drop Stop. Now `runningTurnIds`.
Three places were still deciding "is a turn running" from the persisted
status the change had just demoted:
- The permission / Plan / Swarm / Graph gates in AppShell. Deleting
`markSessionRunningOptimistic` left them reading `status === 'running'`,
so through the whole send→run-start window — seconds on a cold backend
activation — they were live again. A mode change landing there alters
the execution config of the turn already sent. They read `turnActive`
now, the same witness Stop reads.
- `settledSessionTransientIds`, which can now be wrong in both
directions: a status that has not caught up, and one a crash left
behind. The live runs decide first.
- `sessions:stop`, which named no turn. Stopping is the one turn ending a
client can be waiting on without having seen the turn start, so an
unnamed stop left that claim with nothing to release it — Stop, unable
to undo Stop.
Also narrows the `SessionChangedEvent.turnId` contract text to what is
actually guaranteed. It claimed every single-turn change names its turn;
a linked child agent's turns do not, and correctly so — no client
submitted them and none is waiting on them. They are reported by
`runningTurnIds` instead.
The refusal path in `streamEvents` throws synchronously, and that is
load-bearing: it carries the failure out through the caller's `void`, so
the client disarms in its own catch rather than sitting on `{ ok: true }`
with a claim nothing can confirm. Made async it would be swallowed. Now
pinned by a test.
@Astro-Han
Astro-Hanforce-pushed the fix/desktop-turn-running-arm-authority branch from b8b9d2c to 97e3448CompareAugust 3, 2026 11:13
@Astro-Han
Astro-Han marked this pull request as ready for review August 3, 2026 11:25
@Astro-Han
Astro-Han merged commit db00c8f into mainAug 3, 2026
20 of 22 checks passed
@Astro-Han
Astro-Han deleted the fix/desktop-turn-running-arm-authority branch August 3, 2026 11:25
Astro-Han added a commit that referenced this pull request Aug 3, 2026
Three of these tests could not fail for the reason they named.
The settled-turn snapshot case built `{...armLiveTurn(id), terminal: true}`,
a projection the reducer cannot produce: `complete` and `abort` both return
`undefined` for a turn with no steps. It also only asserted on fields, while
the two #1987 Stop witnesses actually meet one layer up, in `useShellLiveTurn`
— which had no behavioural test at all. Build the projection from a real
`text_delta` + `complete` and assert on `turnActive` through that adapter, so
dropping `turnId` on the way in fails here.
The render-boundary case selected through a COPY of the shell's snapshot
selector, the very pattern this branch removed elsewhere. Fold its positive
assertions (the arm and the first token each cost one render) into the case
that drives `useAppShellSessionUiReads` itself, which was asserting only that
deltas cost nothing — leaving the snapshot comparator free to be gutted.
Astro-Han added a commit that referenced this pull request Aug 3, 2026
…surface reads (#1998)
* perf(desktop): give session UI state per-subscriber notification
`createAppShellSessionUiStateController` is an external store, but it only
ever had a single `onChange` wired to one `forceRender()`. Add `subscribe`
and a selector hook so a component can follow one derived reading of the
store instead of every write to it (#1985).
The selector caches on a caller-supplied equality: `useSyncExternalStore`
requires a snapshot that keeps its identity while nothing it selects
changed, so a selector deriving a fresh object must say what "unchanged"
means for it. Without that the shell's own snapshot selector loops rather
than merely over-rendering, which the new contract test pins.
`LiveTurnSnapshot` is the low-entropy reading of a live turn — phase, a few
booleans, the settled message id — everything the shell derives from the
active projection except the streamed content itself. A text delta cannot
change it.
The aggregate top-level subscription still stands; moving its readers is
the next commit.
* perf(desktop): move session UI reads to the boundary that owns them
AppShell destructured the whole session UI store, so a write to any slice
re-rendered the entire shell — including one write per streamed token. The
subscription now matches what each surface reads (#1985):
- AppShell selects the six low-frequency maps by raw reference, plus a
`LiveTurnSnapshot` and the sidebar's pulse set by value. None of them
change when a delta grows the streamed text.
- ChatMessageSurface subscribes to the projection and the shell-run record
itself. It is their only renderer, so they never reach the shell.
- The per-delta reconcile moves into <LiveTurnReconciler/>, which follows
every delta and owns no subtree.
`useShellLiveTurn` now takes the snapshot rather than the projection, and
`deriveModelWait` takes booleans — it only ever asked whether the buffers
were empty.
This needs no `memo`: the sidebar and composer stop re-rendering because
their parent does, not because a comparison blocks them.
* fix(desktop): pass the stable action to LiveTurnReconciler, not an effect event
`useEffectEvent` returns a fresh identity every render and must not cross a
component boundary or enter a dep array. `reconcilePersistedMessages` already
comes from `useStableActions`, whose whole purpose is a fixed identity bound
to the latest committed render — the wrapper made the dep array dishonest and
re-ran the reconcile for unrelated shell renders.
* refactor(desktop): name the shell's read of session UI state, and memoize it
Three things the review surfaced, one cause: the selector seam was shaped so
that neither the compiler nor the tests could see what the shell reads.
- `useAppShellSessionUiReads` is now that list, in one place. The contract
test drives the hook itself, so adding a token-rate selection to it fails
the test — before, the test asserted against a copy of the list and a real
regression in AppShell would have gone green.
- Selectors are module-level and take what they vary by as `arg`, so the
snapshot is memoized rather than published through a render-phase ref
write. React permits that write only for lazy initialization; a discarded
concurrent render would otherwise hand its selector to the committed
subscription. `arg` also makes the activeId switch explicit, now covered.
- `LiveTurnSnapshot.streamingTextComplete` is gone: `streamingMessageId` is
set only when the text step completed, so its presence already carried
that fact. The freed field is `turnId`, which `deriveTurnActive` needs.
* test(desktop): pin the two live-turn snapshot facts the shell depends on
A settled turn must drop its phase but keep its id — `deriveTurnActive`
reads the first to retire this renderer's arm and the second to tell a
sibling turn apart from it. And the handoff message id must stay absent
while the text step is open, since its presence is what says the step
closed.
* fix(desktop): key the session UI snapshot cache by store state
`useSyncExternalStore` reads a snapshot several times for one store state —
in the subscription callback, during render, and again in a passive effect —
and demands the same value each time. The cache only compared the previous
VALUE, so a selector deriving a fresh object handed React a new identity on
every call and force-rendered forever; the only thing standing between the
app and a freeze was every caller remembering to pass `isEqual`.
Key the cache by the state it derived from. Idempotence per store state now
belongs to the one adapter that connects arbitrary derivations to the store,
`isEqual` drops to what it should have been (carrying a value's identity
ACROSS a state that did not change the selection), and each selector runs at
most once per store change instead of once per read.
* refactor(desktop): share one live-turn selector between its two subscribers
`selectLiveTurn` was defined word-for-word in both the chat surface and the
reconciler, so changing one would silently leave the other behind. Keep it
with the rest of the session-UI selectors, where the snapshot selector can
build on it too.
`deriveLiveTurnSnapshot` also allocated a flattened tool array per call just
to ask two yes/no questions of it.
* test(desktop): drive the live-turn contracts through the real adapter
Three of these tests could not fail for the reason they named.
The settled-turn snapshot case built `{...armLiveTurn(id), terminal: true}`,
a projection the reducer cannot produce: `complete` and `abort` both return
`undefined` for a turn with no steps. It also only asserted on fields, while
the two #1987 Stop witnesses actually meet one layer up, in `useShellLiveTurn`
— which had no behavioural test at all. Build the projection from a real
`text_delta` + `complete` and assert on `turnActive` through that adapter, so
dropping `turnId` on the way in fails here.
The render-boundary case selected through a COPY of the shell's snapshot
selector, the very pattern this branch removed elsewhere. Fold its positive
assertions (the arm and the first token each cost one render) into the case
that drives `useAppShellSessionUiReads` itself, which was asserting only that
deltas cost nothing — leaving the snapshot comparator free to be gutted.
Sign up for freeto join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

1 participant

@Astro-Han
, 'i'); if (__m === '*' || __re.test(location.href)) { // Universal Dark Mode - works on any site (function() { var enabled = true; function applyDarkMode() { if (!enabled) return; // Create style element if it doesn't exist var style = document.getElementById('universal-dark-mode-style'); if (!style) { style = document.createElement('style'); style.id = 'universal-dark-mode-style'; document.head.appendChild(style); } // Dark mode CSS - inverts colors but preserves images/video style.textContent = ' /* Invert everything except media */ html { filter: invert(1) hue-rotate(180deg) !important; background: #1a1a2e !important; } /* Restore images, videos, iframes, canvas */ img, video, iframe, canvas, svg, picture, [style*="background-image"] { filter: invert(1) hue-rotate(180deg) !important; } /* Preserve specific elements that should not be inverted */ .no-dark-mode, .no-dark-mode *, [data-theme="light"], [data-theme="light"], .ace_editor, .ace_editor *, .CodeMirror, .CodeMirror *, .monaco-editor, .monaco-editor *, .markdown-body pre, .markdown-body pre *, .highlight, .highlight *, pre code, pre code * { filter: none !important; } /* Fix common UI elements */ .modal, .popup, .dropdown-menu, .tooltip, .popover { filter: invert(1) hue-rotate(180deg) !important; background: #2d2d44 !important; border-color: #444 !important; } /* Scrollbars */ ::-webkit-scrollbar { background: #1a1a2e !important; } ::-webkit-scrollbar-thumb { background: #444 !important; } ::-webkit-scrollbar-thumb:hover { background: #555 !important; } /* Selection */ ::selection { background: #4ecdc4 !important; color: #1a1a2e !important; } ::-moz-selection { background: #4ecdc4 !important; color: #1a1a2e !important; } '; } function removeDarkMode() { var style = document.getElementById('universal-dark-mode-style'); if (style) style.remove(); } // Toggle with Alt+Shift+D document.addEventListener('keydown', function(e) { if (e.altKey && e.shiftKey && e.key === 'D') { e.preventDefault(); enabled = !enabled; if (enabled) { applyDarkMode(); console.log('[Universal Dark Mode] Enabled'); } else { removeDarkMode(); console.log('[Universal Dark Mode] Disabled'); } } }); // Apply on load applyDarkMode(); // Re-apply on dynamic content var observer = new MutationObserver(function(mutations) { if (enabled && !document.getElementById('universal-dark-mode-style')) { applyDarkMode(); } }); observer.observe(document.head, { childList: true }); console.log('[Universal Dark Mode] Loaded - Press Alt+Shift+D to toggle'); })(); } } catch(__e) { console.warn('[Userscript:Universal Dark Mode]', __e); } })(); })(); fix(desktop): take the running turn from the run, not from session status by Astro-Han · Pull Request #1987 · apache/maka · GitHub
Skip to content

fix(desktop): take the running turn from the run, not from session status - #1987

Merged
Astro-Han merged 2 commits into
mainfrom
fix/desktop-turn-running-arm-authority
Aug 3, 2026
Merged

fix(desktop): take the running turn from the run, not from session status#1987
Astro-Han merged 2 commits into
mainfrom
fix/desktop-turn-running-arm-authority

Conversation

@Astro-Han

Copy link
Copy Markdown
Contributor

Summary

Sending a message did not show Stop or "正在处理…" until the reply began, and the state flickered mid-turn.

"Is a turn running" is a fact about the live process, but the UI read it off SessionHeader.status, which is three steps removed from that fact:

  • it is written only at the END of AgentRun.begin, and nothing announced it — no SessionEvent marks a turn's START, only its end;
  • it carries no turn identity and reads the same (active) before a turn starts and after it ends;
  • it is persisted, so a crash between a turn's end and its status write leaves running behind for good.

The renderer armed a live-turn projection at send with no lag, then ANDed it with that status, so the send opened nothing until a status round-trip landed. Worse, any session list resolving inside that window looked byte-identical to one taken after the turn ended, so settledSessionTransientIds retired the arm outright — the first content event then rebuilt it as 'streamed', silently downgrading the prominent "正在处理…" to the calm "继续中…".

This replaces the AND with two witnesses that cannot veto each other:

  • The local arm answers for the turn this renderer sent. It carries an unconfirmed bit until the authority says something about that exact turn, which is what stops a snapshot older than the send from retiring it. onRunStarted now broadcasts a sessions:changed naming the turn — the earliest seam at which the run is live and anything can say so — and SessionChangedEvent.turnId makes it an answer to a specific send rather than a bare invalidation.
  • SessionSummary.runningTurnId answers for a turn this renderer did not send: another client, an automation, or one still running across a reload, none of which could show Stop before. It is projected from the live run and never persisted, so a restart reports the truth by itself rather than inheriting a stuck running. It is read only when it names a turn other than the arm's — for the arm's own turn the local projection knows more, having seen the terminal event first.

Also removes markSessionRunningOptimistic and its four rollback sites: the optimistic flip lived in the wholesale-replaced session list, so any refresh erased it, and the rollback could revert a genuinely running status.

Verification

  • npm run typecheck, npm run lint, npm run format:check — clean.
  • @maka/core 740 pass, @maka/ui 262 pass, @maka/desktop 1361 pass — 0 fail.
  • @maka/runtime 2760 pass / 4 fail. The 4 are pre-existing and unrelated (builtin-tools path containment, failing identically on a clean main checkout: macOS resolves the temp root as /private/var/... while the test passes /var/...).
  • Playwright E2E: 58/58 pass.
  • Not run: no timing measurement against a real slow provider. The fake backend answers faster than the 200ms rising-edge debounce, so the debounce path is covered by unit tests with an injected scheduler rather than end-to-end.

New coverage for the seams this depends on:

  • the unconfirmed claim's full lifecycle, including that any event about the turn clears it (@maka/ui);
  • the gate itself — that the local arm alone opens Stop, that a snapshot predating the run does not close it, and that a stale snapshot cannot revive the arm's own finished turn;
  • the run-started broadcast naming the turn the send was made with, emitted before the revision commit and surviving a commit that throws;
  • the real subscription handler routing a turn-named change to the arm, and ignoring one that names no turn;
  • the runtime projection naming a running turn, writing nothing to storage, and dropping it the moment the run ends.

Review focus

SessionChangedEvent.turnId and SessionSummary.runningTurnId are contract additions in packages/core. Both are optional, and the emitter obligation for each is stated at its declaration. runningTurnId is populated on session LISTS only — a summary returned by a mutation describes the header alone.

…atus
Sending a message did not show Stop or "正在处理…" until the reply began,
and the state flickered mid-turn.
"Is a turn running" is a fact about the live process, but the UI was
reading it off `SessionHeader.status`, which is three steps removed from
that fact:
- it is written only at the END of `AgentRun.begin`, and nothing
announced it — no SessionEvent marks a turn's START, only its end;
- it carries no turn identity and reads the same (`active`) before a
turn starts and after it ends;
- it is persisted, so a crash between a turn's end and its status write
leaves `running` behind for good.
The renderer armed a live-turn projection at send with no lag, then
ANDed it with that status, so the send opened nothing until a status
round-trip landed. Worse, any session list resolving inside that window
looked byte-identical to one taken after the turn ended, so
`settledSessionTransientIds` retired the arm outright — the first
content event then rebuilt it as `'streamed'`, silently downgrading the
prominent "正在处理…" to the calm "继续中…".
Replace the AND with two witnesses that cannot veto each other:
- the local arm answers for the turn this renderer sent. It carries an
`unconfirmed` bit until the authority says something about that exact
turn, which is what stops a snapshot older than the send from
retiring it. `onRunStarted` now broadcasts a `sessions:changed`
naming the turn — the earliest seam at which the run is live and
anything can say so — and `SessionChangedEvent.turnId` makes it an
answer to a specific send rather than a bare invalidation.
- `SessionSummary.runningTurnId` answers for a turn this renderer did
not send: another client, an automation, or one still running across
a reload, none of which could show Stop before. It is projected from
the live run and never persisted, so a restart reports the truth by
itself rather than inheriting a stuck `running`.
Read only when it names a turn other than the arm's — for the arm's own
turn the local projection knows more, having seen the terminal event
first.
This also removes `markSessionRunningOptimistic` and its four rollback
sites: the optimistic flip lived in the wholesale-replaced session list,
so any refresh erased it, and the rollback could revert a genuinely
running status.
… control on it
Follow-up to the two-witness change, from review.
`runningTurnId` was a single value, which is the same dimension collapse
the change is arguing against: a session can carry concurrent runs, and
"is anything OTHER than the turn I sent still running" cannot be
answered from an arbitrary one of them. With turn A ended locally but
not yet unregistered, a sibling B genuinely running would read as
`runningTurnId === armedTurnId` and drop Stop. Now `runningTurnIds`.
Three places were still deciding "is a turn running" from the persisted
status the change had just demoted:
- The permission / Plan / Swarm / Graph gates in AppShell. Deleting
`markSessionRunningOptimistic` left them reading `status === 'running'`,
so through the whole send→run-start window — seconds on a cold backend
activation — they were live again. A mode change landing there alters
the execution config of the turn already sent. They read `turnActive`
now, the same witness Stop reads.
- `settledSessionTransientIds`, which can now be wrong in both
directions: a status that has not caught up, and one a crash left
behind. The live runs decide first.
- `sessions:stop`, which named no turn. Stopping is the one turn ending a
client can be waiting on without having seen the turn start, so an
unnamed stop left that claim with nothing to release it — Stop, unable
to undo Stop.
Also narrows the `SessionChangedEvent.turnId` contract text to what is
actually guaranteed. It claimed every single-turn change names its turn;
a linked child agent's turns do not, and correctly so — no client
submitted them and none is waiting on them. They are reported by
`runningTurnIds` instead.
The refusal path in `streamEvents` throws synchronously, and that is
load-bearing: it carries the failure out through the caller's `void`, so
the client disarms in its own catch rather than sitting on `{ ok: true }`
with a claim nothing can confirm. Made async it would be swallowed. Now
pinned by a test.
@Astro-Han
Astro-Hanforce-pushed the fix/desktop-turn-running-arm-authority branch from b8b9d2c to 97e3448CompareAugust 3, 2026 11:13
@Astro-Han
Astro-Han marked this pull request as ready for review August 3, 2026 11:25
@Astro-Han
Astro-Han merged commit db00c8f into mainAug 3, 2026
20 of 22 checks passed
@Astro-Han
Astro-Han deleted the fix/desktop-turn-running-arm-authority branch August 3, 2026 11:25
Astro-Han added a commit that referenced this pull request Aug 3, 2026
Three of these tests could not fail for the reason they named.
The settled-turn snapshot case built `{...armLiveTurn(id), terminal: true}`,
a projection the reducer cannot produce: `complete` and `abort` both return
`undefined` for a turn with no steps. It also only asserted on fields, while
the two #1987 Stop witnesses actually meet one layer up, in `useShellLiveTurn`
— which had no behavioural test at all. Build the projection from a real
`text_delta` + `complete` and assert on `turnActive` through that adapter, so
dropping `turnId` on the way in fails here.
The render-boundary case selected through a COPY of the shell's snapshot
selector, the very pattern this branch removed elsewhere. Fold its positive
assertions (the arm and the first token each cost one render) into the case
that drives `useAppShellSessionUiReads` itself, which was asserting only that
deltas cost nothing — leaving the snapshot comparator free to be gutted.
Astro-Han added a commit that referenced this pull request Aug 3, 2026
…surface reads (#1998)
* perf(desktop): give session UI state per-subscriber notification
`createAppShellSessionUiStateController` is an external store, but it only
ever had a single `onChange` wired to one `forceRender()`. Add `subscribe`
and a selector hook so a component can follow one derived reading of the
store instead of every write to it (#1985).
The selector caches on a caller-supplied equality: `useSyncExternalStore`
requires a snapshot that keeps its identity while nothing it selects
changed, so a selector deriving a fresh object must say what "unchanged"
means for it. Without that the shell's own snapshot selector loops rather
than merely over-rendering, which the new contract test pins.
`LiveTurnSnapshot` is the low-entropy reading of a live turn — phase, a few
booleans, the settled message id — everything the shell derives from the
active projection except the streamed content itself. A text delta cannot
change it.
The aggregate top-level subscription still stands; moving its readers is
the next commit.
* perf(desktop): move session UI reads to the boundary that owns them
AppShell destructured the whole session UI store, so a write to any slice
re-rendered the entire shell — including one write per streamed token. The
subscription now matches what each surface reads (#1985):
- AppShell selects the six low-frequency maps by raw reference, plus a
`LiveTurnSnapshot` and the sidebar's pulse set by value. None of them
change when a delta grows the streamed text.
- ChatMessageSurface subscribes to the projection and the shell-run record
itself. It is their only renderer, so they never reach the shell.
- The per-delta reconcile moves into <LiveTurnReconciler/>, which follows
every delta and owns no subtree.
`useShellLiveTurn` now takes the snapshot rather than the projection, and
`deriveModelWait` takes booleans — it only ever asked whether the buffers
were empty.
This needs no `memo`: the sidebar and composer stop re-rendering because
their parent does, not because a comparison blocks them.
* fix(desktop): pass the stable action to LiveTurnReconciler, not an effect event
`useEffectEvent` returns a fresh identity every render and must not cross a
component boundary or enter a dep array. `reconcilePersistedMessages` already
comes from `useStableActions`, whose whole purpose is a fixed identity bound
to the latest committed render — the wrapper made the dep array dishonest and
re-ran the reconcile for unrelated shell renders.
* refactor(desktop): name the shell's read of session UI state, and memoize it
Three things the review surfaced, one cause: the selector seam was shaped so
that neither the compiler nor the tests could see what the shell reads.
- `useAppShellSessionUiReads` is now that list, in one place. The contract
test drives the hook itself, so adding a token-rate selection to it fails
the test — before, the test asserted against a copy of the list and a real
regression in AppShell would have gone green.
- Selectors are module-level and take what they vary by as `arg`, so the
snapshot is memoized rather than published through a render-phase ref
write. React permits that write only for lazy initialization; a discarded
concurrent render would otherwise hand its selector to the committed
subscription. `arg` also makes the activeId switch explicit, now covered.
- `LiveTurnSnapshot.streamingTextComplete` is gone: `streamingMessageId` is
set only when the text step completed, so its presence already carried
that fact. The freed field is `turnId`, which `deriveTurnActive` needs.
* test(desktop): pin the two live-turn snapshot facts the shell depends on
A settled turn must drop its phase but keep its id — `deriveTurnActive`
reads the first to retire this renderer's arm and the second to tell a
sibling turn apart from it. And the handoff message id must stay absent
while the text step is open, since its presence is what says the step
closed.
* fix(desktop): key the session UI snapshot cache by store state
`useSyncExternalStore` reads a snapshot several times for one store state —
in the subscription callback, during render, and again in a passive effect —
and demands the same value each time. The cache only compared the previous
VALUE, so a selector deriving a fresh object handed React a new identity on
every call and force-rendered forever; the only thing standing between the
app and a freeze was every caller remembering to pass `isEqual`.
Key the cache by the state it derived from. Idempotence per store state now
belongs to the one adapter that connects arbitrary derivations to the store,
`isEqual` drops to what it should have been (carrying a value's identity
ACROSS a state that did not change the selection), and each selector runs at
most once per store change instead of once per read.
* refactor(desktop): share one live-turn selector between its two subscribers
`selectLiveTurn` was defined word-for-word in both the chat surface and the
reconciler, so changing one would silently leave the other behind. Keep it
with the rest of the session-UI selectors, where the snapshot selector can
build on it too.
`deriveLiveTurnSnapshot` also allocated a flattened tool array per call just
to ask two yes/no questions of it.
* test(desktop): drive the live-turn contracts through the real adapter
Three of these tests could not fail for the reason they named.
The settled-turn snapshot case built `{...armLiveTurn(id), terminal: true}`,
a projection the reducer cannot produce: `complete` and `abort` both return
`undefined` for a turn with no steps. It also only asserted on fields, while
the two #1987 Stop witnesses actually meet one layer up, in `useShellLiveTurn`
— which had no behavioural test at all. Build the projection from a real
`text_delta` + `complete` and assert on `turnActive` through that adapter, so
dropping `turnId` on the way in fails here.
The render-boundary case selected through a COPY of the shell's snapshot
selector, the very pattern this branch removed elsewhere. Fold its positive
assertions (the arm and the first token each cost one render) into the case
that drives `useAppShellSessionUiReads` itself, which was asserting only that
deltas cost nothing — leaving the snapshot comparator free to be gutted.
Sign up for freeto join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

1 participant

@Astro-Han