@wix/himalaya-cli 0.893.0 → 0.895.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.
package/dist/cli.mjs CHANGED
@@ -5241,6 +5241,7 @@ function resolveTranslation(value, catalog, sourceCatalog) {
5241
5241
  var BINDING_SIGILS, BINDING_SIGIL_RE, TRANSLATION_PREFIX, TRANSLATION_KEY_RE;
5242
5242
  var init_bindings = __esm({
5243
5243
  "../../core/ts/src/bindings.ts"() {
5244
+ "use strict";
5244
5245
  BINDING_SIGILS = ["service", "flag", "item", "t"];
5245
5246
  BINDING_SIGIL_RE = new RegExp(
5246
5247
  `^\\$(?:${BINDING_SIGILS.filter((s) => s !== "t").join("|")})\\b|^\\$t:`
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@wix/himalaya-cli",
3
- "version": "0.893.0",
3
+ "version": "0.895.0",
4
4
  "type": "module",
5
5
  "description": "himi — the Himalaya authoring CLI. Build a Tier-5 content package on your own machine, push it to the production serve as a draft, preview it on web + Himi Player, and read worker logs back — the remote dev loop. Wraps @himalaya/serve-cli for transport.",
6
6
  "keywords": [
@@ -64,5 +64,5 @@
64
64
  "artifactId": "himalaya-cli"
65
65
  }
66
66
  },
67
- "falconPackageHash": "f38e92c2fdf1c758a5d00cb4fa45c3ff0f9945647a1ef9932642c7f0"
67
+ "falconPackageHash": "9635f5e7048da477ca7b19e75c3c132a21b7425937ed32d85466ec48"
68
68
  }
@@ -536,6 +536,60 @@ export function countTurns(msgs: ConversationMessage[], role: string): number {
536
536
  return n;
537
537
  }
538
538
 
539
+ /** Has the turn stored under `userMessageId` already been answered?
540
+ *
541
+ * Per-TURN, and that is the entire point. A COUNT of assistant replies says
542
+ * nothing about WHICH turn was answered: a stalled turn sitting behind a later
543
+ * message that did get a reply would read as answered on any count, and expiring
544
+ * it there re-posts a question the server is already holding — the duplicate the
545
+ * latch exists to prevent.
546
+ *
547
+ * So: find the turn, then look at what follows it UNTIL THE NEXT USER TURN. An
548
+ * assistant message in that window answered this one; the next user turn starting
549
+ * first means nothing ever did.
550
+ *
551
+ * A turn that is not in the transcript at all reads as UNANSWERED. The id is the
552
+ * only evidence we have that it exists, and throwing the latch away on a
553
+ * transcript that has not caught up yet is, again, the duplicate.
554
+ *
555
+ * AN EMPTY ASSISTANT ROW IS NOT AN ANSWER — the same rule `countTurns` above
556
+ * states, and for the same reason: the service opens an assistant row before it
557
+ * has written anything into it. Reading that placeholder as the answer is
558
+ * especially bad HERE rather than merely wrong, because the placeholder is
559
+ * exactly what a trigger that was accepted and then lost its response leaves
560
+ * behind. Calling that turn answered retires the latch and the next identical
561
+ * question is persisted a SECOND time — the duplicate this whole latch exists to
562
+ * prevent, produced by the machinery meant to prevent it.
563
+ *
564
+ * Gated on the RAW text, via `messageText`, not on anything bubble-shaped: a
565
+ * widget-only reply (`<<{"widget":…}>>`) is a real answer whose prose is empty,
566
+ * and its non-empty raw text is precisely what separates it from the placeholder.
567
+ *
568
+ * An empty row is SKIPPED rather than treated as a refusal, because the real
569
+ * answer commonly lands in a later row of the same turn. */
570
+ export function turnAnswered(msgs: ConversationMessage[], userMessageId: string): boolean {
571
+ if (!userMessageId) return false;
572
+ let seen = false;
573
+ for (const m of msgs) {
574
+ if (!seen) {
575
+ if (m.id === userMessageId) seen = true;
576
+ continue;
577
+ }
578
+ const role = m.role || "ASSISTANT";
579
+ if (role === "USER") return false; // the next turn began; ours got nothing
580
+ // BOTH halves. An exact role match, because `MessageRole` also carries
581
+ // `UNKNOWN` and the wire may grow more (tool, citation, system) — a row that
582
+ // is merely not-USER is not an answer, and treating it as one retires the
583
+ // latch and re-posts the question. And non-empty raw text, because the service
584
+ // opens an assistant row before writing into it. `countTurns` above matches on
585
+ // exactly these two, and the two scanners read the same transcript: if they
586
+ // disagree, one of them ends the turn while the other leaves the ladder
587
+ // running at the fast cadence.
588
+ if (role === "ASSISTANT" && messageText(m).trim()) return true;
589
+ }
590
+ return false;
591
+ }
592
+
539
593
  // ---------------------------------------------------------------------------
540
594
  // Local recent-conversation list (himalaya.storage)
541
595
  // ---------------------------------------------------------------------------
@@ -694,7 +748,15 @@ export function createAriaChat(o: AriaChatOptions): AriaChatCore {
694
748
  * a transient recovery hint about one failed attempt, not something that
695
749
  * should survive a reload or be persisted with the transcript. Keyed by
696
750
  * conversation AND content so it can only ever re-trigger the exact turn it
697
- * was recorded for. */
751
+ * was recorded for.
752
+ *
753
+ * It also has to EXPIRE, and cannot do so on its own clock. A trigger whose
754
+ * RESPONSE was lost — a timeout, a reset — failed here while succeeding at the
755
+ * service, so the turn runs and the latch is left naming something already
756
+ * answered. Re-triggering that does not duplicate anything; it drops the
757
+ * owner's next identical question instead, which is quieter and worse. The
758
+ * expiry is therefore a question asked of the SERVER at reuse time — see
759
+ * `turnAnswered` — rather than anything inferred from local counts. */
698
760
  let stalledTurn: { convId: string; content: string; userMessageId: string } | null = null;
699
761
 
700
762
  /** Refuse a send that is already on its way. State half + latch half. */
@@ -802,6 +864,13 @@ export function createAriaChat(o: AriaChatOptions): AriaChatCore {
802
864
  // after it fell back to a full send, duplicating the very turn the latch
803
865
  // exists to protect.
804
866
  let retriedId = "";
867
+ // The latch as it stood when this send started, and whether it names THIS
868
+ // send's turn. Out here for the same reason `retriedId` is: the catch has to
869
+ // tell "the latch I was working on" from "a latch that belongs to another
870
+ // turn", and clearing the second is how an unrelated failure destroys a turn
871
+ // the owner is still owed an answer to.
872
+ let latch: typeof stalledTurn = null;
873
+ let supersedes = false;
805
874
  try {
806
875
  const ensured = await ensureConversation(s);
807
876
  convId = ensured.convId;
@@ -816,9 +885,34 @@ export function createAriaChat(o: AriaChatOptions): AriaChatCore {
816
885
  // `createConversation`). Sending without it defaulted the turn to
817
886
  // `ARIA_ORIGIN`, so a consumer that configured its own origin created the
818
887
  // conversation under one name and posted into it under another.
819
- const stalled = stalledTurn && stalledTurn.convId === convId && stalledTurn.content === content
820
- ? stalledTurn
821
- : null;
888
+ //
889
+ // Two questions, deliberately separate. Does the latch name the turn THIS
890
+ // send is about (`supersedes`)? And if so, is that turn still worth
891
+ // re-triggering (`stalled`)?
892
+ latch = stalledTurn;
893
+ supersedes = !!latch && latch.convId === convId && latch.content === content;
894
+ // …and only while it is still UNANSWERED, which is ASKED rather than
895
+ // inferred. A trigger whose response was lost failed here while succeeding
896
+ // at the service, so the answer arrives anyway and the id now names a
897
+ // finished turn; re-triggering it there does not duplicate anything, it
898
+ // drops the owner's new question instead and re-answers the old one — a
899
+ // duplicate is at least visible in the transcript, a dropped question is not.
900
+ //
901
+ // One extra GET, on the retry path only. Local state cannot stand in for it:
902
+ // a COUNT of assistant replies cannot say which turn was answered, and
903
+ // nothing arms the poll ladder after a failed send, so on the very screen
904
+ // this protects there may be no tick to advance a count in the first place.
905
+ //
906
+ // A read that FAILS leaves the latch alone. Unknown has to mean "still
907
+ // stalled": that is the shipped behaviour, and the conditions where this read
908
+ // fails are the same flaky ones that stalled the turn.
909
+ let stalled = supersedes ? latch : null;
910
+ if (stalled) {
911
+ try {
912
+ const seen = messagesOf(await getConversationMessages(convId));
913
+ if (turnAnswered(seen, stalled.userMessageId)) stalled = null;
914
+ } catch { /* unknown — keep the latch, see above */ }
915
+ }
822
916
  if (stalled) {
823
917
  retriedId = stalled.userMessageId;
824
918
  await triggerAssistantMessage(convId, stalled.userMessageId, { origin });
@@ -833,7 +927,25 @@ export function createAriaChat(o: AriaChatOptions): AriaChatCore {
833
927
  // — the turn has been triggered — so leaving `retriedId` set let the catch
834
928
  // below rebuild a latch for a turn already being answered, and the next
835
929
  // identical submission triggered it a second time.
836
- stalledTurn = null;
930
+ //
931
+ // `supersedes`, not unconditional: clearing on ANY success loses a turn that
932
+ // is still stranded across an UNRELATED message. The owner whose question
933
+ // failed to trigger asks something else, it goes through, they come back to
934
+ // the first question — and that retry re-posts it, which is the exact
935
+ // duplicate the latch exists to prevent, one message later. A turn nothing
936
+ // has settled stays latched; `after` above is what eventually retires it.
937
+ //
938
+ // Still one slot, so an unrelated send that FAILS after storing its own turn
939
+ // displaces the older one. Narrowing that wants a queue rather than a latch,
940
+ // and no reported flow reaches it.
941
+ //
942
+ // Compared by IDENTITY, not just `supersedes`: `latch` was read before the
943
+ // awaits above, and actions interleave at every await in this worker (the
944
+ // `creating` latch says the same). `duplicate()` only refuses IDENTICAL
945
+ // content, so a second message can be in flight alongside this one and store
946
+ // its own latch while we wait — clearing on the stale answer would throw
947
+ // away a turn this send never touched.
948
+ if (supersedes && stalledTurn === latch) stalledTurn = null;
837
949
  retriedId = "";
838
950
  // A conversation minted just now starts empty, so the marks taken above
839
951
  // from the PREVIOUS conversation's counts would be ahead of it and its
@@ -862,9 +974,27 @@ export function createAriaChat(o: AriaChatOptions): AriaChatCore {
862
974
  // whose trigger failed (the id arrives on the error), or a RE-TRIGGER of
863
975
  // an already-stored turn that failed again (no id on the error, because
864
976
  // nothing new was stored — the one we were retrying is still the answer).
977
+ //
978
+ // "Any other failure clears the latch" holds only for a latch THIS SEND
979
+ // OWNED. Clearing unconditionally destroys an older, still-stranded turn
980
+ // that this same send had just correctly preserved on the success path
981
+ // above — and there is a whole class of failures with nothing stored:
982
+ // a step-1 failure, a failed create, and (with no `o.poll`) a `busyLoop`
983
+ // transcript read that throws AFTER delivery, where `retriedId` has already
984
+ // been cleared so `keptId` is empty. The owner returns to the first question
985
+ // and it is posted twice: the same defect as the success path, one layer
986
+ // down and under the same flaky conditions.
987
+ //
988
+ // Identity again, and for the reason spelled out on the success path: what
989
+ // is in the slot now may belong to a send that interleaved with this one.
865
990
  const storedId = (e as { storedUserMessageId?: unknown })?.storedUserMessageId;
866
991
  const keptId = typeof storedId === "string" && storedId ? storedId : retriedId;
867
- stalledTurn = convId && keptId ? { convId, content, userMessageId: keptId } : null;
992
+ stalledTurn = convId && keptId
993
+ ? { convId, content, userMessageId: keptId }
994
+ // Nothing stored by this attempt. Drop the latch only if it is the one
995
+ // this send was working on and is still the one we captured; otherwise it
996
+ // belongs to another turn and must survive.
997
+ : (supersedes && stalledTurn === latch ? null : stalledTurn);
868
998
  return {
869
999
  ...base, status: "ready", _pending: "",
870
1000
  // `_siteId` moves with the id or neither does: a conversation remembered
@@ -536,6 +536,60 @@ export function countTurns(msgs: ConversationMessage[], role: string): number {
536
536
  return n;
537
537
  }
538
538
 
539
+ /** Has the turn stored under `userMessageId` already been answered?
540
+ *
541
+ * Per-TURN, and that is the entire point. A COUNT of assistant replies says
542
+ * nothing about WHICH turn was answered: a stalled turn sitting behind a later
543
+ * message that did get a reply would read as answered on any count, and expiring
544
+ * it there re-posts a question the server is already holding — the duplicate the
545
+ * latch exists to prevent.
546
+ *
547
+ * So: find the turn, then look at what follows it UNTIL THE NEXT USER TURN. An
548
+ * assistant message in that window answered this one; the next user turn starting
549
+ * first means nothing ever did.
550
+ *
551
+ * A turn that is not in the transcript at all reads as UNANSWERED. The id is the
552
+ * only evidence we have that it exists, and throwing the latch away on a
553
+ * transcript that has not caught up yet is, again, the duplicate.
554
+ *
555
+ * AN EMPTY ASSISTANT ROW IS NOT AN ANSWER — the same rule `countTurns` above
556
+ * states, and for the same reason: the service opens an assistant row before it
557
+ * has written anything into it. Reading that placeholder as the answer is
558
+ * especially bad HERE rather than merely wrong, because the placeholder is
559
+ * exactly what a trigger that was accepted and then lost its response leaves
560
+ * behind. Calling that turn answered retires the latch and the next identical
561
+ * question is persisted a SECOND time — the duplicate this whole latch exists to
562
+ * prevent, produced by the machinery meant to prevent it.
563
+ *
564
+ * Gated on the RAW text, via `messageText`, not on anything bubble-shaped: a
565
+ * widget-only reply (`<<{"widget":…}>>`) is a real answer whose prose is empty,
566
+ * and its non-empty raw text is precisely what separates it from the placeholder.
567
+ *
568
+ * An empty row is SKIPPED rather than treated as a refusal, because the real
569
+ * answer commonly lands in a later row of the same turn. */
570
+ export function turnAnswered(msgs: ConversationMessage[], userMessageId: string): boolean {
571
+ if (!userMessageId) return false;
572
+ let seen = false;
573
+ for (const m of msgs) {
574
+ if (!seen) {
575
+ if (m.id === userMessageId) seen = true;
576
+ continue;
577
+ }
578
+ const role = m.role || "ASSISTANT";
579
+ if (role === "USER") return false; // the next turn began; ours got nothing
580
+ // BOTH halves. An exact role match, because `MessageRole` also carries
581
+ // `UNKNOWN` and the wire may grow more (tool, citation, system) — a row that
582
+ // is merely not-USER is not an answer, and treating it as one retires the
583
+ // latch and re-posts the question. And non-empty raw text, because the service
584
+ // opens an assistant row before writing into it. `countTurns` above matches on
585
+ // exactly these two, and the two scanners read the same transcript: if they
586
+ // disagree, one of them ends the turn while the other leaves the ladder
587
+ // running at the fast cadence.
588
+ if (role === "ASSISTANT" && messageText(m).trim()) return true;
589
+ }
590
+ return false;
591
+ }
592
+
539
593
  // ---------------------------------------------------------------------------
540
594
  // Local recent-conversation list (himalaya.storage)
541
595
  // ---------------------------------------------------------------------------
@@ -694,7 +748,15 @@ export function createAriaChat(o: AriaChatOptions): AriaChatCore {
694
748
  * a transient recovery hint about one failed attempt, not something that
695
749
  * should survive a reload or be persisted with the transcript. Keyed by
696
750
  * conversation AND content so it can only ever re-trigger the exact turn it
697
- * was recorded for. */
751
+ * was recorded for.
752
+ *
753
+ * It also has to EXPIRE, and cannot do so on its own clock. A trigger whose
754
+ * RESPONSE was lost — a timeout, a reset — failed here while succeeding at the
755
+ * service, so the turn runs and the latch is left naming something already
756
+ * answered. Re-triggering that does not duplicate anything; it drops the
757
+ * owner's next identical question instead, which is quieter and worse. The
758
+ * expiry is therefore a question asked of the SERVER at reuse time — see
759
+ * `turnAnswered` — rather than anything inferred from local counts. */
698
760
  let stalledTurn: { convId: string; content: string; userMessageId: string } | null = null;
699
761
 
700
762
  /** Refuse a send that is already on its way. State half + latch half. */
@@ -802,6 +864,13 @@ export function createAriaChat(o: AriaChatOptions): AriaChatCore {
802
864
  // after it fell back to a full send, duplicating the very turn the latch
803
865
  // exists to protect.
804
866
  let retriedId = "";
867
+ // The latch as it stood when this send started, and whether it names THIS
868
+ // send's turn. Out here for the same reason `retriedId` is: the catch has to
869
+ // tell "the latch I was working on" from "a latch that belongs to another
870
+ // turn", and clearing the second is how an unrelated failure destroys a turn
871
+ // the owner is still owed an answer to.
872
+ let latch: typeof stalledTurn = null;
873
+ let supersedes = false;
805
874
  try {
806
875
  const ensured = await ensureConversation(s);
807
876
  convId = ensured.convId;
@@ -816,9 +885,34 @@ export function createAriaChat(o: AriaChatOptions): AriaChatCore {
816
885
  // `createConversation`). Sending without it defaulted the turn to
817
886
  // `ARIA_ORIGIN`, so a consumer that configured its own origin created the
818
887
  // conversation under one name and posted into it under another.
819
- const stalled = stalledTurn && stalledTurn.convId === convId && stalledTurn.content === content
820
- ? stalledTurn
821
- : null;
888
+ //
889
+ // Two questions, deliberately separate. Does the latch name the turn THIS
890
+ // send is about (`supersedes`)? And if so, is that turn still worth
891
+ // re-triggering (`stalled`)?
892
+ latch = stalledTurn;
893
+ supersedes = !!latch && latch.convId === convId && latch.content === content;
894
+ // …and only while it is still UNANSWERED, which is ASKED rather than
895
+ // inferred. A trigger whose response was lost failed here while succeeding
896
+ // at the service, so the answer arrives anyway and the id now names a
897
+ // finished turn; re-triggering it there does not duplicate anything, it
898
+ // drops the owner's new question instead and re-answers the old one — a
899
+ // duplicate is at least visible in the transcript, a dropped question is not.
900
+ //
901
+ // One extra GET, on the retry path only. Local state cannot stand in for it:
902
+ // a COUNT of assistant replies cannot say which turn was answered, and
903
+ // nothing arms the poll ladder after a failed send, so on the very screen
904
+ // this protects there may be no tick to advance a count in the first place.
905
+ //
906
+ // A read that FAILS leaves the latch alone. Unknown has to mean "still
907
+ // stalled": that is the shipped behaviour, and the conditions where this read
908
+ // fails are the same flaky ones that stalled the turn.
909
+ let stalled = supersedes ? latch : null;
910
+ if (stalled) {
911
+ try {
912
+ const seen = messagesOf(await getConversationMessages(convId));
913
+ if (turnAnswered(seen, stalled.userMessageId)) stalled = null;
914
+ } catch { /* unknown — keep the latch, see above */ }
915
+ }
822
916
  if (stalled) {
823
917
  retriedId = stalled.userMessageId;
824
918
  await triggerAssistantMessage(convId, stalled.userMessageId, { origin });
@@ -833,7 +927,25 @@ export function createAriaChat(o: AriaChatOptions): AriaChatCore {
833
927
  // — the turn has been triggered — so leaving `retriedId` set let the catch
834
928
  // below rebuild a latch for a turn already being answered, and the next
835
929
  // identical submission triggered it a second time.
836
- stalledTurn = null;
930
+ //
931
+ // `supersedes`, not unconditional: clearing on ANY success loses a turn that
932
+ // is still stranded across an UNRELATED message. The owner whose question
933
+ // failed to trigger asks something else, it goes through, they come back to
934
+ // the first question — and that retry re-posts it, which is the exact
935
+ // duplicate the latch exists to prevent, one message later. A turn nothing
936
+ // has settled stays latched; `after` above is what eventually retires it.
937
+ //
938
+ // Still one slot, so an unrelated send that FAILS after storing its own turn
939
+ // displaces the older one. Narrowing that wants a queue rather than a latch,
940
+ // and no reported flow reaches it.
941
+ //
942
+ // Compared by IDENTITY, not just `supersedes`: `latch` was read before the
943
+ // awaits above, and actions interleave at every await in this worker (the
944
+ // `creating` latch says the same). `duplicate()` only refuses IDENTICAL
945
+ // content, so a second message can be in flight alongside this one and store
946
+ // its own latch while we wait — clearing on the stale answer would throw
947
+ // away a turn this send never touched.
948
+ if (supersedes && stalledTurn === latch) stalledTurn = null;
837
949
  retriedId = "";
838
950
  // A conversation minted just now starts empty, so the marks taken above
839
951
  // from the PREVIOUS conversation's counts would be ahead of it and its
@@ -862,9 +974,27 @@ export function createAriaChat(o: AriaChatOptions): AriaChatCore {
862
974
  // whose trigger failed (the id arrives on the error), or a RE-TRIGGER of
863
975
  // an already-stored turn that failed again (no id on the error, because
864
976
  // nothing new was stored — the one we were retrying is still the answer).
977
+ //
978
+ // "Any other failure clears the latch" holds only for a latch THIS SEND
979
+ // OWNED. Clearing unconditionally destroys an older, still-stranded turn
980
+ // that this same send had just correctly preserved on the success path
981
+ // above — and there is a whole class of failures with nothing stored:
982
+ // a step-1 failure, a failed create, and (with no `o.poll`) a `busyLoop`
983
+ // transcript read that throws AFTER delivery, where `retriedId` has already
984
+ // been cleared so `keptId` is empty. The owner returns to the first question
985
+ // and it is posted twice: the same defect as the success path, one layer
986
+ // down and under the same flaky conditions.
987
+ //
988
+ // Identity again, and for the reason spelled out on the success path: what
989
+ // is in the slot now may belong to a send that interleaved with this one.
865
990
  const storedId = (e as { storedUserMessageId?: unknown })?.storedUserMessageId;
866
991
  const keptId = typeof storedId === "string" && storedId ? storedId : retriedId;
867
- stalledTurn = convId && keptId ? { convId, content, userMessageId: keptId } : null;
992
+ stalledTurn = convId && keptId
993
+ ? { convId, content, userMessageId: keptId }
994
+ // Nothing stored by this attempt. Drop the latch only if it is the one
995
+ // this send was working on and is still the one we captured; otherwise it
996
+ // belongs to another turn and must survive.
997
+ : (supersedes && stalledTurn === latch ? null : stalledTurn);
868
998
  return {
869
999
  ...base, status: "ready", _pending: "",
870
1000
  // `_siteId` moves with the id or neither does: a conversation remembered
@@ -536,6 +536,60 @@ export function countTurns(msgs: ConversationMessage[], role: string): number {
536
536
  return n;
537
537
  }
538
538
 
539
+ /** Has the turn stored under `userMessageId` already been answered?
540
+ *
541
+ * Per-TURN, and that is the entire point. A COUNT of assistant replies says
542
+ * nothing about WHICH turn was answered: a stalled turn sitting behind a later
543
+ * message that did get a reply would read as answered on any count, and expiring
544
+ * it there re-posts a question the server is already holding — the duplicate the
545
+ * latch exists to prevent.
546
+ *
547
+ * So: find the turn, then look at what follows it UNTIL THE NEXT USER TURN. An
548
+ * assistant message in that window answered this one; the next user turn starting
549
+ * first means nothing ever did.
550
+ *
551
+ * A turn that is not in the transcript at all reads as UNANSWERED. The id is the
552
+ * only evidence we have that it exists, and throwing the latch away on a
553
+ * transcript that has not caught up yet is, again, the duplicate.
554
+ *
555
+ * AN EMPTY ASSISTANT ROW IS NOT AN ANSWER — the same rule `countTurns` above
556
+ * states, and for the same reason: the service opens an assistant row before it
557
+ * has written anything into it. Reading that placeholder as the answer is
558
+ * especially bad HERE rather than merely wrong, because the placeholder is
559
+ * exactly what a trigger that was accepted and then lost its response leaves
560
+ * behind. Calling that turn answered retires the latch and the next identical
561
+ * question is persisted a SECOND time — the duplicate this whole latch exists to
562
+ * prevent, produced by the machinery meant to prevent it.
563
+ *
564
+ * Gated on the RAW text, via `messageText`, not on anything bubble-shaped: a
565
+ * widget-only reply (`<<{"widget":…}>>`) is a real answer whose prose is empty,
566
+ * and its non-empty raw text is precisely what separates it from the placeholder.
567
+ *
568
+ * An empty row is SKIPPED rather than treated as a refusal, because the real
569
+ * answer commonly lands in a later row of the same turn. */
570
+ export function turnAnswered(msgs: ConversationMessage[], userMessageId: string): boolean {
571
+ if (!userMessageId) return false;
572
+ let seen = false;
573
+ for (const m of msgs) {
574
+ if (!seen) {
575
+ if (m.id === userMessageId) seen = true;
576
+ continue;
577
+ }
578
+ const role = m.role || "ASSISTANT";
579
+ if (role === "USER") return false; // the next turn began; ours got nothing
580
+ // BOTH halves. An exact role match, because `MessageRole` also carries
581
+ // `UNKNOWN` and the wire may grow more (tool, citation, system) — a row that
582
+ // is merely not-USER is not an answer, and treating it as one retires the
583
+ // latch and re-posts the question. And non-empty raw text, because the service
584
+ // opens an assistant row before writing into it. `countTurns` above matches on
585
+ // exactly these two, and the two scanners read the same transcript: if they
586
+ // disagree, one of them ends the turn while the other leaves the ladder
587
+ // running at the fast cadence.
588
+ if (role === "ASSISTANT" && messageText(m).trim()) return true;
589
+ }
590
+ return false;
591
+ }
592
+
539
593
  // ---------------------------------------------------------------------------
540
594
  // Local recent-conversation list (himalaya.storage)
541
595
  // ---------------------------------------------------------------------------
@@ -694,7 +748,15 @@ export function createAriaChat(o: AriaChatOptions): AriaChatCore {
694
748
  * a transient recovery hint about one failed attempt, not something that
695
749
  * should survive a reload or be persisted with the transcript. Keyed by
696
750
  * conversation AND content so it can only ever re-trigger the exact turn it
697
- * was recorded for. */
751
+ * was recorded for.
752
+ *
753
+ * It also has to EXPIRE, and cannot do so on its own clock. A trigger whose
754
+ * RESPONSE was lost — a timeout, a reset — failed here while succeeding at the
755
+ * service, so the turn runs and the latch is left naming something already
756
+ * answered. Re-triggering that does not duplicate anything; it drops the
757
+ * owner's next identical question instead, which is quieter and worse. The
758
+ * expiry is therefore a question asked of the SERVER at reuse time — see
759
+ * `turnAnswered` — rather than anything inferred from local counts. */
698
760
  let stalledTurn: { convId: string; content: string; userMessageId: string } | null = null;
699
761
 
700
762
  /** Refuse a send that is already on its way. State half + latch half. */
@@ -802,6 +864,13 @@ export function createAriaChat(o: AriaChatOptions): AriaChatCore {
802
864
  // after it fell back to a full send, duplicating the very turn the latch
803
865
  // exists to protect.
804
866
  let retriedId = "";
867
+ // The latch as it stood when this send started, and whether it names THIS
868
+ // send's turn. Out here for the same reason `retriedId` is: the catch has to
869
+ // tell "the latch I was working on" from "a latch that belongs to another
870
+ // turn", and clearing the second is how an unrelated failure destroys a turn
871
+ // the owner is still owed an answer to.
872
+ let latch: typeof stalledTurn = null;
873
+ let supersedes = false;
805
874
  try {
806
875
  const ensured = await ensureConversation(s);
807
876
  convId = ensured.convId;
@@ -816,9 +885,34 @@ export function createAriaChat(o: AriaChatOptions): AriaChatCore {
816
885
  // `createConversation`). Sending without it defaulted the turn to
817
886
  // `ARIA_ORIGIN`, so a consumer that configured its own origin created the
818
887
  // conversation under one name and posted into it under another.
819
- const stalled = stalledTurn && stalledTurn.convId === convId && stalledTurn.content === content
820
- ? stalledTurn
821
- : null;
888
+ //
889
+ // Two questions, deliberately separate. Does the latch name the turn THIS
890
+ // send is about (`supersedes`)? And if so, is that turn still worth
891
+ // re-triggering (`stalled`)?
892
+ latch = stalledTurn;
893
+ supersedes = !!latch && latch.convId === convId && latch.content === content;
894
+ // …and only while it is still UNANSWERED, which is ASKED rather than
895
+ // inferred. A trigger whose response was lost failed here while succeeding
896
+ // at the service, so the answer arrives anyway and the id now names a
897
+ // finished turn; re-triggering it there does not duplicate anything, it
898
+ // drops the owner's new question instead and re-answers the old one — a
899
+ // duplicate is at least visible in the transcript, a dropped question is not.
900
+ //
901
+ // One extra GET, on the retry path only. Local state cannot stand in for it:
902
+ // a COUNT of assistant replies cannot say which turn was answered, and
903
+ // nothing arms the poll ladder after a failed send, so on the very screen
904
+ // this protects there may be no tick to advance a count in the first place.
905
+ //
906
+ // A read that FAILS leaves the latch alone. Unknown has to mean "still
907
+ // stalled": that is the shipped behaviour, and the conditions where this read
908
+ // fails are the same flaky ones that stalled the turn.
909
+ let stalled = supersedes ? latch : null;
910
+ if (stalled) {
911
+ try {
912
+ const seen = messagesOf(await getConversationMessages(convId));
913
+ if (turnAnswered(seen, stalled.userMessageId)) stalled = null;
914
+ } catch { /* unknown — keep the latch, see above */ }
915
+ }
822
916
  if (stalled) {
823
917
  retriedId = stalled.userMessageId;
824
918
  await triggerAssistantMessage(convId, stalled.userMessageId, { origin });
@@ -833,7 +927,25 @@ export function createAriaChat(o: AriaChatOptions): AriaChatCore {
833
927
  // — the turn has been triggered — so leaving `retriedId` set let the catch
834
928
  // below rebuild a latch for a turn already being answered, and the next
835
929
  // identical submission triggered it a second time.
836
- stalledTurn = null;
930
+ //
931
+ // `supersedes`, not unconditional: clearing on ANY success loses a turn that
932
+ // is still stranded across an UNRELATED message. The owner whose question
933
+ // failed to trigger asks something else, it goes through, they come back to
934
+ // the first question — and that retry re-posts it, which is the exact
935
+ // duplicate the latch exists to prevent, one message later. A turn nothing
936
+ // has settled stays latched; `after` above is what eventually retires it.
937
+ //
938
+ // Still one slot, so an unrelated send that FAILS after storing its own turn
939
+ // displaces the older one. Narrowing that wants a queue rather than a latch,
940
+ // and no reported flow reaches it.
941
+ //
942
+ // Compared by IDENTITY, not just `supersedes`: `latch` was read before the
943
+ // awaits above, and actions interleave at every await in this worker (the
944
+ // `creating` latch says the same). `duplicate()` only refuses IDENTICAL
945
+ // content, so a second message can be in flight alongside this one and store
946
+ // its own latch while we wait — clearing on the stale answer would throw
947
+ // away a turn this send never touched.
948
+ if (supersedes && stalledTurn === latch) stalledTurn = null;
837
949
  retriedId = "";
838
950
  // A conversation minted just now starts empty, so the marks taken above
839
951
  // from the PREVIOUS conversation's counts would be ahead of it and its
@@ -862,9 +974,27 @@ export function createAriaChat(o: AriaChatOptions): AriaChatCore {
862
974
  // whose trigger failed (the id arrives on the error), or a RE-TRIGGER of
863
975
  // an already-stored turn that failed again (no id on the error, because
864
976
  // nothing new was stored — the one we were retrying is still the answer).
977
+ //
978
+ // "Any other failure clears the latch" holds only for a latch THIS SEND
979
+ // OWNED. Clearing unconditionally destroys an older, still-stranded turn
980
+ // that this same send had just correctly preserved on the success path
981
+ // above — and there is a whole class of failures with nothing stored:
982
+ // a step-1 failure, a failed create, and (with no `o.poll`) a `busyLoop`
983
+ // transcript read that throws AFTER delivery, where `retriedId` has already
984
+ // been cleared so `keptId` is empty. The owner returns to the first question
985
+ // and it is posted twice: the same defect as the success path, one layer
986
+ // down and under the same flaky conditions.
987
+ //
988
+ // Identity again, and for the reason spelled out on the success path: what
989
+ // is in the slot now may belong to a send that interleaved with this one.
865
990
  const storedId = (e as { storedUserMessageId?: unknown })?.storedUserMessageId;
866
991
  const keptId = typeof storedId === "string" && storedId ? storedId : retriedId;
867
- stalledTurn = convId && keptId ? { convId, content, userMessageId: keptId } : null;
992
+ stalledTurn = convId && keptId
993
+ ? { convId, content, userMessageId: keptId }
994
+ // Nothing stored by this attempt. Drop the latch only if it is the one
995
+ // this send was working on and is still the one we captured; otherwise it
996
+ // belongs to another turn and must survive.
997
+ : (supersedes && stalledTurn === latch ? null : stalledTurn);
868
998
  return {
869
999
  ...base, status: "ready", _pending: "",
870
1000
  // `_siteId` moves with the id or neither does: a conversation remembered