Skip to content

Commit 1b5e9a4

Browse files
authored
Merge pull request #637 from hevayo/fix/635-answers-gap-meets-retry
fix(console): submitted answers count as agent work before the turn exists
2 parents a15e140 + 184a2bf commit 1b5e9a4

6 files changed

Lines changed: 392 additions & 4 deletions

File tree

apps/console/design/lexicon.md

Lines changed: 13 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -413,6 +413,19 @@ Requirements — nothing downstream can be written before that document exists,
413413
a member's interview answers (plain prose, no flow) is the very one that writes it (#629). The
414414
moment anything exists, the silence above resumes.
415415

416+
**"In flight" starts at the submit, not at the server.** `spec.agent` cannot see a turn before its
417+
row exists, and submitted interview answers take the dispatch round-trip — seconds — to become one;
418+
in that gap every signal above read idle and the empty workspace offered Retry against the very
419+
interview it could not see ([#635](https://github.com/wso2/labs-agentic-engineer/issues/635)). The
420+
browser that submitted holds the missing evidence — a seeded message waiting, a dispatch awaiting
421+
its turn id, a stream being folded — and that chain counts as agent work until the status catches
422+
up. It is claim-scoped, not timed — a refused send releases its claim and Retry surfaces at once —
423+
with one backstop: a seeded message whose consumer never opens (the one stage with no failure path
424+
of its own) lapses from the signal after a generous TTL, so an outage cannot pin a working state
425+
that hides Retry. A
426+
teammate's browser holds no claim for a send made elsewhere and waits on the status, as it always
427+
did.
428+
416429
**The counts read the LIVE document, not the committed one.** Deleting an `*assumed*` flag clears
417430
the alert as you delete it; the committed copy is a collab flush behind, and on the agent's own
418431
edits that lag was long enough to look broken.

apps/console/src/features/agent-chat/chatStore.test.ts

Lines changed: 115 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -16,7 +16,7 @@
1616
* under the License.
1717
*/
1818

19-
import { beforeEach, describe, expect, it } from "vitest";
19+
import { afterEach, beforeEach, describe, expect, it, vi } from "vitest";
2020

2121
// Node test env: the store only touches localStorage — a Map-backed stub
2222
// keeps the test out of jsdom.
@@ -51,6 +51,11 @@ import {
5151
subscribeTurnEnd,
5252
upsertToolMessage,
5353
clearFailedSends,
54+
claimSendInFlight,
55+
claimStreamFold,
56+
hasLocalTurnActivity,
57+
subscribeLocalTurnActivity,
58+
SEED_ACTIVITY_TTL_MS,
5459
} from "./chatStore";
5560

5661
let n = 0;
@@ -444,3 +449,112 @@ describe("clearFailedSends", () => {
444449
expect(getMessages(KEY)).toBe(before);
445450
});
446451
});
452+
453+
// #635: the chain a submitted send rides from form to turn — seed waiting,
454+
// dispatch in flight, stream being folded. Any stage live means this browser
455+
// holds evidence of a turn the status endpoint may not report yet.
456+
describe("local turn activity", () => {
457+
it("is live through each stage of a send, and collapses when the last releases", () => {
458+
const key = freshKey();
459+
expect(hasLocalTurnActivity(key)).toBe(false);
460+
461+
setPendingSeed(key, "answers");
462+
expect(hasLocalTurnActivity(key)).toBe(true);
463+
464+
// Consumption hands over to the send claim with no dead stage between.
465+
const releaseSend = claimSendInFlight(key);
466+
consumePendingSeed(key);
467+
expect(hasLocalTurnActivity(key)).toBe(true);
468+
469+
const releaseFold = claimStreamFold(key);
470+
releaseSend();
471+
expect(hasLocalTurnActivity(key)).toBe(true);
472+
473+
releaseFold();
474+
expect(hasLocalTurnActivity(key)).toBe(false);
475+
});
476+
477+
it("collapses when a send dies before a turn exists", () => {
478+
const key = freshKey();
479+
const release = claimSendInFlight(key);
480+
expect(hasLocalTurnActivity(key)).toBe(true);
481+
release();
482+
expect(hasLocalTurnActivity(key)).toBe(false);
483+
});
484+
485+
it("notifies on every edge: seed set/consumed, claim taken/released", () => {
486+
const key = freshKey();
487+
let fired = 0;
488+
const unsubscribe = subscribeLocalTurnActivity(key, () => {
489+
fired += 1;
490+
});
491+
setPendingSeed(key, "answers");
492+
consumePendingSeed(key);
493+
const release = claimSendInFlight(key);
494+
release();
495+
expect(fired).toBe(4);
496+
497+
unsubscribe();
498+
setPendingSeed(key, "again");
499+
expect(fired).toBe(4);
500+
consumePendingSeed(key);
501+
});
502+
503+
it("keeps distinct keys apart", () => {
504+
const key1 = freshKey();
505+
const key2 = freshKey();
506+
const release = claimSendInFlight(key1);
507+
expect(hasLocalTurnActivity(key2)).toBe(false);
508+
release();
509+
});
510+
511+
// The seed is the one stage with no failure path of its own: its consumer
512+
// sits behind gates an outage can hold shut, and an unconsumed seed would
513+
// pin a working state that HIDES Retry. So only the seed's contribution
514+
// expires — and expiry is an edge subscribers hear about, or the pane
515+
// would hold the stale state until an unrelated re-render.
516+
describe("seed TTL", () => {
517+
beforeEach(() => vi.useFakeTimers());
518+
afterEach(() => vi.useRealTimers());
519+
520+
it("expires a waiting seed from the signal, and notifies the edge", () => {
521+
const key = freshKey();
522+
let fired = 0;
523+
const unsubscribe = subscribeLocalTurnActivity(key, () => {
524+
fired += 1;
525+
});
526+
setPendingSeed(key, "answers");
527+
expect(hasLocalTurnActivity(key)).toBe(true);
528+
const firedBeforeExpiry = fired;
529+
530+
vi.advanceTimersByTime(SEED_ACTIVITY_TTL_MS);
531+
expect(hasLocalTurnActivity(key)).toBe(false);
532+
expect(fired).toBe(firedBeforeExpiry + 1);
533+
534+
// The seed itself is untouched by the lapse — still there to consume.
535+
expect(peekPendingSeed(key)).toEqual({ message: "answers", guarded: false });
536+
unsubscribe();
537+
consumePendingSeed(key);
538+
});
539+
540+
it("does not expire the claims — their release paths own their end", () => {
541+
const key = freshKey();
542+
const release = claimSendInFlight(key);
543+
vi.advanceTimersByTime(SEED_ACTIVITY_TTL_MS * 2);
544+
expect(hasLocalTurnActivity(key)).toBe(true);
545+
release();
546+
});
547+
548+
it("a fresh seed restarts the clock", () => {
549+
const key = freshKey();
550+
setPendingSeed(key, "first");
551+
vi.advanceTimersByTime(SEED_ACTIVITY_TTL_MS - 1000);
552+
setPendingSeed(key, "second");
553+
vi.advanceTimersByTime(2000);
554+
expect(hasLocalTurnActivity(key)).toBe(true);
555+
vi.advanceTimersByTime(SEED_ACTIVITY_TTL_MS);
556+
expect(hasLocalTurnActivity(key)).toBe(false);
557+
consumePendingSeed(key);
558+
});
559+
});
560+
});

