switchroom 0.15.9 → 0.15.11
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/agent-scheduler/index.js +2 -80
- package/dist/cli/switchroom.js +6 -82
- package/dist/cli/ui/index.html +71 -1
- package/package.json +1 -1
- package/profiles/_base/cron-session.sh.hbs +10 -6
- package/telegram-plugin/dist/gateway/gateway.js +87 -26
- package/telegram-plugin/gateway/cron-session.ts +34 -0
- package/telegram-plugin/gateway/gateway.ts +118 -35
- package/telegram-plugin/gateway/obligation-ledger.ts +56 -15
- package/telegram-plugin/history.ts +57 -0
- package/telegram-plugin/tests/cron-session.test.ts +36 -0
- package/telegram-plugin/tests/history.test.ts +83 -0
- package/telegram-plugin/tests/obligation-ledger.test.ts +213 -5
- package/telegram-plugin/tests/obligation-store.test.ts +17 -0
|
@@ -105,14 +105,26 @@ describe("ObligationLedger", () => {
|
|
|
105
105
|
expect(L.resolveCloseTarget(undefined, "c:3#715")).toBe("c:3#715");
|
|
106
106
|
});
|
|
107
107
|
|
|
108
|
-
it("no echo + MULTIPLE open → close
|
|
108
|
+
it("no echo + MULTIPLE open + live turn IS open → close the live turn's OWN obligation (Fix 2)", () => {
|
|
109
109
|
const L = new ObligationLedger();
|
|
110
110
|
L.openIfAbsent(input("c:635#713", 1000));
|
|
111
111
|
L.openIfAbsent(input("c:3#715", 1100));
|
|
112
|
-
//
|
|
113
|
-
//
|
|
114
|
-
|
|
115
|
-
expect(L.
|
|
112
|
+
// A substantive reply delivered during currentTurn=715 (no model echo, no
|
|
113
|
+
// routed origin) closes 715's own obligation. 713 stays open and is
|
|
114
|
+
// re-presented. The 713/715 invariant holds: this does NOT close 713.
|
|
115
|
+
expect(L.resolveCloseTarget(undefined, "c:3#715")).toBe("c:3#715");
|
|
116
|
+
expect(L.isOpen("c:635#713")).toBe(true); // 713 stays open
|
|
117
|
+
});
|
|
118
|
+
|
|
119
|
+
it("no echo + MULTIPLE open + live turn NOT one of them → close nothing (713/715 invariant preserved)", () => {
|
|
120
|
+
const L = new ObligationLedger();
|
|
121
|
+
L.openIfAbsent(input("c:635#713", 1000));
|
|
122
|
+
L.openIfAbsent(input("c:3#715", 1100));
|
|
123
|
+
// currentTurn=999 is NOT an open obligation. We can't identify the right
|
|
124
|
+
// target → refuse to close anything (a re-present is safer than a wrong close).
|
|
125
|
+
expect(L.resolveCloseTarget(undefined, "c:9#999")).toBeNull();
|
|
126
|
+
expect(L.isOpen("c:635#713")).toBe(true);
|
|
127
|
+
expect(L.isOpen("c:3#715")).toBe(true);
|
|
116
128
|
});
|
|
117
129
|
|
|
118
130
|
it("no echo + live turn not an open obligation → null", () => {
|
|
@@ -120,6 +132,35 @@ describe("ObligationLedger", () => {
|
|
|
120
132
|
L.openIfAbsent(input("c:3#715", 1100));
|
|
121
133
|
expect(L.resolveCloseTarget(undefined, "c:9#999")).toBeNull();
|
|
122
134
|
});
|
|
135
|
+
|
|
136
|
+
it("routedOriginId (Fix 1) closes the routed target even with multiple open + no model echo", () => {
|
|
137
|
+
// Simulate the clerk incident: two obligations open, agent answers #14057
|
|
138
|
+
// via a quoted reply (via=quoted), no model echo. The gateway resolves
|
|
139
|
+
// routedOriginId=#14057 from the quote. That obligation closes; #14059 stays.
|
|
140
|
+
const L = new ObligationLedger();
|
|
141
|
+
L.openIfAbsent(input("c:0#14057", 1000));
|
|
142
|
+
L.openIfAbsent(input("c:0#14059", 1100));
|
|
143
|
+
expect(L.resolveCloseTarget(undefined, "c:0#14059", "c:0#14057")).toBe("c:0#14057");
|
|
144
|
+
// Close it to confirm only #14057 closes
|
|
145
|
+
L.close("c:0#14057");
|
|
146
|
+
expect(L.isOpen("c:0#14057")).toBe(false);
|
|
147
|
+
expect(L.isOpen("c:0#14059")).toBe(true);
|
|
148
|
+
});
|
|
149
|
+
|
|
150
|
+
it("echoedTurnId wins over routedOriginId when both present (echoed is authoritative)", () => {
|
|
151
|
+
const L = new ObligationLedger();
|
|
152
|
+
L.openIfAbsent(input("c:635#713", 1000));
|
|
153
|
+
L.openIfAbsent(input("c:3#715", 1100));
|
|
154
|
+
// Model explicitly echoed 713 AND router resolved 715 as routed origin.
|
|
155
|
+
// The echo is authoritative — close 713.
|
|
156
|
+
expect(L.resolveCloseTarget("c:635#713", "c:3#715", "c:3#715")).toBe("c:635#713");
|
|
157
|
+
});
|
|
158
|
+
|
|
159
|
+
it("routedOriginId=null falls through to live-turn fallback", () => {
|
|
160
|
+
const L = new ObligationLedger();
|
|
161
|
+
L.openIfAbsent(input("c:3#715", 1100));
|
|
162
|
+
expect(L.resolveCloseTarget(undefined, "c:3#715", null)).toBe("c:3#715");
|
|
163
|
+
});
|
|
123
164
|
});
|
|
124
165
|
});
|
|
125
166
|
|
|
@@ -411,3 +452,170 @@ describe("ObligationLedger — background-work grace (extended-autonomous fix, g
|
|
|
411
452
|
).toBe("represent");
|
|
412
453
|
});
|
|
413
454
|
});
|
|
455
|
+
|
|
456
|
+
describe("ObligationLedger — per-represent grace (Fix 3: clerk 2026-06-13 incident)", () => {
|
|
457
|
+
function input(id: string, openedAt: number) {
|
|
458
|
+
return { originTurnId: id, chatId: "-100123", threadId: 3, messageId: Number(id.split("#").pop() ?? 0), text: "x", openedAt };
|
|
459
|
+
}
|
|
460
|
+
|
|
461
|
+
const REPR_GRACE = 120_000; // 2 min, mirroring the default
|
|
462
|
+
|
|
463
|
+
it("a freshly re-presented obligation is ineligible until representGraceMs elapses", () => {
|
|
464
|
+
const L = new ObligationLedger(2);
|
|
465
|
+
L.openIfAbsent(input("c:3#1", 1000));
|
|
466
|
+
// Re-present fires at t=5000; markRepresented stamps lastRepresentedAt.
|
|
467
|
+
L.markRepresented("c:3#1", 5000);
|
|
468
|
+
// 10s later — still within 120s represent grace → ineligible → none.
|
|
469
|
+
expect(
|
|
470
|
+
L.decideAtIdle({ now: 15000, graceMs: 45000, representGraceMs: REPR_GRACE }).action,
|
|
471
|
+
).toBe("none");
|
|
472
|
+
// 120s + 1ms after re-present → grace expired → act.
|
|
473
|
+
expect(
|
|
474
|
+
L.decideAtIdle({ now: 5000 + REPR_GRACE + 1, graceMs: 45000, representGraceMs: REPR_GRACE }).action,
|
|
475
|
+
).toBe("represent");
|
|
476
|
+
});
|
|
477
|
+
|
|
478
|
+
it("markRepresented stamps lastRepresentedAt and persists", () => {
|
|
479
|
+
const snapshots: Obligation[][] = [];
|
|
480
|
+
const L = new ObligationLedger(2, { onChange: (s) => snapshots.push(s) });
|
|
481
|
+
L.openIfAbsent(input("c:3#1", 1000));
|
|
482
|
+
L.markRepresented("c:3#1", 9000);
|
|
483
|
+
const snap = L.list()[0];
|
|
484
|
+
expect(snap.lastRepresentedAt).toBe(9000);
|
|
485
|
+
expect(snap.representCount).toBe(1);
|
|
486
|
+
// Persisted: onChange fired for open + markRepresented = 2 snapshots
|
|
487
|
+
expect(snapshots.length).toBe(2);
|
|
488
|
+
expect(snapshots[1][0].lastRepresentedAt).toBe(9000);
|
|
489
|
+
});
|
|
490
|
+
|
|
491
|
+
it("representGraceMs=0 (kill switch) → no per-represent grace, acts immediately", () => {
|
|
492
|
+
const L = new ObligationLedger(2);
|
|
493
|
+
L.openIfAbsent(input("c:3#1", 1000));
|
|
494
|
+
L.markRepresented("c:3#1", 5000);
|
|
495
|
+
// Kill switch: representGraceMs=0 → the freshly-represented obligation is still eligible.
|
|
496
|
+
expect(
|
|
497
|
+
L.decideAtIdle({ now: 5001, graceMs: 45000, representGraceMs: 0 }).action,
|
|
498
|
+
).toBe("represent");
|
|
499
|
+
});
|
|
500
|
+
|
|
501
|
+
it("an obligation that was NEVER re-presented has no per-represent grace (no lastRepresentedAt)", () => {
|
|
502
|
+
const L = new ObligationLedger(2);
|
|
503
|
+
L.openIfAbsent(input("c:3#1", 1000));
|
|
504
|
+
// No markRepresented call → no lastRepresentedAt → always eligible on this axis.
|
|
505
|
+
expect(
|
|
506
|
+
L.decideAtIdle({ now: 2000, graceMs: 45000, representGraceMs: REPR_GRACE }).action,
|
|
507
|
+
).toBe("represent");
|
|
508
|
+
});
|
|
509
|
+
|
|
510
|
+
it("per-represent grace survives hydration (durable snapshot)", () => {
|
|
511
|
+
const L = new ObligationLedger(2);
|
|
512
|
+
const now = 10_000;
|
|
513
|
+
L.hydrate([
|
|
514
|
+
{
|
|
515
|
+
originTurnId: "c:3#1",
|
|
516
|
+
chatId: "-100123",
|
|
517
|
+
threadId: 3,
|
|
518
|
+
messageId: 1,
|
|
519
|
+
text: "x",
|
|
520
|
+
openedAt: 1000,
|
|
521
|
+
representCount: 1,
|
|
522
|
+
lastRepresentedAt: now - 5000, // represented 5s ago
|
|
523
|
+
},
|
|
524
|
+
]);
|
|
525
|
+
// 5s after re-present, grace=120s → still within grace → none.
|
|
526
|
+
expect(
|
|
527
|
+
L.decideAtIdle({ now, graceMs: 0, representGraceMs: REPR_GRACE }).action,
|
|
528
|
+
).toBe("none");
|
|
529
|
+
// 120s + 1ms after re-present → grace expired → escalate (count=1 < max=2 → represent).
|
|
530
|
+
expect(
|
|
531
|
+
L.decideAtIdle({ now: now - 5000 + REPR_GRACE + 1, graceMs: 0, representGraceMs: REPR_GRACE }).action,
|
|
532
|
+
).toBe("represent");
|
|
533
|
+
});
|
|
534
|
+
|
|
535
|
+
it("per-represent grace composes with trailing-answer grace: both must clear", () => {
|
|
536
|
+
const L = new ObligationLedger(2);
|
|
537
|
+
L.openIfAbsent(input("c:3#1", 1000));
|
|
538
|
+
L.noteTurnEnded("c:3#1", 5000);
|
|
539
|
+
L.markRepresented("c:3#1", 5000);
|
|
540
|
+
// Both graces active; trailing: turn ended 5s ago (grace=45s → in grace);
|
|
541
|
+
// per-represent: re-presented 5s ago (grace=120s → in grace) → none.
|
|
542
|
+
expect(
|
|
543
|
+
L.decideAtIdle({ now: 10000, graceMs: 45000, representGraceMs: REPR_GRACE }).action,
|
|
544
|
+
).toBe("none");
|
|
545
|
+
// Trailing grace cleared (50s after turn-end), per-represent NOT yet (only 45s after re-present).
|
|
546
|
+
expect(
|
|
547
|
+
L.decideAtIdle({ now: 5000 + 50000, graceMs: 45000, representGraceMs: REPR_GRACE }).action,
|
|
548
|
+
).toBe("none");
|
|
549
|
+
// Both cleared (125s after re-present at t=5000 → t=130000).
|
|
550
|
+
expect(
|
|
551
|
+
L.decideAtIdle({ now: 5000 + REPR_GRACE + 5000, graceMs: 45000, representGraceMs: REPR_GRACE }).action,
|
|
552
|
+
).toBe("represent");
|
|
553
|
+
});
|
|
554
|
+
|
|
555
|
+
it("the clerk incident: two messages arrive, re-present fires at T; the sweep ticks again at T+5s → grace prevents premature escalation", () => {
|
|
556
|
+
// This is the exact sequence from clerk 2026-06-13: obligation re-presented
|
|
557
|
+
// at T, sweep fires at T+5s before the re-present turn has even landed, would
|
|
558
|
+
// immediately fire AGAIN (burning representCount to 2 → escalate in 5 more
|
|
559
|
+
// seconds). With per-represent grace, the T+5s tick is a no-op.
|
|
560
|
+
const L = new ObligationLedger(2);
|
|
561
|
+
const T = 1_000_000;
|
|
562
|
+
L.openIfAbsent(input("c:0#14057", T));
|
|
563
|
+
// First represent at T
|
|
564
|
+
L.markRepresented("c:0#14057", T);
|
|
565
|
+
expect(L.list()[0].representCount).toBe(1);
|
|
566
|
+
// Sweep at T+5s: MUST be "none" (within per-represent grace)
|
|
567
|
+
expect(
|
|
568
|
+
L.decideAtIdle({ now: T + 5000, graceMs: 45000, representGraceMs: REPR_GRACE }).action,
|
|
569
|
+
).toBe("none");
|
|
570
|
+
// Sweep at T+10s: still within grace
|
|
571
|
+
expect(
|
|
572
|
+
L.decideAtIdle({ now: T + 10000, graceMs: 45000, representGraceMs: REPR_GRACE }).action,
|
|
573
|
+
).toBe("none");
|
|
574
|
+
// Sweep after grace: eligible for second represent (not escalate — count=1 < max=2)
|
|
575
|
+
expect(
|
|
576
|
+
L.decideAtIdle({ now: T + REPR_GRACE + 1000, graceMs: 45000, representGraceMs: REPR_GRACE }).action,
|
|
577
|
+
).toBe("represent");
|
|
578
|
+
expect(L.list()[0].representCount).toBe(1); // still 1 — decideAtIdle is pure
|
|
579
|
+
});
|
|
580
|
+
});
|
|
581
|
+
|
|
582
|
+
describe("ObligationLedger — escalation suppression predicate (Fix 4)", () => {
|
|
583
|
+
// Fix 4 is implemented in the gateway (obligationSweep checks
|
|
584
|
+
// hasOutboundDeliveredSince before calling driveEscalation). We test the
|
|
585
|
+
// predicate seam via the pattern the sweep uses: if an outbound was delivered
|
|
586
|
+
// since openedAt, the obligation should be closed silently, not escalated.
|
|
587
|
+
// This suite tests the ledger behaviour that enables that path: after a
|
|
588
|
+
// silent close the ledger is empty and the next sweep sees 'none'.
|
|
589
|
+
function input(id: string, openedAt: number) {
|
|
590
|
+
return { originTurnId: id, chatId: "-100123", threadId: 3, messageId: Number(id.split("#").pop() ?? 0), text: "x", openedAt };
|
|
591
|
+
}
|
|
592
|
+
|
|
593
|
+
it("a silently-closed obligation (gateway closed on outbound-delivered check) leaves ledger empty", () => {
|
|
594
|
+
const L = new ObligationLedger(2);
|
|
595
|
+
L.openIfAbsent(input("c:3#1", 1000));
|
|
596
|
+
// Gateway's Fix 4 path: outbound delivered since openedAt → close silently
|
|
597
|
+
expect(L.close("c:3#1")).toBe(true);
|
|
598
|
+
expect(L.hasOpen()).toBe(false);
|
|
599
|
+
expect(L.decideAtIdle().action).toBe("none");
|
|
600
|
+
});
|
|
601
|
+
|
|
602
|
+
it("an escalation that reaches maxRepresents WITHOUT any known outbound still proceeds (escalate action)", () => {
|
|
603
|
+
const L = new ObligationLedger(2);
|
|
604
|
+
L.openIfAbsent(input("c:3#1", 1000));
|
|
605
|
+
L.markRepresented("c:3#1");
|
|
606
|
+
L.markRepresented("c:3#1");
|
|
607
|
+
// hasOutboundDeliveredSince returns false (no outbound recorded) → escalate
|
|
608
|
+
const d = L.decideAtIdle({ now: 9_999_999, graceMs: 0, representGraceMs: 0 });
|
|
609
|
+
expect(d.action).toBe("escalate");
|
|
610
|
+
expect(d.obligation?.originTurnId).toBe("c:3#1");
|
|
611
|
+
});
|
|
612
|
+
|
|
613
|
+
it("silent close fires onChange (persists the empty set)", () => {
|
|
614
|
+
const snapshots: Obligation[][] = [];
|
|
615
|
+
const L = new ObligationLedger(2, { onChange: (s) => snapshots.push(s) });
|
|
616
|
+
L.openIfAbsent(input("c:3#1", 1000)); // snapshot[0]
|
|
617
|
+
L.close("c:3#1"); // snapshot[1] = []
|
|
618
|
+
expect(snapshots.length).toBe(2);
|
|
619
|
+
expect(snapshots[1]).toEqual([]);
|
|
620
|
+
});
|
|
621
|
+
});
|
|
@@ -101,6 +101,23 @@ describe("obligation-store", () => {
|
|
|
101
101
|
expect(loaded.map((o) => o.originTurnId)).toEqual(["c:3#715", "c:5#900"]);
|
|
102
102
|
});
|
|
103
103
|
|
|
104
|
+
it("round-trips lastRepresentedAt through parse → isObligationRow filter → hydrate", () => {
|
|
105
|
+
// Regression: isObligationRow only checks required fields; optional fields
|
|
106
|
+
// (lastRepresentedAt, lastTurnEndedAt, escalateAttempts) must survive the
|
|
107
|
+
// filter without being stripped. A missing check would silently drop the field
|
|
108
|
+
// and break the per-represent grace window across restarts.
|
|
109
|
+
const { fs } = memFs();
|
|
110
|
+
const snap: Obligation[] = [
|
|
111
|
+
ob("c:3#715", { representCount: 1, lastRepresentedAt: 1_700_000_000_000, lastTurnEndedAt: 1_700_000_001_000 }),
|
|
112
|
+
];
|
|
113
|
+
persistObligations(PATH, fs, snap);
|
|
114
|
+
const loaded = loadObligations(PATH, fs);
|
|
115
|
+
expect(loaded).toHaveLength(1);
|
|
116
|
+
expect(loaded[0]!.lastRepresentedAt).toBe(1_700_000_000_000);
|
|
117
|
+
expect(loaded[0]!.lastTurnEndedAt).toBe(1_700_000_001_000);
|
|
118
|
+
expect(loaded[0]!.representCount).toBe(1);
|
|
119
|
+
});
|
|
120
|
+
|
|
104
121
|
it("never throws on a write failure — degrades to in-memory (logs)", () => {
|
|
105
122
|
const logs: string[] = [];
|
|
106
123
|
const fs: ObligationStoreFsSeam = {
|