@cat-factory/app 0.190.1 → 0.192.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
@@ -22,17 +22,36 @@
22
22
  // account that also has no storage — an accepted trade-off (the setting stays reachable from
23
23
  // account settings, and the SESSION dismissal re-nags on the next load regardless).
24
24
  //
25
- // Freshness note: `infraSetup` is a server projection recomputed only on snapshot (re)load, so a
26
- // banner clears on the next board load after the operator configures the area via the deep-link,
27
- // not the instant the config panel saves.
25
+ // Two KINDS of card share this surface, and the difference drives the dismissal fork:
26
+ // - a setup gap (`not_defined`) is a stable operator decision, so both dismissals are offered;
27
+ // - an OUTAGE (`unreachable` — configured, but the reachability watcher's live probe can't reach
28
+ // it) is a health state, so ONLY the session dismissal is offered. A permanent "don't notify me
29
+ // again" on a transient failure would let one click silence every future outage, and the outage
30
+ // that matters is always the next one. `isInfraSetupHealthStatus` (contracts) is the single
31
+ // definition both this component and the store's re-nag logic key off.
32
+ //
33
+ // BOTH dismissals are keyed by the CLAIM, never by the area alone, because the two cards say
34
+ // different things about the same area: silencing "you haven't configured this" must not also
35
+ // silence the outage card raised after the operator configures it and the provider then dies.
36
+ //
37
+ // Freshness note: a setup gap clears on the next board load after the operator configures the area
38
+ // via the deep-link, not the instant the config panel saves — the projection is recomputed on
39
+ // snapshot (re)load. An OUTAGE is different: it arrives and clears live, pushed as an `infraSetup`
40
+ // event by the watcher and applied by `workspace.patchInfraSetup`.
28
41
  import { useLocalStorage } from '@vueuse/core'
29
42
  import { computed } from 'vue'
30
43
  // The localStorage key holding the permanent per-user dismissals lives in `@cat-factory/contracts`
31
44
  // (a dependency-free package the SPA and the e2e suite both import), so the key + shape can't drift
32
45
  // between this component and the e2e seed in `backend/internal/e2e/tests/helpers.ts` (`pinWorkspace`).
33
- import { INFRA_SETUP_DISMISSED_STORAGE_KEY } from '@cat-factory/contracts'
46
+ import {
47
+ INFRA_SETUP_DISMISSED_STORAGE_KEY,
48
+ type InfraSetupProbedArea,
49
+ isInfraSetupHealthStatus,
50
+ isInfraSetupProbedArea,
51
+ } from '@cat-factory/contracts'
34
52
  import type { DropdownMenuItem } from '@nuxt/ui'
35
- import type { InfraSetupArea } from '~/types/domain'
53
+ import type { InfraSetupArea, InfraSetupStatus } from '~/types/domain'
54
+ import { infraSetupDismissalKey, type InfraSetupCardKind } from '~/utils/infraSetup'
36
55
 
37
56
  const { t } = useI18n()
38
57
  const ui = useUiStore()
@@ -75,6 +94,18 @@ const AREA_META: Record<
75
94
  },
76
95
  }
77
96
 
97
+ // Outage titles are keyed over the PROBED areas only, not over every area: `binaryStorage` has no
98
+ // reachability probe, so an entry for it would be copy that ships in every locale and can never
99
+ // render. Each probed area carries its OWN title rather than interpolating the area name into a
100
+ // shared one — a predicate adjective ("is unreachable") agrees with its subject's gender in most of
101
+ // the locales we ship, so `{area} is unreachable` cannot be translated correctly as one string. The
102
+ // outage BODY and action ARE shared, because neither refers back to the area; each locale's body
103
+ // opens with its own fixed subject noun for exactly that reason.
104
+ const UNREACHABLE_TITLE_KEYS: Record<InfraSetupProbedArea, string> = {
105
+ agentExecutor: 'layout.infraSetupBanner.agentExecutor.unreachableTitle',
106
+ ephemeralEnvironments: 'layout.infraSetupBanner.ephemeralEnvironments.unreachableTitle',
107
+ }
108
+
78
109
  // Permanent, per-user dismissals: one shared localStorage record keyed BY user id (so it's
