switchroom 0.19.16 → 0.19.18

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.
Files changed (66) hide show
  1. package/bin/run-hook.sh +148 -0
  2. package/bin/workspace-dynamic-hook.sh +147 -38
  3. package/dist/agent-scheduler/index.js +11 -3
  4. package/dist/auth-broker/index.js +29 -4
  5. package/dist/cli/notion-write-pretool.mjs +11 -3
  6. package/dist/cli/switchroom.js +8307 -7620
  7. package/dist/host-control/main.js +626 -36
  8. package/dist/vault/approvals/kernel-server.js +30 -5
  9. package/dist/vault/broker/server.js +71 -18
  10. package/package.json +3 -2
  11. package/profiles/_base/start.sh.hbs +8 -4
  12. package/profiles/coding/CLAUDE.md.hbs +1 -1
  13. package/profiles/default/CLAUDE.md.hbs +3 -3
  14. package/profiles/executive-assistant/CLAUDE.md.hbs +1 -1
  15. package/profiles/health-coach/CLAUDE.md.hbs +1 -1
  16. package/skills/mental-model-curator/SKILL.md +8 -6
  17. package/telegram-plugin/bridge/bridge.ts +11 -19
  18. package/telegram-plugin/bridge/mcp-instructions.ts +87 -0
  19. package/telegram-plugin/dist/bridge/bridge.js +15 -20
  20. package/telegram-plugin/dist/gateway/gateway.js +763 -373
  21. package/telegram-plugin/dist/server.js +19 -20
  22. package/telegram-plugin/gateway/boot-card.ts +5 -1
  23. package/telegram-plugin/gateway/boot-probes.ts +113 -0
  24. package/telegram-plugin/gateway/config-approval-handler.test.ts +54 -0
  25. package/telegram-plugin/gateway/config-approval-handler.ts +16 -1
  26. package/telegram-plugin/gateway/disconnect-flush.ts +17 -0
  27. package/telegram-plugin/gateway/gateway.ts +43 -1
  28. package/telegram-plugin/gateway/handback-preturn-signal.ts +61 -7
  29. package/telegram-plugin/gateway/ipc-protocol.ts +5 -0
  30. package/telegram-plugin/gateway/ipc-server.ts +13 -0
  31. package/telegram-plugin/gateway/liveness-wiring.ts +125 -5
  32. package/telegram-plugin/gateway/obligation-ledger.ts +84 -4
  33. package/telegram-plugin/gateway/resume-inbound-builder.ts +13 -4
  34. package/telegram-plugin/gateway/stream-render.ts +24 -5
  35. package/telegram-plugin/hooks/secret-guard-pretool.mjs +249 -76
  36. package/telegram-plugin/registry/turns-schema.test.ts +8 -3
  37. package/telegram-plugin/registry/turns-schema.ts +40 -12
  38. package/telegram-plugin/runtime-metrics.ts +14 -0
  39. package/telegram-plugin/silence-poke.ts +138 -0
  40. package/telegram-plugin/tests/boot-probe-drift.test.ts +152 -0
  41. package/telegram-plugin/tests/gateway-disconnect-flush.test.ts +32 -0
  42. package/telegram-plugin/tests/handback-preturn-signal.test.ts +62 -0
  43. package/telegram-plugin/tests/helpers/liveness-wiring-fixture.ts +178 -0
  44. package/telegram-plugin/tests/ipc-server-validate-config-approval.test.ts +95 -0
  45. package/telegram-plugin/tests/mcp-instructions-budget.test.ts +184 -0
  46. package/telegram-plugin/tests/multitopic-routing-wiring.test.ts +22 -2
  47. package/telegram-plugin/tests/obligation-determinism.test.ts +114 -3
  48. package/telegram-plugin/tests/obligation-ledger.test.ts +310 -0
  49. package/telegram-plugin/tests/registry-turns.test.ts +13 -0
  50. package/telegram-plugin/tests/resume-inbound-builder.test.ts +15 -0
  51. package/telegram-plugin/tests/secret-guard-pretool.test.ts +347 -16
  52. package/telegram-plugin/tests/silence-poke-orphan-reap.test.ts +392 -0
  53. package/telegram-plugin/tests/silence-poke-teardown-notice.test.ts +301 -0
  54. package/telegram-plugin/tests/stream-render-golden.test.ts +103 -1
  55. package/telegram-plugin/tests/tts-normalize.test.ts +43 -0
  56. package/telegram-plugin/tests/voice-normalize-text.test.ts +212 -3
  57. package/telegram-plugin/tts-normalize.ts +6 -4
  58. package/telegram-plugin/voice-normalize-text.ts +168 -11
  59. package/vendor/hindsight-memory/CHANGELOG.md +73 -0
  60. package/vendor/hindsight-memory/scripts/lib/config.py +8 -3
  61. package/vendor/hindsight-memory/scripts/lib/directives.py +62 -4
  62. package/vendor/hindsight-memory/scripts/recall.py +257 -12
  63. package/vendor/hindsight-memory/scripts/retain.py +12 -6
  64. package/vendor/hindsight-memory/scripts/tests/test_directives.py +80 -9
  65. package/vendor/hindsight-memory/scripts/tests/test_recall_integration.py +362 -18
  66. package/vendor/hindsight-memory/settings.json +1 -1