apps/console/src/features/agent-chat/chatStore.ts

Lines changed: 100 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -433,6 +433,7 @@ const seedListeners = new Map<string, Set<() => void>>();
433433
*/
434434
export function setPendingSeed(key: string, message: string, guarded = false): void {
435435
pendingSeeds.set(key, { message, guarded });
436+
stampSeedForActivity(key);
436437
for (const fn of seedListeners.get(key) ?? []) fn();
437438
}
438439

@@ -451,6 +452,7 @@ export function consumePendingSeed(key: string): PendingSeed | null {
451452
const seed = pendingSeeds.get(key);
452453
if (seed === undefined) return null;
453454
pendingSeeds.delete(key);
455+
clearSeedActivity(key);
454456
for (const fn of seedListeners.get(key) ?? []) fn();
455457
return seed;
456458
}
@@ -559,13 +561,15 @@ const inFlightSends = new Map<string, number>();
559561

560562
function claim(counts: Map<string, number>, key: string): () => void {
561563
counts.set(key, (counts.get(key) ?? 0) + 1);
564+
notifyLocalTurnActivity(key);
562565
let released = false;
563566
return () => {
564567
if (released) return; // idempotent: cleanup may run twice (StrictMode)
565568
released = true;
566569
const remaining = (counts.get(key) ?? 1) - 1;
567570
if (remaining <= 0) counts.delete(key);
568571
else counts.set(key, remaining);
572+
notifyLocalTurnActivity(key);
569573
};
570574
}
571575