79
110
  // scoped to the signed-in user and doesn't leak across accounts on a shared browser). No
80
111
  // signed-in user (local/auth-off single-user mode) ⇒ the `local` bucket.
@@ -94,30 +125,64 @@ function dismissPermanently(area: InfraSetupArea) {
94
125
  }
95
126
  }
96
127
 
97
- const visible = computed<InfraSetupArea[]>(() => {
128
+ /** One rendered card: the area plus which CLAIM it is making about it. */
129
+ interface AreaCard {
130
+ area: InfraSetupArea
131
+ /** `outage` for a live-health status — drives the copy, the severity styling and the dismissals. */
132
+ kind: InfraSetupCardKind
133
+ /** The failing probe's reason, when this session saw the transition that raised the card. */
134
+ detail?: string
135
+ }
136
+
137
+ const visible = computed<AreaCard[]>(() => {
98
138
  const status = workspace.infraSetup
99
139
  if (!status) return []
100
- return AREAS.filter(
101
- (area) =>
102
- status[area] === 'not_defined' &&
103
- !ui.infraSetupSessionDismissed.includes(area) &&
104
- !dismissedForUser.value.includes(area),
105
- )
140
+ return AREAS.filter((area) => {
141
+ const kind = cardKind(status[area])
142
+ if (!kind) return false
143
+ // Both dismissals are keyed by the CLAIM, not by the area: silencing "you haven't configured
144
+ // this" must not also silence "you configured it and it is now down". The permanent dismissal
145
+ // only ever covers a setup gap (`dismissPermanently` is offered nowhere else).
146
+ if (ui.infraSetupSessionDismissed.includes(infraSetupDismissalKey(area, kind))) return false
147
+ return !(kind === 'setup' && dismissedForUser.value.includes(area))
148
+ }).map((area) => ({
149
+ area,
150
+ kind: cardKind(status[area])!,
151
+ ...(workspace.infraSetupDetails[area] ? { detail: workspace.infraSetupDetails[area] } : {}),
152
+ }))
106
153
  })
107
154
 
108
- // The dismiss dropdown: the product wants the user asked WHICH kind of dismissal on close.
109
- function dismissMenu(area: InfraSetupArea): DropdownMenuItem[][] {
155
+ /** Which card an area's status raises, or null when it raises none (`configured`/`not_applicable`). */
156
+ function cardKind(status: InfraSetupStatus): InfraSetupCardKind | null {
157
+ if (isInfraSetupHealthStatus(status)) return 'outage'
158
+ return status === 'not_defined' ? 'setup' : null
159
+ }
160
+
161
+ /** The card's title key: the per-area outage title for an outage, else the setup-gap title. */
162
+ function titleKey(card: AreaCard): string {
163
+ return card.kind === 'outage' && isInfraSetupProbedArea(card.area)
164
+ ? UNREACHABLE_TITLE_KEYS[card.area]
165
+ : AREA_META[card.area].titleKey
166
+ }
167
+
168
+ /**
169
+ * The dismiss dropdown: the product wants the user asked WHICH kind of dismissal on close. An
170
+ * outage offers the session option ONLY — see the fork note at the top of this file.
171
+ */
172
+ function dismissMenu(card: AreaCard): DropdownMenuItem[][] {
173
+ const session = {
174
+ label: t('layout.infraSetupBanner.dismiss.session'),
175
+ icon: 'i-lucide-clock',
176
+ onSelect: () => ui.dismissInfraSetupForSession(card.area, card.kind),
177
+ }
178
+ if (card.kind === 'outage') return [[session]]
110
179
  return [
111
180
  [
112
- {
113
- label: t('layout.infraSetupBanner.dismiss.session'),
114
- icon: 'i-lucide-clock',
115
- onSelect: () => ui.dismissInfraSetupForSession(area),
116
- },
181
+ session,
117
182
  {
118
183
  label: t('layout.infraSetupBanner.dismiss.permanent'),
119
184
  icon: 'i-lucide-bell-off',
120
- onSelect: () => dismissPermanently(area),
185
+ onSelect: () => dismissPermanently(card.area),
121
186
  },
122
187
  ],
123
188
  ]
@@ -135,42 +200,81 @@ function dismissMenu(area: InfraSetupArea): DropdownMenuItem[][] {
135
200
  role="status"
136
201
  aria-live="polite"
137
202
  >
203
+ <!-- An OUTAGE reads red, a setup gap amber: one is something breaking now, the other is
204
+ something never switched on, and a reader has to be able to tell at a glance. -->
138
205
  <div
139
- v-for="area in visible"
140
- :key="area"
141
- class="pointer-events-auto w-full max-w-3xl rounded-2xl border-2 border-amber-500/70 bg-amber-950/95 p-5 shadow-2xl backdrop-blur"
142
- :data-testid="`infra-setup-banner-${area}`"
206
+ v-for="card in visible"
207
+ :key="card.area"
208
+ class="pointer-events-auto w-full max-w-3xl rounded-2xl border-2 p-5 shadow-2xl backdrop-blur"
209
+ :class="
210
+ card.kind === 'outage'
211
+ ? 'border-red-500/70 bg-red-950/95'
212
+ : 'border-amber-500/70 bg-amber-950/95'
213
+ "
214
+ :data-testid="`infra-setup-banner-${card.area}`"
215
+ :data-infra-status="card.kind === 'outage' ? 'unreachable' : 'not_defined'"
143
216
  >
144
217
  <div class="flex items-start gap-4">
145
- <UIcon :name="AREA_META[area].icon" class="mt-0.5 h-9 w-9 shrink-0 text-amber-400" />
218
+ <UIcon
219
+ :name="card.kind === 'outage' ? 'i-lucide-plug-zap' : AREA_META[card.area].icon"
220
+ class="mt-0.5 h-9 w-9 shrink-0"
221
+ :class="card.kind === 'outage' ? 'text-red-400' : 'text-amber-400'"
222
+ />
146
223
  <div class="min-w-0 flex-1">
147
224
  <div class="flex items-start justify-between gap-3">
148
- <h2 class="text-lg font-semibold text-amber-100">
149
- {{ t(AREA_META[area].titleKey) }}
225
+ <h2
226
+ class="text-lg font-semibold"
227
+ :class="card.kind === 'outage' ? 'text-red-100' : 'text-amber-100'"
228
+ >
229
+ {{ t(titleKey(card)) }}
150
230
  </h2>
151
- <UDropdownMenu :items="dismissMenu(area)" :content="{ align: 'end' }">
231
+ <UDropdownMenu :items="dismissMenu(card)" :content="{ align: 'end' }">
152
232
  <UButton
153
233
  color="neutral"
154
234
  variant="ghost"
155
235
  size="xs"
156
236
  icon="i-lucide-x"
157
237
  :aria-label="t('common.close')"
158
- :data-testid="`infra-setup-dismiss-${area}`"
238
+ :data-testid="`infra-setup-dismiss-${card.area}`"
159
239
  />
160
240
  </UDropdownMenu>
161
241
  </div>
162
- <p class="mt-1 text-sm text-amber-200/90">
163
- {{ t(AREA_META[area].bodyKey) }}
242
+ <p
243
+ class="mt-1 text-sm"
244
+ :class="card.kind === 'outage' ? 'text-red-200/90' : 'text-amber-200/90'"
245
+ >
246
+ {{
247
+ card.kind === 'outage'
248
+ ? t('layout.infraSetupBanner.unreachable.body')
249
+ : t(AREA_META[card.area].bodyKey)
250
+ }}
251
+ </p>
252
+ <!-- The failing probe's OWN reason, when this session saw the transition that raised the
253
+ card: a refused connection, a rejected token and a timeout need different fixes, and
254
+ the generic body cannot tell them apart. Absent after a reload (it rides the live
255
+ event, never the deduped notification), so it is an addition to the copy above and
256
+ never the only thing that explains the card. -->
257
+ <p
258
+ v-if="card.detail"
259
+ class="mt-2 truncate font-mono text-xs text-red-300/80"
260
+ :title="card.detail"
261
+ :data-testid="`infra-setup-detail-${card.area}`"
262
+ >
263
+ {{ t('layout.infraSetupBanner.unreachable.reason', { detail: card.detail }) }}
164
264
  </p>
165
265
  <div class="mt-4">
166
266
  <UButton
167
- color="warning"
267
+ :color="card.kind === 'outage' ? 'error' : 'warning'"
168
268
  variant="solid"
169
269
  icon="i-lucide-settings"
170
- :data-testid="`infra-setup-configure-${area}`"
171
- @click="AREA_META[area].onConfigure()"
270
+ :data-testid="`infra-setup-configure-${card.area}`"
271
+ @click="AREA_META[card.area].onConfigure()"
172
272
  >
173
- {{ t(AREA_META[area].actionKey) }}
273
+ {{
274
+ card.kind === 'outage'
275
+ ? t('layout.infraSetupBanner.unreachable.action')
276
+ : t(AREA_META[card.area].actionKey)
277
+ }}
174
278
  </UButton>
175
279
  </div>
176
280
  </div>
@@ -88,6 +88,10 @@ const META: Record<Notification['type'], { icon: string; color: Accent }> = {
88
88
  // sealed). Not block-scoped; "act" drops the listed stale ciphertexts so they can be re-entered
89
89
  // (or restore the previous key to recover them instead).
90
90
  key_drift: { icon: 'i-lucide-key-round', color: 'error' },
91
+ // A configured infrastructure connection stopped answering its live probe. Not block-scoped and
92
+ // nothing to act on from here — the fix is on the provider's side, and the card clears itself when
93
+ // the reachability watcher sees it answer again; "act" just marks it read.
94
+ infra_unreachable: { icon: 'i-lucide-plug-zap', color: 'error' },
91
95
  }
92
96
 
93
97
  // Per-type primary-action label. An exhaustive Record keyed off the notification
@@ -114,6 +118,7 @@ const ACTION_KEYS: Record<Notification['type'], string> = {
114
118
  platform_health: 'layout.notifications.action.platform_health',
115
119
  budget_paused: 'layout.notifications.action.budget_paused',
116
120
  key_drift: 'layout.notifications.action.key_drift',
121
+ infra_unreachable: 'layout.notifications.action.infra_unreachable',
117
122
  }
118
123
 
119
124
  /** The localized primary-action label for a notification (te()-guarded against a
@@ -7,6 +7,10 @@
7
7
  // findings to a Fixer that commits fixes onto the PR branch), `Post` (publish them as inline PR
8
8
  // review comments), or `Finish` (just record the curated selection). Fix/Post act on the
9
9
  // selection, so they require at least one selected finding.
10
+ //
11
+ // While the review is still RUNNING it also offers `Resume`, which re-dispatches a review that
12
+ // appears stuck for only the slices that never reported (see `canResume` for why it is always
13
+ // offered rather than gated on an activity heuristic).
10
14
  import { computed, ref, watch } from 'vue'
11
15
  import { useResultView } from '~/composables/useResultView'
12
16
  import { useExecutionStore } from '~/stores/execution'
@@ -208,6 +212,25 @@ async function onResolve(action: PrReviewResolution): Promise<void> {
208
212
  await prReview.resolve(id, activeSelectedIds.value, action).catch(() => {})
209
213
  }
210
214
 
215
+ /**
216
+ * RESUME a review that appears stuck. Offered throughout the `reviewing` phase — including the
217
+ * neutral "planning" sub-state, since a wedge is just as possible before a plan is reported as
218
+ * after — because the whole complaint this answers is that a stuck review had no visible
219
+ * affordance at all. Deliberately NOT hidden behind a staleness heuristic: `lastActivityAt` freezes
220
+ * on a long silent turn (a single completion emits no tool call and grows no subagent transcript),
221
+ * so the platform cannot tell a wedged review from a quiet-but-working one, and hiding the control
222
+ * until it thinks it can would put it out of reach in exactly the case that motivated it.
223
+ */
224
+ const canResume = computed(
225
+ () => status.value === 'reviewing' && !prReview.resuming && access.canExecuteRuns.value,
226
+ )
227
+
228
+ async function onResume(): Promise<void> {
229
+ const id = instanceId.value
230
+ if (!id || !canResume.value) return
231
+ await prReview.resume(id).catch(() => {})
232
+ }
233
+
211
234
  // Per-finding CHALLENGE: the open finding's id (its inline concern box is showing) + the drafted
212
235
  // concern text. Dispatching moves the whole review to `challenging` until the verdict lands.
213
236
  const challengeForId = ref<string | null>(null)
@@ -273,7 +296,7 @@ async function onDismiss(id: string): Promise<void> {
273
296
  <div
274
297
  v-if="planning"
275
298
  data-testid="pr-review-planning"
276
- class="flex h-full flex-col items-center justify-center gap-2 py-10 text-center text-slate-400"
299
+ class="flex flex-1 flex-col items-center justify-center gap-2 py-10 text-center text-slate-400"
277
300
  >
278
301
  <UIcon name="i-lucide-loader-circle" class="h-8 w-8 animate-spin opacity-60" />
279
302
  <p class="text-sm text-slate-200">{{ t('prReview.reviewing.planning.title') }}</p>
@@ -395,6 +418,37 @@ async function onDismiss(id: string): Promise<void> {
395
418
  </ul>
396
419
  </template>
397
420
  </div>
421
+
422
+ <!-- Nudge a review that looks stuck. Present in BOTH reviewing sub-states, and never
423
+ gated on a staleness guess: the heartbeat freezes on a long silent turn, so nothing
424
+ here can tell wedged from quiet-but-working (see `canResume`). Re-reviews only the
425
+ slices that never reported; the finished ones are re-aggregated from their captured
426
+ reports. -->
427
+ <div class="mt-4 border-t border-slate-800 pt-3">
428
+ <p
429
+ v-if="prReview.error"
430
+ data-testid="pr-review-resume-error"
431
+ class="mb-2 rounded-md bg-rose-500/10 px-3 py-2 text-[12px] text-rose-300"
432
+ >
433
+ {{ prReview.error }}
434
+ </p>
435
+ <div class="flex items-start justify-between gap-3">
436
+ <p class="min-w-0 text-[11px] text-slate-500">{{ t('prReview.resume.hint') }}</p>
437
+ <UButton
438
+ data-testid="pr-review-resume"
439
+ size="xs"
440
+ color="neutral"
441
+ variant="soft"
442
+ icon="i-lucide-rotate-ccw"
443
+ :loading="prReview.resuming"
444
+ :disabled="!canResume"
445
+ :title="access.canExecuteRuns.value ? undefined : t('access.noRunExecute')"
446
+ @click="onResume"
447
+ >
448
+ {{ t('prReview.resume.action') }}
449
+ </UButton>
450
+ </div>
451
+ </div>
398
452
  </div>
399
453
 
400
454
  <!-- A resolution is executing: the Fixer is committing / comments are being posted. -->
@@ -43,6 +43,7 @@ const ROUTABLE = computed<{ type: NotificationType; label: string }[]>(() => [
43
43
  { type: 'pr_review_ready', label: t('slack.routable.pr_review_ready') },
44
44
  { type: 'initiative', label: t('slack.routable.initiative') },
45
45
  { type: 'platform_health', label: t('slack.routable.platform_health') },
46
+ { type: 'infra_unreachable', label: t('slack.routable.infra_unreachable') },
46
47
  ])
47
48
 
48
49
  /** Notification-role options for a mapped member (drives who gets @-mentioned). */
@@ -75,6 +76,7 @@ const routes = reactive<Record<NotificationType, SlackRoute>>({
75
76
  budget_paused: { enabled: false, channel: '' },
76
77
  // In-app only (not in ROUTABLE), but the map is exhaustive over the type.
77
78
  key_drift: { enabled: false, channel: '' },
79
+ infra_unreachable: { enabled: false, channel: '' },
78
80
  })
79
81
  const mentionsEnabled = ref(false)
80
82
  // Editable member rows carry a client-only stable `uid` (see `slackMemberMapping`) so
@@ -3,6 +3,7 @@ import {
3
3
  dismissPrReviewFindingContract,
4
4
  getPrReviewContract,
5
5
  resolvePrReviewContract,
6
+ resumePrReviewContract,
6
7
  } from '@cat-factory/contracts'
7
8
  import type { ApiContext } from './context'
8
9
 
@@ -32,6 +33,15 @@ export function prReviewApi({ send, ws }: ApiContext) {
32
33
  body,
33
34
  }),
34
35
 
36
+ // Re-trigger a review stuck mid-`reviewing`: only the slices that never reported are
37
+ // re-reviewed. No body — the engine derives what to redo from what it observed.
38
+ resumePrReview: (workspaceId: string, executionId: string) =>
39
+ send(resumePrReviewContract, {
40
+ pathPrefix: ws(workspaceId),
41
+ pathParams: { executionId },
42
+ body: {},
43
+ }),
44
+
35
45
  // Dismiss a parked finding entirely (drops it + prunes it from the selection).
36
46
  dismissPrReviewFinding: (workspaceId: string, executionId: string, findingId: string) =>
37
47
  send(dismissPrReviewFindingContract, {
@@ -111,6 +111,12 @@ export function useWorkspaceStream() {
111
111
  // inspector's "Test environment creation" control shows the live stage + final
112
112
  // outcome in place without a refetch. No board block.
113
113
  environmentTest.upsert(event.run)
114
+ } else if (event.type === 'infraSetup') {
115
+ // The reachability watcher found a configured infrastructure area dead (or answering again) —
116
+ // patch that one area so the setup banner appears/clears immediately. A full refresh would
117
+ // pay the whole snapshot aggregate for a one-field delta, and the projection the snapshot
118
+ // recomputes already folds the same recorded state.
119
+ workspace.patchInfraSetup(event.area, event.status, event.detail)
114
120
  } else if (event.type === 'notification') {
115
121
  // A PR needs a merge decision, a pipeline finished, or CI gave up — patch the
116
122
  // inbox + per-block badge in place (resolved ones drop out of the inbox).
@@ -186,3 +186,90 @@ describe('execution store metrics preservation (live-only rollup)', () => {
186
186
  expect(step.metrics?.calls).toBe(5)
187
187
  })
188
188
  })
189
+
190
+ // Regression for the optimistic-echo CLOBBER. `upsert`/`hydrate` are monotonic by `rev`, but an
191
+ // action store's echo used to reach into the cached run and assign a step's sub-state directly,
192
+ // comparing nothing — so a slow HTTP response overwrote state the stream had already advanced, and
193
+ // no later event restored it. `echoAfter` closes that by capturing the run's `rev` before the
194
+ // request and re-reading it after.
195
+ //
196
+ // The fork-decision chat is the case that caught it in CI: `chat` emits the one-message `answering`
197
+ // state and then wakes the driver, which appends the reply and emits again. With a canned (no-model)
198
+ // reply the two-message thread routinely lands first, and echoing the response dropped the reply
199
+ // permanently — a parked run emits nothing more.
200
+ describe('execution store echoAfter (optimistic-echo guard)', () => {
201
+ let store: ReturnType<typeof useExecutionStore>
202
+ beforeEach(() => {
203
+ store = useExecutionStore()
204
+ })
205
+
206
+ const run = (rev: number, chat: unknown[]): ExecutionInstance =>
207
+ ({
208
+ id: 'e1',
209
+ blockId: 'b1',
210
+ rev,
211
+ currentStep: 0,
212
+ steps: [{ agentKind: 'coder', forkDecision: { status: 'answering', chat } }],
213
+ }) as unknown as ExecutionInstance
214
+
215
+ const chatOf = () =>
216
+ (store.getInstance('e1')!.steps[0] as unknown as { forkDecision: { chat: unknown[] } })
217
+ .forkDecision.chat
218
+
219
+ it('applies the echo when nothing newer arrived while the request was in flight', () => {
220
+ store.hydrate([run(1, ['human'])], 'ws1')
221
+ return store
222
+ .echoAfter(
223
+ 'e1',
224
+ async () => ({ status: 'answering', chat: ['human', 'echoed'] }),
225
+ (state, instance) => {
226
+ ;(instance.steps[0] as unknown as { forkDecision: unknown }).forkDecision = state
227
+ },
228
+ )
229
+ .then(() => expect(chatOf()).toEqual(['human', 'echoed']))
230
+ })
231
+
232
+ it('DROPS the echo when the stream delivered a newer revision first', async () => {
233
+ store.hydrate([run(1, ['human'])], 'ws1')
234
+ // The driver's reply lands (rev 2, two messages) while the chat POST is still in flight...
235
+ await store.echoAfter(
236
+ 'e1',
237
+ async () => {
238
+ store.upsert(run(2, ['human', 'assistant reply']))
239
+ return { status: 'answering', chat: ['human'] }
240
+ },
241
+ (state, instance) => {
242
+ ;(instance.steps[0] as unknown as { forkDecision: unknown }).forkDecision = state
243
+ },
244
+ )
245
+ // ...so the one-message response must not put the thread back. Unguarded, this was ['human'],
246
+ // the reply was gone, and the "thinking…" bubble spun forever.
247
+ expect(chatOf()).toEqual(['human', 'assistant reply'])
248
+ })
249
+
250
+ it('still returns the response body when the echo is dropped', async () => {
251
+ store.hydrate([run(1, [])], 'ws1')
252
+ const returned = await store.echoAfter(
253
+ 'e1',
254
+ async () => {
255
+ store.upsert(run(5, ['newer']))
256
+ return 'body'
257
+ },
258
+ () => {
259
+ throw new Error('apply must not run')
260
+ },
261
+ )
262
+ expect(returned).toBe('body')
263
+ })
264
+
265
+ it('skips the echo for a run the cache does not hold, rather than throwing', async () => {
266
+ const returned = await store.echoAfter(
267
+ 'missing',
268
+ async () => 'body',
269
+ () => {
270
+ throw new Error('apply must not run')
271
+ },
272
+ )
273
+ expect(returned).toBe('body')
274
+ })
275
+ })
@@ -130,6 +130,44 @@ export const useExecutionStore = defineStore('execution', () => {
130
130
  } else instances.value.push(instance)
131
131
  }
132
132
 
133
+ /**
134
+ * Run an action that returns a run's authoritative sub-state and apply that state to the cached
135
+ * run as an OPTIMISTIC ECHO — but only when the event stream has not delivered a newer revision
136
+ * while the request was in flight.
137
+ *
138
+ * WHY THIS EXISTS. {@link upsert} and {@link hydrate} are monotonic by `rev`, so a stale stream
139
+ * event can never regress a run. An action store's echo bypassed both: it reached into the cached
140
+ * instance and assigned `step.forkDecision` / `step.prReview` / `step.judge` / `step.followUps`
141
+ * directly, with nothing comparing revisions. That is a live-push CLOBBER in its optimistic-echo
142
+ * form, and it loses state that no later event restores.
143
+ *
144
+ * The fork-decision chat is the case that caught it. `chat` records the human turn and wakes the
145
+ * durable driver, which computes the reply and re-parks — two separate emits. With no model wired
146
+ * the reply is canned, so the driver routinely emits the two-message thread BEFORE the browser has
147
+ * even processed the HTTP response carrying the one-message `answering` state. Echoing that
148
+ * response then dropped the reply back off the thread, permanently: the run is parked, so nothing
149
+ * emits again. It read as a hung "thinking…" bubble to a user and as a flaky spec in CI.
150
+ *
151
+ * The guard is the run's own `rev`, captured BEFORE the request and re-read after. Any advance
152
+ * means the stream has already delivered this write (or something later), so the echo has nothing
153
+ * left to add and is skipped. Unchanged means the echo is still the freshest thing available,
154
+ * which is exactly what it is for. Taking the request as a thunk keeps the capture-then-compare
155
+ * ordering here rather than at four call sites that each have to remember it.
156
+ */
157
+ async function echoAfter<T>(
158
+ executionId: string,
159
+ send: () => Promise<T>,
160
+ apply: (state: T, instance: ExecutionInstance) => void,
161
+ ): Promise<T> {
162
+ const before = byId.value.get(executionId)
163
+ const revBefore = before ? revOf(before) : -1
164
+ const state = await send()
165
+ const instance = byId.value.get(executionId)
166
+ if (!instance || revOf(instance) !== revBefore) return state
167
+ apply(state, instance)
168
+ return state
169
+ }
170
+
133
171
  const byId = computed(() => {
134
172
  const map = new Map<string, ExecutionInstance>()
135
173
  for (const e of instances.value) map.set(e.id, e)
@@ -248,6 +286,7 @@ export const useExecutionStore = defineStore('execution', () => {
248
286
  instances,
249
287
  hydrate,
250
288
  upsert,
289
+ echoAfter,
251
290
  byId,
252
291
  getInstance,
253
292
  getByBlock,
@@ -33,7 +33,7 @@ export const useFollowUpsStore = defineStore('followUps', () => {
33
33
  acting.value = next
34
34
  }
35
35
 
36
- /** Run one decide action, reflecting the returned state onto the run's Coder step. */
36
+ /** Run one decide action, echoing the returned state onto the run's Coder step. */
37
37
  async function act(
38
38
  executionId: string,
39
39
  itemId: string,
@@ -42,13 +42,19 @@ export const useFollowUpsStore = defineStore('followUps', () => {
42
42
  error.value = null
43
43
  mark(itemId, true)
44
44
  try {
45
- const state = await call(workspace.requireId())
46
- // Reflect the authoritative state immediately (the stream will also echo it).
47
- const instance = execution.getInstance(executionId)
48
- const step = instance?.steps.find((s) => s.followUps?.enabled)
49
- if (step && state && typeof state === 'object') {
50
- step.followUps = state as typeof step.followUps
51
- }
45
+ // Echo the authoritative state immediately (the stream also delivers it), but only when the
46
+ // stream has not already delivered something NEWER deciding a follow-up can re-arm the run,
47
+ // so the driver emits while this response is still in flight. See `execution.echoAfter`.
48
+ await execution.echoAfter(
49
+ executionId,
50
+ () => call(workspace.requireId()),
51
+ (state, instance) => {
52
+ const step = instance.steps.find((s) => s.followUps?.enabled)
53
+ if (step && state && typeof state === 'object') {
54
+ step.followUps = state as typeof step.followUps
55
+ }
56
+ },
57
+ )
52
58
  } catch (e) {
53
59
  error.value = e instanceof Error ? e.message : 'Action failed'
54
60
  throw e