@cat-factory/app 0.191.0 → 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.
@@ -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. -->
@@ -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, {
@@ -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
@@ -26,17 +26,20 @@ export const useForkDecisionStore = defineStore('forkDecision', () => {
26
26
  const error = ref<string | null>(null)
27
27
 
28
28
  /**
29
- * Reflect an authoritative fork-decision state onto the run's Coder step. A pipeline may
29
+ * Apply an authoritative fork-decision state to the run's Coder step. A pipeline may
30
30
  * carry more than one `coder` step, so target the step this decision is about rather than
31
31
  * the first one that happens to hold fork state: prefer the step that is still live
32
32
  * (proposing / awaiting the choice / answering), then the current step, and only then fall
33
- * back to the first step carrying fork state. The stream corrects any mismatch, but this
34
- * keeps the immediate optimistic echo on the right step.
33
+ * back to the first step carrying fork state.
34
+ *
35
+ * Only ever called through {@link ExecutionStore.echoAfter}, which drops the echo when the event
36
+ * stream already delivered a newer revision — without that guard this assignment silently
37
+ * regressed the chat thread (see `echoAfter` for the failure it caused).
35
38
  */
36
- function reflect(executionId: string, state: ForkDecisionStepState | null): void {
37
- if (!state) return
38
- const instance = execution.getInstance(executionId)
39
- if (!instance) return
39
+ function assign(
40
+ instance: ReturnType<typeof execution.getInstance> & object,
41
+ state: ForkDecisionStepState,
42
+ ): void {
40
43
  const isLive = (s: (typeof instance.steps)[number]) =>
41
44
  s.agentKind === 'coder' &&
42
45
  (s.forkDecision?.status === 'awaiting_choice' ||
@@ -54,8 +57,13 @@ export const useForkDecisionStore = defineStore('forkDecision', () => {
54
57
  async function load(executionId: string): Promise<void> {
55
58
  error.value = null
56
59
  try {
57
- const state = await api.getForkDecision(workspace.requireId(), executionId)
58
- reflect(executionId, state as ForkDecisionStepState | null)
60
+ await execution.echoAfter(
61
+ executionId,
62
+ () => api.getForkDecision(workspace.requireId(), executionId),
63
+ (state, instance) => {
64
+ if (state) assign(instance, state as ForkDecisionStepState)
65
+ },
66
+ )
59
67
  } catch (e) {
60
68
  error.value = e instanceof Error ? e.message : 'Failed to load'
61
69
  }
@@ -72,8 +80,11 @@ export const useForkDecisionStore = defineStore('forkDecision', () => {
72
80
  error.value = null
73
81
  choosing.value = true
74
82
  try {
75
- const state = await api.chooseFork(workspace.requireId(), executionId, choice)
76
- reflect(executionId, state as ForkDecisionStepState)
83
+ await execution.echoAfter(
84
+ executionId,
85
+ () => api.chooseFork(workspace.requireId(), executionId, choice),
86
+ (state, instance) => assign(instance, state as ForkDecisionStepState),
87
+ )
77
88
  } catch (e) {
78
89
  error.value = e instanceof Error ? e.message : 'Failed to choose'
79
90
  throw e
@@ -85,15 +96,24 @@ export const useForkDecisionStore = defineStore('forkDecision', () => {
85
96
  /**
86
97
  * Send a grounded chat message about the surfaced forks. The reply is computed inline in the
87
98
  * durable driver and arrives via the execution stream; the immediate response is the
88
- * `answering` state (the human message already appended), which we reflect so the thread shows
89
- * the sent turn + a "thinking…" bubble without waiting for the stream.
99
+ * `answering` state (the human message already appended), echoed so the thread shows the sent
100
+ * turn + a "thinking…" bubble without waiting for the stream.
101
+ *
102
+ * The echo is the RACIEST one in the app and must stay guarded: `chat` emits the one-message
103
+ * `answering` state and then wakes the driver, which appends the reply and emits again, so with a
104
+ * canned (no-model) reply the two-message thread frequently reaches the browser first. Applying
105
+ * this response unconditionally dropped the reply and left the bubble spinning forever, since a
106
+ * parked run emits nothing more.
90
107
  */
91
108
  async function chat(executionId: string, text: string): Promise<void> {
92
109
  error.value = null
93
110
  chatting.value = true
94
111
  try {
95
- const state = await api.forkChat(workspace.requireId(), executionId, text)
96
- reflect(executionId, state as ForkDecisionStepState)
112
+ await execution.echoAfter(
113
+ executionId,
114
+ () => api.forkChat(workspace.requireId(), executionId, text),
115
+ (state, instance) => assign(instance, state as ForkDecisionStepState),
116
+ )
97
117
  } catch (e) {
98
118
  error.value = e instanceof Error ? e.message : 'Failed to send message'
99
119
  throw e
@@ -27,13 +27,16 @@ export const useJudgeStore = defineStore('judge', () => {
27
27
  * Reflect an authoritative judge state onto the run's judge step. A pipeline may place more
28
28
  * than one judge, so target the step this verdict is about rather than the first one holding
29
29
  * judge state: prefer the step still awaiting a decision, then the current step, and only then
30
- * fall back to the first step carrying judge state. The stream corrects any mismatch; this
31
- * keeps the immediate optimistic echo on the right step.
30
+ * fall back to the first step carrying judge state.
31
+ *
32
+ * Only ever called through {@link ExecutionStore.echoAfter}, which drops the echo when the event
33
+ * stream already delivered a newer revision — a `bounce` re-arms the producing step, so the
34
+ * driver is emitting fresh state while this response is still in flight.
32
35
  */
33
- function reflect(executionId: string, state: JudgeStepState | null): void {
34
- if (!state) return
35
- const instance = execution.getInstance(executionId)
36
- if (!instance) return
36
+ function assign(
37
+ instance: ReturnType<typeof execution.getInstance> & object,
38
+ state: JudgeStepState,
39
+ ): void {
37
40
  const current = instance.steps[instance.currentStep]
38
41
  const step =
39
42
  instance.steps.find((s) => s.judge?.status === 'awaiting_decision') ??
@@ -46,8 +49,13 @@ export const useJudgeStore = defineStore('judge', () => {
46
49
  async function load(executionId: string): Promise<void> {
47
50
  error.value = null
48
51
  try {
49
- const state = await api.getJudgeState(workspace.requireId(), executionId)
50
- reflect(executionId, state as JudgeStepState | null)
52
+ await execution.echoAfter(
53
+ executionId,
54
+ () => api.getJudgeState(workspace.requireId(), executionId),
55
+ (state, instance) => {
56
+ if (state) assign(instance, state as JudgeStepState)
57
+ },
58
+ )
51
59
  } catch (e) {
52
60
  error.value = e instanceof Error ? e.message : 'Failed to load'
53
61
  }
@@ -66,11 +74,15 @@ export const useJudgeStore = defineStore('judge', () => {
66
74
  error.value = null
67
75
  resolving.value = true
68
76
  try {
69
- const state = await api.resolveJudge(workspace.requireId(), executionId, {
70
- choice,
71
- ...(feedback ? { feedback } : {}),
72
- })
73
- reflect(executionId, state as JudgeStepState)
77
+ await execution.echoAfter(
78
+ executionId,
79
+ () =>
80
+ api.resolveJudge(workspace.requireId(), executionId, {
81
+ choice,
82
+ ...(feedback ? { feedback } : {}),
83
+ }),
84
+ (state, instance) => assign(instance, state as JudgeStepState),
85
+ )
74
86
  } catch (e) {
75
87
  error.value = e instanceof Error ? e.message : 'Failed to resolve'
76
88
  throw e
@@ -20,20 +20,30 @@ export const usePrReviewStore = defineStore('prReview', () => {
20
20
 
21
21
  /** True while a resolve call is in flight (drives the Finish button spinner / disabled state). */
22
22
  const resolving = ref(false)
23
+ /**
24
+ * True while a RESUME call is in flight. Kept separate from `resolving` rather than folded into
25
+ * it: a resume acts during the `reviewing` phase and a resolve during `awaiting_selection`, so
26
+ * sharing one flag would let either action's spinner appear on the other's controls.
27
+ */
28
+ const resuming = ref(false)
23
29
  /** The last error message from an action, surfaced inline; cleared on the next action. */
24
30
  const error = ref<string | null>(null)
25
31
 
26
32
  /**
27
- * Reflect an authoritative PR-review state onto the run's `pr-reviewer` step. A pipeline could
33
+ * Apply an authoritative PR-review state to the run's `pr-reviewer` step. A pipeline could
28
34
  * carry more than one such step, so target the step this review is about: prefer the step that
29
35
  * is still awaiting a selection, then the current step, and only then the first step carrying
30
- * review state. The stream corrects any mismatch; this keeps the immediate optimistic echo on
31
- * the right step.
36
+ * review state.
37
+ *
38
+ * Only ever called through {@link ExecutionStore.echoAfter}, which drops the echo when the event
39
+ * stream already delivered a newer revision. `resume` needs that guard most: it returns a
40
+ * `reviewing` state and then the re-dispatched reviewer starts publishing slice reviews, so an
41
+ * unguarded echo could put the freshly-captured reports back to what the resume saw.
32
42
  */
33
- function reflect(executionId: string, state: PrReviewStepState | null): void {
34
- if (!state) return
35
- const instance = execution.getInstance(executionId)
36
- if (!instance) return
43
+ function assign(
44
+ instance: ReturnType<typeof execution.getInstance> & object,
45
+ state: PrReviewStepState,
46
+ ): void {
37
47
  const isLive = (s: (typeof instance.steps)[number]) =>
38
48
  s.agentKind === 'pr-reviewer' && s.prReview?.status === 'awaiting_selection'
39
49
  const current = instance.steps[instance.currentStep]
@@ -48,8 +58,13 @@ export const usePrReviewStore = defineStore('prReview', () => {
48
58
  async function load(executionId: string): Promise<void> {
49
59
  error.value = null
50
60
  try {
51
- const state = await api.getPrReview(workspace.requireId(), executionId)
52
- reflect(executionId, state as PrReviewStepState | null)
61
+ await execution.echoAfter(
62
+ executionId,
63
+ () => api.getPrReview(workspace.requireId(), executionId),
64
+ (state, instance) => {
65
+ if (state) assign(instance, state as PrReviewStepState)
66
+ },
67
+ )
53
68
  } catch (e) {
54
69
  error.value = e instanceof Error ? e.message : 'Failed to load'
55
70
  }
@@ -69,11 +84,11 @@ export const usePrReviewStore = defineStore('prReview', () => {
69
84
  error.value = null
70
85
  resolving.value = true
71
86
  try {
72
- const state = await api.resolvePrReview(workspace.requireId(), executionId, {
73
- action,
74
- findingIds,
75
- })
76
- reflect(executionId, state as PrReviewStepState)
87
+ await execution.echoAfter(
88
+ executionId,
89
+ () => api.resolvePrReview(workspace.requireId(), executionId, { action, findingIds }),
90
+ (state, instance) => assign(instance, state as PrReviewStepState),
91
+ )
77
92
  } catch (e) {
78
93
  error.value = e instanceof Error ? e.message : 'Failed to resolve review'
79
94
  throw e
@@ -82,13 +97,38 @@ export const usePrReviewStore = defineStore('prReview', () => {
82
97
  }
83
98
  }
84
99
 
100
+ /**
101
+ * Resume a review stuck mid-`reviewing`: the reviewer is re-dispatched for only the slices that
102
+ * never reported, and the already-captured reports are fed back in so the finished slices are
103
+ * re-aggregated rather than re-reviewed. Rejected (409) unless the review is still `reviewing`.
104
+ */
105
+ async function resume(executionId: string): Promise<void> {
106
+ error.value = null
107
+ resuming.value = true
108
+ try {
109
+ await execution.echoAfter(
110
+ executionId,
111
+ () => api.resumePrReview(workspace.requireId(), executionId),
112
+ (state, instance) => assign(instance, state as PrReviewStepState),
113
+ )
114
+ } catch (e) {
115
+ error.value = e instanceof Error ? e.message : 'Failed to resume review'
116
+ throw e
117
+ } finally {
118
+ resuming.value = false
119
+ }
120
+ }
121
+
85
122
  /** Dismiss a finding entirely: it's removed from the review (and the selection). Stays parked. */
86
123
  async function dismiss(executionId: string, findingId: string): Promise<void> {
87
124
  error.value = null
88
125
  resolving.value = true
89
126
  try {
90
- const state = await api.dismissPrReviewFinding(workspace.requireId(), executionId, findingId)
91
- reflect(executionId, state as PrReviewStepState)
127
+ await execution.echoAfter(
128
+ executionId,
129
+ () => api.dismissPrReviewFinding(workspace.requireId(), executionId, findingId),
130
+ (state, instance) => assign(instance, state as PrReviewStepState),
131
+ )
92
132
  } catch (e) {
93
133
  error.value = e instanceof Error ? e.message : 'Failed to dismiss finding'
94
134
  throw e
@@ -110,15 +150,14 @@ export const usePrReviewStore = defineStore('prReview', () => {
110
150
  error.value = null
111
151
  resolving.value = true
112
152
  try {
113
- const state = await api.challengePrReviewFinding(
114
- workspace.requireId(),
153
+ await execution.echoAfter(
115
154
  executionId,
116
- findingId,
117
- {
118
- question,
119
- },
155
+ () =>
156
+ api.challengePrReviewFinding(workspace.requireId(), executionId, findingId, {
157
+ question,
158
+ }),
159
+ (state, instance) => assign(instance, state as PrReviewStepState),
120
160
  )
121
- reflect(executionId, state as PrReviewStepState)
122
161
  } catch (e) {
123
162
  error.value = e instanceof Error ? e.message : 'Failed to challenge finding'
124
163
  throw e
@@ -127,5 +166,5 @@ export const usePrReviewStore = defineStore('prReview', () => {
127
166
  }
128
167
  }
129
168
 
130
- return { resolving, error, load, resolve, dismiss, challenge }
169
+ return { resolving, resuming, error, load, resolve, resume, dismiss, challenge }
131
170
  })
@@ -5660,6 +5660,10 @@
5660
5660
  "pending": "In Warteschlange"
5661
5661
  }
5662
5662
  },
5663
+ "resume": {
5664
+ "action": "Review fortsetzen",
5665
+ "hint": "Hängt es? Beim Fortsetzen werden nur die Abschnitte erneut geprüft, die nie zurückgemeldet haben; die Ergebnisse der fertigen bleiben erhalten."
5666
+ },
5663
5667
  "phase": {
5664
5668
  "planning": "Wird geprüft…",
5665
5669
  "reviewing": "Prüfe {completed}/{total} Abschnitte",
@@ -5836,6 +5836,10 @@
5836
5836
  "pending": "Queued"
5837
5837
  }
5838
5838
  },
5839
+ "resume": {
5840
+ "action": "Resume review",
5841
+ "hint": "Looks stuck? Resuming re-reviews only the chunks that never came back, and keeps the findings from the ones that did."
5842
+ },
5839
5843
  "phase": {
5840
5844
  "planning": "Reviewing…",
5841
5845
  "reviewing": "Reviewing {completed}/{total} slices",
@@ -5648,6 +5648,10 @@
5648
5648
  "pending": "En cola"
5649
5649
  }
5650
5650
  },
5651
+ "resume": {
5652
+ "action": "Reanudar la revisión",
5653
+ "hint": "¿Parece atascada? Al reanudar solo se vuelven a revisar los fragmentos que nunca respondieron y se conservan los hallazgos de los ya terminados."
5654
+ },
5651
5655
  "phase": {
5652
5656
  "planning": "Revisando…",
5653
5657
  "reviewing": "Revisando {completed}/{total} secciones",
@@ -5648,6 +5648,10 @@
5648
5648
  "pending": "En attente"
5649
5649
  }
5650
5650
  },
5651
+ "resume": {
5652
+ "action": "Reprendre la revue",
5653
+ "hint": "Elle semble bloquée ? La reprise ne réexamine que les sections qui n’ont jamais répondu et conserve les constats de celles déjà terminées."
5654
+ },
5651
5655
  "phase": {
5652
5656
  "planning": "Révision…",
5653
5657
  "reviewing": "Révision {completed}/{total} sections",
@@ -5659,6 +5659,10 @@
5659
5659
  "pending": "בתור"
5660
5660
  }
5661
5661
  },
5662
+ "resume": {
5663
+ "action": "המשך סקירה",
5664
+ "hint": "נראה שנתקע? המשך יבדוק מחדש רק את המקטעים שלא חזרו, וישמור את הממצאים של אלה שכבר הושלמו."
5665
+ },
5662
5666
  "phase": {
5663
5667
  "planning": "בודק…",
5664
5668
  "reviewing": "בודק {completed}/{total} מקטעים",
@@ -5660,6 +5660,10 @@
5660
5660
  "pending": "In coda"
5661
5661
  }
5662
5662
  },
5663
+ "resume": {
5664
+ "action": "Riprendi la revisione",
5665
+ "hint": "Sembra bloccata? La ripresa riesamina solo i blocchi che non hanno mai risposto e conserva i rilievi di quelli già completati."
5666
+ },
5663
5667
  "phase": {
5664
5668
  "planning": "Revisione…",
5665
5669
  "reviewing": "Revisione {completed}/{total} sezioni",
@@ -5660,6 +5660,10 @@
5660
5660
  "pending": "待機中"
5661
5661
  }
5662
5662
  },
5663
+ "resume": {
5664
+ "action": "レビューを再開",
5665
+ "hint": "停止しているように見えますか?再開すると、応答がなかったチャンクだけを再レビューし、完了済みチャンクの指摘はそのまま引き継ぎます。"
5666
+ },
5663
5667
  "phase": {
5664
5668
  "planning": "レビュー中…",
5665
5669
  "reviewing": "レビュー中 {completed}/{total} 区分",
@@ -5648,6 +5648,10 @@
5648
5648
  "pending": "W kolejce"
5649
5649
  }
5650
5650
  },
5651
+ "resume": {
5652
+ "action": "Wznów przegląd",
5653
+ "hint": "Wygląda na zawieszony? Wznowienie sprawdza ponownie tylko te fragmenty, które nigdy nie odpowiedziały, i zachowuje ustalenia z już ukończonych."
5654
+ },
5651
5655
  "phase": {
5652
5656
  "planning": "Przegląd…",
5653
5657
  "reviewing": "Przegląd {completed}/{total} fragmentów",
@@ -5660,6 +5660,10 @@
5660
5660
  "pending": "Sırada"
5661
5661
  }
5662
5662
  },
5663
+ "resume": {
5664
+ "action": "İncelemeyi sürdür",
5665
+ "hint": "Takılmış gibi mi görünüyor? Sürdürme yalnızca hiç yanıt vermeyen parçaları yeniden inceler ve tamamlananların bulgularını korur."
5666
+ },
5663
5667
  "phase": {
5664
5668
  "planning": "İnceleniyor…",
5665
5669
  "reviewing": "İnceleniyor {completed}/{total} bölüm",
@@ -5648,6 +5648,10 @@
5648
5648
  "pending": "У черзі"
5649
5649
  }
5650
5650
  },
5651
+ "resume": {
5652
+ "action": "Відновити рецензування",
5653
+ "hint": "Схоже, що зависло? Відновлення повторно перевіряє лише ті фрагменти, які не відповіли, і зберігає висновки вже завершених."
5654
+ },
5651
5655
  "phase": {
5652
5656
  "planning": "Перевірка…",
5653
5657
  "reviewing": "Перевірка {completed}/{total} фрагментів",
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@cat-factory/app",
3
- "version": "0.191.0",
3
+ "version": "0.192.0",
4
4
  "description": "Reusable Nuxt layer for the Agent Architecture Board SPA (components, stores, composables, pages). Consume it from a thin deployment app via `extends: ['@cat-factory/app']` and point it at your backend with NUXT_PUBLIC_API_BASE. See deploy/frontend for an example.",
5
5
  "repository": {
6
6
  "type": "git",
@@ -40,7 +40,7 @@
40
40
  "valibot": "^1.4.2",
41
41
  "vue": "3.5.40",
42
42
  "wretch": "^3.0.9",
43
- "@cat-factory/contracts": "0.198.0"
43
+ "@cat-factory/contracts": "0.199.0"
44
44
  },
45
45
  "devDependencies": {
46
46
  "@toad-contracts/testing": "0.3.2",