@@ -5,6 +5,7 @@ import {
5
5
  obligationEscalationText,
6
6
  type Obligation,
7
7
  } from "../gateway/obligation-ledger.js";
8
+ import { deriveTurnId } from "../gateway/derive-turn-id.js";
8
9
 
9
10
  function input(id: string, openedAt: number, text = "do the thing") {
10
11
  return { originTurnId: id, chatId: "-100123", threadId: 3, messageId: Number(id.split("#").pop() ?? 0), text, openedAt };
@@ -660,3 +661,312 @@ describe("ObligationLedger — noteCapturedDelivery (#3282)", () => {
660
661
  expect(L.isOpen("c:3#1")).toBe(false);
661
662
  });
662
663
  });
664
+
665
+ describe("ObligationLedger — #3550: the represent grace retires once its turn has ended", () => {
666
+ function input(id: string, openedAt: number) {
667
+ return { originTurnId: id, chatId: "-100123", threadId: 3, messageId: Number(id.split("#").pop() ?? 0), text: "x", openedAt };
668
+ }
669
+ const REPR_GRACE = 120_000; // default
670
+ const TRAIL = 45_000; // OBLIGATION_ESCALATE_GRACE_MS default
671
+
672
+ // The bug: the per-represent grace is a proxy for "the re-present has not
673
+ // landed yet". Once a turn that started after it has ENDED, the proxy is
674
+ // provably false, yet the obligation stayed frozen for the rest of the 120s.
675
+ it("acts once the trailing grace clears, instead of waiting out the full represent window", () => {
676
+ const L = new ObligationLedger(2);
677
+ L.openIfAbsent(input("c:3#1", 0));
678
+ L.markRepresented("c:3#1", 5_000);
679
+ L.noteTurnEnded("c:3#1", 15_000); // the re-presented turn ran and ended
680
+ const eligibleAt = 15_000 + TRAIL + 1; // trailing-answer grace from turn end
681
+ expect(eligibleAt).toBeLessThan(5_000 + REPR_GRACE); // i.e. this is genuinely earlier
682
+ expect(
683
+ L.decideAtIdle({ now: eligibleAt, graceMs: TRAIL, representGraceMs: REPR_GRACE }).action,
684
+ ).toBe("represent");
685
+ });
686
+
687
+ it("still refuses to act during the trailing-answer grace — the answer may be landing", () => {
688
+ const L = new ObligationLedger(2);
689
+ L.openIfAbsent(input("c:3#1", 0));
690
+ L.markRepresented("c:3#1", 5_000);
691
+ L.noteTurnEnded("c:3#1", 15_000);
692
+ // 1ms before the trailing grace expires: the slow answer still has its beat.
693
+ expect(
694
+ L.decideAtIdle({ now: 15_000 + TRAIL - 1, graceMs: TRAIL, representGraceMs: REPR_GRACE }).action,
695
+ ).toBe("none");
696
+ });
697
+
698
+ it("an IN-FLIGHT re-present (no turn ended after it) keeps the FULL window — the protection is untouched", () => {
699
+ const L = new ObligationLedger(2);
700
+ L.openIfAbsent(input("c:3#1", 0));
701
+ L.noteTurnEnded("c:3#1", 4_000); // the turn BEFORE the represent — must not count
702
+ L.markRepresented("c:3#1", 5_000);
703
+ expect(
704
+ L.decideAtIdle({ now: 5_000 + REPR_GRACE - 1, graceMs: TRAIL, representGraceMs: REPR_GRACE }).action,
705
+ ).toBe("none");
706
+ expect(
707
+ L.decideAtIdle({ now: 5_000 + REPR_GRACE + 1, graceMs: TRAIL, representGraceMs: REPR_GRACE }).action,
708
+ ).toBe("represent");
709
+ });
710
+
711
+ it("with the trailing grace disabled the full represent window is kept — never leaves the obligation ungated", () => {
712
+ const L = new ObligationLedger(2);
713
+ L.openIfAbsent(input("c:3#1", 0));
714
+ L.markRepresented("c:3#1", 5_000);
715
+ L.noteTurnEnded("c:3#1", 15_000);
716
+ expect(
717
+ L.decideAtIdle({ now: 16_000, graceMs: 0, representGraceMs: REPR_GRACE }).action,
718
+ ).toBe("none");
719
+ });
720
+
721
+ it("representGraceStillProtecting states the rule directly", () => {
722
+ const P = ObligationLedger.representGraceStillProtecting;
723
+ // in-flight re-present, inside window → still protecting
724
+ expect(P({ lastRepresentedAt: 5_000 }, 10_000, REPR_GRACE, TRAIL)).toBe(true);
725
+ // turn ended after the re-present, but still inside the floor → protecting
726
+ expect(P({ lastRepresentedAt: 5_000, lastTurnEndedAt: 6_000 }, 10_000, REPR_GRACE, TRAIL)).toBe(true);
727
+ // turn ended after the re-present, past the floor → spent
728
+ expect(P({ lastRepresentedAt: 5_000, lastTurnEndedAt: 6_000 }, 5_000 + TRAIL, REPR_GRACE, TRAIL)).toBe(false);
729
+ // turn ended BEFORE the re-present → irrelevant, still protecting
730
+ expect(P({ lastRepresentedAt: 5_000, lastTurnEndedAt: 4_000 }, 10_000, REPR_GRACE, TRAIL)).toBe(true);
731
+ // window already elapsed → not protecting either way
732
+ expect(P({ lastRepresentedAt: 5_000 }, 5_000 + REPR_GRACE, REPR_GRACE, TRAIL)).toBe(false);
733
+ // kill switch / never re-presented
734
+ expect(P({ lastRepresentedAt: 5_000 }, 6_000, 0, TRAIL)).toBe(false);
735
+ expect(P({}, 6_000, REPR_GRACE, TRAIL)).toBe(false);
736
+ // trailing grace disabled → the early-out is withheld
737
+ expect(P({ lastRepresentedAt: 5_000, lastTurnEndedAt: 6_000 }, 10_000, REPR_GRACE, 0)).toBe(true);
738
+ });
739
+
740
+ // ── Review finding 3: the derived rung interval needs a FLOOR ──────────────
741
+ // Retiring the represent window makes the rung interval derived (trailing
742
+ // grace + the represent-turn's duration) where the flat 120s used to floor it
743
+ // regardless of tuning. `MIN_REPRESENT_INTERVAL_MS` closes that footgun as a
744
+ // mechanism rather than a doc warning.
745
+ describe("MIN_REPRESENT_INTERVAL_MS floors the derived rung interval", () => {
746
+ const SNAPPY = 1_000; // SWITCHROOM_OBLIGATION_ESCALATE_GRACE_MS=1000
747
+
748
+ it("a small-but-non-zero trailing grace cannot collapse the ladder to seconds", () => {
749
+ const FLOOR = ObligationLedger.MIN_REPRESENT_INTERVAL_MS;
750
+ const L = new ObligationLedger(2);
751
+ L.openIfAbsent(input("c:3#1", 0));
752
+ L.markRepresented("c:3#1", 5_000);
753
+ L.noteTurnEnded("c:3#1", 6_000); // represent turn ran and ended in 1s
754
+ // Pre-floor this was eligible at 6_000 + 1_000 = 7_000 — 2s after the
755
+ // re-present. The ladder would have escalated inside ~15-20s.
756
+ expect(
757
+ L.decideAtIdle({ now: 7_000, graceMs: SNAPPY, representGraceMs: REPR_GRACE }).action,
758
+ ).toBe("none");
759
+ // Still held right up to the floor…
760
+ expect(
761
+ L.decideAtIdle({ now: 5_000 + FLOOR - 1, graceMs: SNAPPY, representGraceMs: REPR_GRACE }).action,
762
+ ).toBe("none");
763
+ // …and released at it (the floor, not the full 120s window — the #3550
764
+ // early-out is clamped, not reverted).
765
+ expect(
766
+ L.decideAtIdle({ now: 5_000 + FLOOR, graceMs: SNAPPY, representGraceMs: REPR_GRACE }).action,
767
+ ).toBe("represent");
768
+ expect(5_000 + FLOOR).toBeLessThan(5_000 + REPR_GRACE);
769
+ });
770
+
771
+ it("the floor never EXTENDS a window past the represent grace the operator configured", () => {
772
+ // representGraceMs below the floor: the configured window still wins, so
773
+ // the clamp can only ever shorten-cap, never lengthen.
774
+ const SHORT_REPR = 10_000;
775
+ expect(SHORT_REPR).toBeLessThan(ObligationLedger.MIN_REPRESENT_INTERVAL_MS);
776
+ const L = new ObligationLedger(2);
777
+ L.openIfAbsent(input("c:3#1", 0));
778
+ L.markRepresented("c:3#1", 5_000);
779
+ L.noteTurnEnded("c:3#1", 6_000);
780
+ expect(
781
+ L.decideAtIdle({ now: 5_000 + SHORT_REPR, graceMs: SNAPPY, representGraceMs: SHORT_REPR }).action,
782
+ ).toBe("represent");
783
+ });
784
+
785
+ it("at the shipped defaults the floor never binds — behaviour is unchanged", () => {
786
+ // 45s trailing ≥ the 30s floor, and it is measured from a turn end that is
787
+ // strictly LATER than the re-present, so the trailing grace always expires
788
+ // after the floor. The clamp is inert in the shipped configuration.
789
+ const P = ObligationLedger.representGraceStillProtecting;
790
+ for (const turnDurationMs of [1, 1_000, 10_000, 60_000]) {
791
+ const represented = 5_000;
792
+ const turnEnded = represented + turnDurationMs;
793
+ const eligibleAt = turnEnded + TRAIL; // what the trailing grace alone allows
794
+ expect(P({ lastRepresentedAt: represented, lastTurnEndedAt: turnEnded }, eligibleAt, REPR_GRACE, TRAIL)).toBe(false);
795
+ }
796
+ });
797
+ });
798
+
799
+ // ── Review finding 5: a turn that ended WITHOUT consuming its re-present ───
800
+ // `representGraceStillProtecting` retires the window on a bare timestamp
801
+ // comparison (`lastTurnEndedAt > lastRepresentedAt`) and the doc comment
802
+ // justifies that as "the agent ran a whole turn on it". The code cannot in
803
+ // fact distinguish a consuming turn from a turn that ended without ever
804
+ // seeing the re-present — a bridge-down abort, an instantly-failed turn, or
805
+ // one torn down by #3575's silence-poke fallback. These tests pin what the
806
+ // ledger ACTUALLY does in that case, so the behaviour is asserted rather than
807
+ // assumed by the comment.
808
+ describe("a turn that ends WITHOUT consuming the re-present", () => {
809
+ it("retires the window on the bare timestamp — but never before the floor", () => {
810
+ const FLOOR = ObligationLedger.MIN_REPRESENT_INTERVAL_MS;
811
+ const L = new ObligationLedger(2);
812
+ L.openIfAbsent(input("c:3#1", 0));
813
+ L.markRepresented("c:3#1", 5_000);
814
+ // A turn that died 200ms after the represent fired. It cannot have carried
815
+ // the re-presented text to the agent, yet it stamps lastTurnEndedAt.
816
+ L.noteTurnEnded("c:3#1", 5_200);
817
+ // The ledger treats it as spent, so the next rung is NOT held for the full
818
+ // 120s. It is held for max(floor, trailing-from-turn-end) instead.
819
+ const eligibleAt = Math.max(5_000 + FLOOR, 5_200 + TRAIL);
820
+ expect(
821
+ L.decideAtIdle({ now: eligibleAt - 1, graceMs: TRAIL, representGraceMs: REPR_GRACE }).action,
822
+ ).toBe("none");
823
+ expect(
824
+ L.decideAtIdle({ now: eligibleAt, graceMs: TRAIL, representGraceMs: REPR_GRACE }).action,
825
+ ).toBe("represent");
826
+ // Documented consequence: an aborted turn shortens the rung. That is
827
+ // SAFE-BY-DIRECTION — a turn that never consumed the re-present is a turn
828
+ // that never answered, so re-presenting sooner is the correct action; the
829
+ // cost is one rung of the (already bounded) budget spent earlier.
830
+ expect(eligibleAt).toBeLessThan(5_000 + REPR_GRACE);
831
+ });
832
+
833
+ it("burns at most maxRepresents rungs even if EVERY turn aborts instantly", () => {
834
+ // The worst case for the timestamp proxy: no turn ever consumes anything.
835
+ // Termination must still hold and the ladder must still land on escalate.
836
+ const FLOOR = ObligationLedger.MIN_REPRESENT_INTERVAL_MS;
837
+ const L = new ObligationLedger(2);
838
+ L.openIfAbsent(input("c:3#1", 0));
839
+ let now = 0;
840
+ const actions: string[] = [];
841
+ for (let i = 0; i < 5; i++) {
842
+ const d = L.decideAtIdle({ now, graceMs: TRAIL, representGraceMs: REPR_GRACE });
843
+ actions.push(d.action);
844
+ if (d.action === "represent") {
845
+ L.markRepresented("c:3#1", now);
846
+ L.noteTurnEnded("c:3#1", now + 200); // instant abort — nothing consumed
847
+ now += Math.max(FLOOR, TRAIL) + 1_000;
848
+ } else if (d.action === "escalate") {
849
+ break;
850
+ } else {
851
+ now += 5_000;
852
+ }
853
+ }
854
+ expect(actions.filter((a) => a === "represent")).toHaveLength(2); // maxRepresents
855
+ expect(actions.at(-1)).toBe("escalate");
856
+ });
857
+
858
+ it("a turn that ended BEFORE the re-present is still not mistaken for a consuming one", () => {
859
+ // The complement: only a STRICTLY later stamp retires the window, so a
860
+ // stale pre-represent turn end can never shorten it.
861
+ const L = new ObligationLedger(2);
862
+ L.openIfAbsent(input("c:3#1", 0));
863
+ L.noteTurnEnded("c:3#1", 4_999);
864
+ L.markRepresented("c:3#1", 5_000);
865
+ expect(
866
+ L.decideAtIdle({ now: 5_000 + REPR_GRACE - 1, graceMs: TRAIL, representGraceMs: REPR_GRACE }).action,
867
+ ).toBe("none");
868
+ });
869
+ });
870
+
871
+ // ── Review finding 6: the hydrated post-restart shape ──────────────────────
872
+ // A snapshot restored with lastTurnEndedAt > lastRepresentedAt hits the
873
+ // early-out on the FIRST sweep after boot. Pre-#3550 the represent window was
874
+ // measured from lastRepresentedAt and survived the restart, so a crash 2s
875
+ // after a re-present still cost 118s before the next rung.
876
+ describe("hydrated post-restart obligation (E1 > R1)", () => {
877
+ it("cannot fire on the first sweep after boot — the floor survives hydration", () => {
878
+ const FLOOR = ObligationLedger.MIN_REPRESENT_INTERVAL_MS;
879
+ const L = new ObligationLedger(2);
880
+ L.hydrate([
881
+ {
882
+ ...input("c:3#1", 0),
883
+ representCount: 1,
884
+ lastRepresentedAt: 100_000,
885
+ lastTurnEndedAt: 101_000, // the turn the crash ended
886
+ },
887
+ ]);
888
+ // Boot sweep, ~5s after the crash. Without the floor this is eligible at
889
+ // 101_000+TRAIL and, with a snappier trailing tune, immediately — racing
890
+ // the boot-resume inbound so the user sees the resume AND a re-present.
891
+ expect(
892
+ L.decideAtIdle({ now: 105_000, graceMs: TRAIL, representGraceMs: REPR_GRACE }).action,
893
+ ).toBe("none");
894
+ expect(
895
+ L.decideAtIdle({ now: 105_000, graceMs: 1_000, representGraceMs: REPR_GRACE }).action,
896
+ ).toBe("none");
897
+ // The floor is measured from the DURABLE lastRepresentedAt, so it holds
898
+ // across the restart rather than restarting from boot.
899
+ expect(
900
+ L.decideAtIdle({ now: 100_000 + FLOOR - 1, graceMs: 1_000, representGraceMs: REPR_GRACE }).action,
901
+ ).toBe("none");
902
+ expect(
903
+ L.decideAtIdle({ now: 100_000 + FLOOR, graceMs: 1_000, representGraceMs: REPR_GRACE }).action,
904
+ ).toBe("represent");
905
+ });
906
+
907
+ it("still eventually advances after a restart — the floor delays, never prevents", () => {
908
+ const L = new ObligationLedger(2);
909
+ L.hydrate([
910
+ { ...input("c:3#1", 0), representCount: 1, lastRepresentedAt: 100_000, lastTurnEndedAt: 101_000 },
911
+ ]);
912
+ expect(
913
+ L.decideAtIdle({ now: 101_000 + TRAIL, graceMs: TRAIL, representGraceMs: REPR_GRACE }).action,
914
+ ).toBe("represent");
915
+ });
916
+ });
917
+ });
918
+
919
+ describe("#3550 G2 — the seam that makes noteTurnEnded actually fire in production", () => {
920
+ // Every other #3550 test calls noteTurnEnded() directly, so they prove the
921
+ // LEDGER RULE but not the WIRING. In production the stamp only lands because
922
+ // the represent inbound round-trips back to the same key: the gateway builds
923
+ // the inbound from the obligation, and the turn it spawns derives its turn id
924
+ // from that inbound's chat/thread/message. noteTurnEnded is a keyed lookup —
925
+ // if `deriveTurnId(buildObligationRepresentInbound(o)) !== o.originTurnId` it
926
+ // silently no-ops, this entire fix goes dead in the field, and every other
927
+ // test in this file still passes green. That linkage was asserted only by a
928
+ // comment at obligation-wiring.ts:250-256. This pins it.
929
+ function obligationFor(chatId: string, threadId: number | undefined, messageId: number) {
930
+ const L = new ObligationLedger(2);
931
+ const originTurnId = deriveTurnId(chatId, threadId ?? null, messageId)!;
932
+ L.openIfAbsent({ originTurnId, chatId, threadId, messageId, text: "do the thing", openedAt: 0 });
933
+ return L.list()[0];
934
+ }
935
+
936
+ const cases: Array<[string, string, number | undefined, number]> = [
937
+ ["forum topic", "-100123", 3, 715],
938
+ ["DM (no thread)", "12345", undefined, 42],
939
+ ["general topic id 1", "-100999", 1, 8],
940
+ ];
941
+
942
+ for (const [label, chatId, threadId, messageId] of cases) {
943
+ it(`round-trips the origin id through the represent inbound — ${label}`, () => {
944
+ const o = obligationFor(chatId, threadId, messageId);
945
+ const inbound = buildObligationRepresentInbound(o, 1_000);
946
+ const derived = deriveTurnId(inbound.chatId, inbound.threadId ?? null, inbound.messageId);
947
+ expect(derived).toBe(o.originTurnId);
948
+ });
949
+ }
950
+
951
+ it("a represent-spawned turn's derived key actually reaches noteTurnEnded (end-to-end on the real seam)", () => {
952
+ const L = new ObligationLedger(2);
953
+ const originTurnId = deriveTurnId("-100123", 3, 715)!;
954
+ L.openIfAbsent({ originTurnId, chatId: "-100123", threadId: 3, messageId: 715, text: "x", openedAt: 0 });
955
+ L.markRepresented(originTurnId, 5_000);
956
+ // Stamp using ONLY what a represent-spawned turn would know: the inbound.
957
+ const inbound = buildObligationRepresentInbound(L.list()[0], 5_000);
958
+ const turnKey = deriveTurnId(inbound.chatId, inbound.threadId ?? null, inbound.messageId)!;
959
+ L.noteTurnEnded(turnKey, 15_000);
960
+ // If the seam drifted, noteTurnEnded no-ops, lastTurnEndedAt stays undefined,
961
+ // the #3550 early-out never fires, and this stays "none" until t=125_000.
962
+ expect(L.list()[0].lastTurnEndedAt).toBe(15_000);
963
+ expect(
964
+ L.decideAtIdle({ now: 15_000 + 45_000 + 1, graceMs: 45_000, representGraceMs: 120_000 }).action,
965
+ ).toBe("represent");
966
+ });
967
+
968
+ it("the represent inbound also echoes origin_turn_id in meta (the close-side half of the same seam)", () => {
969
+ const o = obligationFor("-100123", 3, 715);
970
+ expect(buildObligationRepresentInbound(o, 1_000).meta?.origin_turn_id).toBe(o.originTurnId);
971
+ });
972
+ });
@@ -462,6 +462,19 @@ describe('findLatestTurnIfInterrupted', () => {
462
462
  db.close()
463
463
  })
