@@ -433,6 +433,7 @@ const seedListeners = new Map<string, Set<() => void>>();
433433 */
434434export 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
560562function 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 */
592596export 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}
0 commit comments