@@ -590,5 +594,100 @@ export function claimSendInFlight(key: string): () => void {
590594
* is itself appending fresher content than the replace would have written.
591595
*/
592596
export function canReplaceLog(key: string): boolean {
593-
return !attachedFolds.has(key) && !inFlightSends.has(key);
597+
return !hasLiveClaims(key);
598+
}
599+
600+
/** A dispatch or fold this browser currently owns for `key` — the shared base
601+
* of `canReplaceLog` and `hasLocalTurnActivity`, so a future claim map cannot
602+
* be added to one reading and silently missed by the other. */
603+
function hasLiveClaims(key: string): boolean {
604+
return inFlightSends.has(key) || attachedFolds.has(key);
605+
}
606+
607+
// --- Local turn activity (#635) -------------------------------------------
608+
//
609+
// `spec.agent` lags a send by the dispatch round-trip: interview answers leave
610+
// through the seed slot the instant the question form submits, but the turn
611+
// carrying them has no `agent_turns` row until StartTurn answers — seconds
612+
// later, longer under load. In that window the status endpoint reads idle, the
613+
// question form is gone, and an empty project has no files, so every signal
614+
// the spec workspace checks said "nothing running" and it offered Retry
615+
// against an interview mid-flight — #629's hazard surviving as a race.
616+
//
617+
// This browser knows better. The seed slot, the send claim and the fold claim
618+
// chain without a gap from form-submit to the turn's terminal frame (`send()`
619+
// releases the send claim and takes the fold claim in one synchronous
620+
// continuation), and every failure path releases its claim — a refused
621+
// dispatch, a severed stream, a chatKey rotation. So "any of the three is
622+
// live" is precisely "this browser holds evidence of a turn the status
623+
// endpoint may not report yet". The CLAIMS need no expiry timer — the signal
624+
// collapses the moment a send is refused or a turn dies, letting Retry
625+
// surface honestly. The SEED is the one stage with no failure path of its
626+
// own: its sole consumer sits behind gates (the conversation id resolving,
627+
// the history rehydrate landing) that an outage can hold shut indefinitely,
628+
// and a seed nobody consumes would otherwise pin a working state that HIDES
629+
// Retry — strictly worse than the gap being closed. So only the seed's
630+
// contribution expires, on a TTL generous against a slow panel mount; the
631+
// seed itself stays consumable, exactly as before.
632+
//
633+
// Browser-local by nature: a teammate's browser holds no claim for a send
634+
// made here. Their pane recovers through the status poll as it always did —
635+
// this only closes the gap for the member who just submitted.
636+
637+
const localTurnActivityListeners = new Map<string, Set<() => void>>();
638+
639+
function notifyLocalTurnActivity(key: string): void {
640+
for (const fn of localTurnActivityListeners.get(key) ?? []) fn();
641+
}
642+
643+
/** How long a WAITING seed counts as turn activity. Normal consumption is
644+
* near-immediate (the panel is mounted, or mounts on the seed's own signal),
645+
* so this bounds only the pathological stall where the consumer's gates
646+
* never open. */
647+
export const SEED_ACTIVITY_TTL_MS = 30_000;
648+
649+
const seedActivitySetAt = new Map<string, number>();
650+
const seedActivityTimers = new Map<string, ReturnType<typeof setTimeout>>();
651+
652+
function stampSeedForActivity(key: string): void {
653+
seedActivitySetAt.set(key, Date.now());
654+
clearTimeout(seedActivityTimers.get(key));
655+
// The expiry is an EDGE subscribers must hear about — without the wake-up
656+
// call, a pane holding "working" on this seed would keep it until some
657+
// unrelated re-render happened to re-read the snapshot.
658+
seedActivityTimers.set(
659+
key,
660+
setTimeout(() => {
661+
seedActivityTimers.delete(key);
662+
notifyLocalTurnActivity(key);
663+
}, SEED_ACTIVITY_TTL_MS),
664+
);
665+
}
666+
667+
function clearSeedActivity(key: string): void {
668+
seedActivitySetAt.delete(key);
669+
clearTimeout(seedActivityTimers.get(key));
670+
seedActivityTimers.delete(key);
671+
}
672+
673+
/** True while THIS browser holds live evidence of a turn for `key`: a seed
674+
* waiting to send (within its TTL), a dispatch awaiting its turn id, or a
675+
* stream being folded. */
676+
export function hasLocalTurnActivity(key: string): boolean {
677+
const setAt = pendingSeeds.has(key) ? seedActivitySetAt.get(key) : undefined;
678+
const seedLive = setAt !== undefined && Date.now() - setAt < SEED_ACTIVITY_TTL_MS;
679+
return seedLive || hasLiveClaims(key);
680+
}
681+
682+
/** Fires on every edge of `hasLocalTurnActivity`: seed set or consumed, claim
683+
* taken or released. */
684+
export function subscribeLocalTurnActivity(key: string, fn: () => void): () => void {
685+
const unsubscribeSeed = subscribeSeed(key, fn);
686+
const set = localTurnActivityListeners.get(key) ?? new Set();
687+
set.add(fn);
688+
localTurnActivityListeners.set(key, set);
689+
return () => {
690+
unsubscribeSeed();
691+
set.delete(fn);
692+
};
594693
}
Lines changed: 42 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,42 @@
1+
/**
2+
* Copyright (c) 2026, WSO2 LLC. (https://www.wso2.com).
3+
*
4+
* WSO2 LLC. licenses this file to you under the Apache License,
5+
* Version 2.0 (the "License"); you may not use this file except
6+
* in compliance with the License.
7+
* You may obtain a copy of the License at
8+
*
9+
* http://www.apache.org/licenses/LICENSE-2.0
10+
*
11+
* Unless required by applicable law or agreed to in writing,
12+
* software distributed under the License is distributed on an
13+
* "AS IS" BASIS, WITHOUT WARRANTIES OR CONDITIONS OF ANY
14+
* KIND, either express or implied. See the License for the
15+
* specific language governing permissions and limitations
16+
* under the License.
17+
*/
18+
19+
import { useCallback, useSyncExternalStore } from "react";
20+
import { chatKeyFor, hasLocalTurnActivity, subscribeLocalTurnActivity } from "./chatStore.js";
21+
22+
/**
23+
* True while THIS browser holds live evidence of a turn for the project — a
24+
* seeded message waiting to send, a dispatch awaiting its turn id, or a
25+
* stream being folded (#635). Covers the window where `spec.agent` still
26+
* reads idle because the turn's row does not exist server-side yet, which is
27+
* exactly when a surface deciding "is anything running" from status alone
28+
* would wrongly offer Retry against work in flight.
29+
*/
30+
export function useLocalTurnActivity(
31+
org: string,
32+
projectName: string | undefined,
33+
): boolean {
34+
const chatKey = projectName ? chatKeyFor(org, projectName) : null;
35+
return useSyncExternalStore(
36+
useCallback(
37+
(fn: () => void) => (chatKey ? subscribeLocalTurnActivity(chatKey, fn) : () => {}),
38+
[chatKey],
39+
),
40+
() => (chatKey ? hasLocalTurnActivity(chatKey) : false),
41+
);
42+
}

0 commit comments

Comments
 (0)