464
464
 
465
+ it("returns a reaped_stale-stamped turn as interrupted (#3555 — the rename must not drop resume)", () => {
466
+ // If 'reaped_stale' were left out of INTERRUPTED_VIA, the honest label
467
+ // would silently cost the user their resume — the exact regression the
468
+ // rename must not cause.
469
+ const db = openTurnsDbInMemory()
470
+ recordTurnStart(db, { turnKey: 'rs:1', chatId: 'rs' })
471
+ recordTurnEnd(db, { turnKey: 'rs:1', endedVia: 'reaped_stale' })
472
+ const t = findLatestTurnIfInterrupted(db)
473
+ expect(t).not.toBeNull()
474
+ expect(t!.ended_via).toBe('reaped_stale')
475
+ db.close()
476
+ })
477
+
465
478
  it('returns a restart-stamped turn as interrupted', () => {
466
479
  const db = openTurnsDbInMemory()
467
480
  recordTurnStart(db, { turnKey: 'bbb:1', chatId: 'bbb' })
@@ -368,6 +368,7 @@ describe('selectResumeBuilder', () => {
368
368
  const cases: Array<[TurnEndedVia | null, 'resume' | 'report' | null]> = [
369
369
  ['timeout', 'report'],
370
370
  ['restart', 'resume'],
371
+ ['reaped_stale', 'resume'], // #3555 — same clean-interrupt policy as restart
371
372
  ['sigterm', 'resume'],
372
373
  ['unknown', 'resume'],
373
374
  [null, 'resume'],
@@ -393,6 +394,20 @@ describe('selectResumeBuilder', () => {
393
394
  expect(selectResumeBuilder('timeout', { ageMs: MAX + 1, maxAgeMs: MAX })).toBe('report')
394
395
  expect(selectResumeBuilder('stop', { ageMs: MAX + 1, maxAgeMs: MAX })).toBe(null)
395
396
  })
397
+ // #3555 — the mid-session sweep stopped mislabelling itself as 'restart'.
398
+ // Renaming the label must not change what the gateway DOES with the turn:
399
+ // 'reaped_stale' has to behave identically to 'restart' at every input.
400
+ it("'reaped_stale' is policy-identical to 'restart' (rename is forensic only)", () => {
401
+ expect(selectResumeBuilder('reaped_stale')).toBe(selectResumeBuilder('restart'))
402
+ expect(selectResumeBuilder('reaped_stale', { ageMs: MAX - 1, maxAgeMs: MAX }))
403
+ .toBe(selectResumeBuilder('restart', { ageMs: MAX - 1, maxAgeMs: MAX }))
404
+ expect(selectResumeBuilder('reaped_stale', { ageMs: MAX + 1, maxAgeMs: MAX }))
405
+ .toBe(selectResumeBuilder('restart', { ageMs: MAX + 1, maxAgeMs: MAX }))
406
+ // and pin the absolute values so a future change to BOTH is still caught
407
+ expect(selectResumeBuilder('reaped_stale')).toBe('resume')
408
+ expect(selectResumeBuilder('reaped_stale', { ageMs: MAX + 1, maxAgeMs: MAX })).toBe('report')
409
+ })
410
+
396
411
  it('legacy behaviour preserved when age/maxAge omitted (blanket resume)', () => {
397
412
  expect(selectResumeBuilder('restart')).toBe('resume')
398
413
  expect(selectResumeBuilder('restart', { ageMs: MAX + 1 })).toBe('resume') // needs BOTH to cap