switchroom 0.14.60 → 0.14.62
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/switchroom.js +73 -62
- package/package.json +1 -1
- package/telegram-plugin/dist/gateway/gateway.js +2586 -2061
- package/telegram-plugin/gateway/auth-broker-client.ts +18 -8
- package/telegram-plugin/gateway/gateway.ts +401 -14
- package/telegram-plugin/gateway/microsoft-connect-flow.ts +226 -0
- package/telegram-plugin/gateway/obligation-ledger.ts +65 -2
- package/telegram-plugin/gateway/obligation-store.ts +107 -0
- package/telegram-plugin/gateway/with-deadline.ts +43 -0
- package/telegram-plugin/tests/microsoft-connect-flow.test.ts +185 -0
- package/telegram-plugin/tests/obligation-determinism.test.ts +241 -0
- package/telegram-plugin/tests/obligation-ledger.test.ts +69 -0
- package/telegram-plugin/tests/obligation-store.test.ts +117 -0
- package/telegram-plugin/tests/with-deadline.test.ts +61 -0
|
@@ -0,0 +1,241 @@
|
|
|
1
|
+
import { describe, it, expect } from "vitest";
|
|
2
|
+
import { ObligationLedger, type Obligation } from "../gateway/obligation-ledger.js";
|
|
3
|
+
import {
|
|
4
|
+
loadObligations,
|
|
5
|
+
persistObligations,
|
|
6
|
+
type ObligationStoreFsSeam,
|
|
7
|
+
} from "../gateway/obligation-store.js";
|
|
8
|
+
|
|
9
|
+
/**
|
|
10
|
+
* REGRESSION GUARD — not the proof.
|
|
11
|
+
*
|
|
12
|
+
* The actual determinism argument is closed-form and lives WITH the code: the
|
|
13
|
+
* ledger is a finite FSM with a total transition function and a strictly-
|
|
14
|
+
* decreasing measure μ = (REPRESENT_MAX - representCount) + (ESCALATE_MAX -
|
|
15
|
+
* escalateAttempts) ⇒ every OPEN reaches a terminal (see the proof comment on
|
|
16
|
+
* obligationSweep in gateway.ts and the ledger methods in obligation-ledger.ts).
|
|
17
|
+
* A total state-machine proof also found — and a fix closed — the one liveness
|
|
18
|
+
* hole this kind of SAMPLING test structurally cannot reach: a hung escalation
|
|
19
|
+
* send leaking the in-flight flag (now bounded by withDeadline; guarded by
|
|
20
|
+
* with-deadline.test.ts). The lesson stands: a random-schedule test only
|
|
21
|
+
* exercises the behaviours its model encodes; it is evidence, never the proof.
|
|
22
|
+
*
|
|
23
|
+
* What this file still earns its keep doing: drive the REAL ObligationLedger +
|
|
24
|
+
* REAL durable snapshot store over many random {model-behaviour × timing ×
|
|
25
|
+
* restart} schedules to catch a regression that breaks the FSM invariant
|
|
26
|
+
* (no silent drop, no double-ask of an answered message, bounded termination).
|
|
27
|
+
* It models the lifecycle SYNCHRONOUSLY (open at receipt; close at turn_end on a
|
|
28
|
+
* delivered answer; bounded represent→escalate; restart = hydrate from snapshot)
|
|
29
|
+
* — so it does NOT and cannot cover async/coupling liveness (hung send, gate
|
|
30
|
+
* never opening, drain wedging); those are proven/bounded in the code, not here.
|
|
31
|
+
* The coalesced PARTIAL-ANSWER residual is also out of model — the one honest
|
|
32
|
+
* hard limit (a turn-keyed ledger can't see "answered half" without parsing the
|
|
33
|
+
* model's prose), mitigated by coalescing policy, not the ledger.
|
|
34
|
+
*/
|
|
35
|
+
|
|
36
|
+
// Mirrors the gateway constants under test.
|
|
37
|
+
const MAX_REPRESENTS = 2;
|
|
38
|
+
const ESCALATE_MAX = 3;
|
|
39
|
+
|
|
40
|
+
// Deterministic PRNG (mulberry32) so any failure reproduces from its seed.
|
|
41
|
+
function rng(seed: number): () => number {
|
|
42
|
+
let a = seed >>> 0;
|
|
43
|
+
return () => {
|
|
44
|
+
a |= 0;
|
|
45
|
+
a = (a + 0x6d2b79f5) | 0;
|
|
46
|
+
let t = Math.imul(a ^ (a >>> 15), 1 | a);
|
|
47
|
+
t = (t + Math.imul(t ^ (t >>> 7), 61 | t)) ^ t;
|
|
48
|
+
return ((t ^ (t >>> 14)) >>> 0) / 4294967296;
|
|
49
|
+
};
|
|
50
|
+
}
|
|
51
|
+
|
|
52
|
+
function memStore(): { fs: ObligationStoreFsSeam } {
|
|
53
|
+
const files = new Map<string, string>();
|
|
54
|
+
return {
|
|
55
|
+
fs: {
|
|
56
|
+
readFileSync: (p) => {
|
|
57
|
+
if (!files.has(p)) throw new Error(`ENOENT ${p}`);
|
|
58
|
+
return files.get(p)!;
|
|
59
|
+
},
|
|
60
|
+
writeFileSync: (p, d) => files.set(p, d),
|
|
61
|
+
renameSync: (a, b) => {
|
|
62
|
+
if (!files.has(a)) throw new Error(`ENOENT ${a}`);
|
|
63
|
+
files.set(b, files.get(a)!);
|
|
64
|
+
files.delete(a);
|
|
65
|
+
},
|
|
66
|
+
existsSync: (p) => files.has(p),
|
|
67
|
+
},
|
|
68
|
+
};
|
|
69
|
+
}
|
|
70
|
+
|
|
71
|
+
type Terminal = "answered" | "escalation-delivered" | "escalation-give-up";
|
|
72
|
+
|
|
73
|
+
interface Msg {
|
|
74
|
+
id: string;
|
|
75
|
+
/** Real numeric Telegram message id (the gateway only opens an obligation
|
|
76
|
+
* when deriveTurnId is non-null, i.e. messageId > 0 — so the durable row
|
|
77
|
+
* always carries a valid number). */
|
|
78
|
+
msgId: number;
|
|
79
|
+
/** Turn attempt index (0 = original, 1 = 1st re-present, 2 = 2nd) at which
|
|
80
|
+
* the model delivers a final answer. >MAX_REPRESENTS ⇒ never answered ⇒ escalates. */
|
|
81
|
+
answerOnAttempt: number;
|
|
82
|
+
/** How many escalation SEND attempts fail before one succeeds. ≥ESCALATE_MAX
|
|
83
|
+
* ⇒ permanently undeliverable ⇒ bounded give-up. */
|
|
84
|
+
escalateFailsFor: number;
|
|
85
|
+
}
|
|
86
|
+
|
|
87
|
+
interface Sim {
|
|
88
|
+
terminals: Map<string, Terminal>;
|
|
89
|
+
steps: number;
|
|
90
|
+
}
|
|
91
|
+
|
|
92
|
+
function runSchedule(msgs: Msg[], seed: number): Sim {
|
|
93
|
+
const PATH = "/state/agent/telegram/obligations.json";
|
|
94
|
+
const store = memStore();
|
|
95
|
+
let ledger = new ObligationLedger(MAX_REPRESENTS, {
|
|
96
|
+
onChange: (snap) => persistObligations(PATH, store.fs, snap),
|
|
97
|
+
});
|
|
98
|
+
const r = rng(seed);
|
|
99
|
+
|
|
100
|
+
const pending = [...msgs]; // not yet received
|
|
101
|
+
const byId = new Map(msgs.map((m) => [m.id, m]));
|
|
102
|
+
const turnsHad = new Map<string, number>(); // total turns delivered to each obligation
|
|
103
|
+
const terminals = new Map<string, Terminal>();
|
|
104
|
+
const received = new Set<string>();
|
|
105
|
+
|
|
106
|
+
const close = (id: string, why: Terminal) => {
|
|
107
|
+
ledger.close(id);
|
|
108
|
+
terminals.set(id, why);
|
|
109
|
+
};
|
|
110
|
+
|
|
111
|
+
// Run one turn for an obligation; close if the model answers on this attempt.
|
|
112
|
+
const deliverTurn = (id: string) => {
|
|
113
|
+
const had = (turnsHad.get(id) ?? 0);
|
|
114
|
+
const attemptIndex = had; // 0-based
|
|
115
|
+
turnsHad.set(id, had + 1);
|
|
116
|
+
if (byId.get(id)!.answerOnAttempt === attemptIndex) close(id, "answered");
|
|
117
|
+
};
|
|
118
|
+
|
|
119
|
+
const ESC_IN_FLIGHT = new Set<string>(); // mirrors the gateway's concurrency guard (no-op in a sync model)
|
|
120
|
+
|
|
121
|
+
let steps = 0;
|
|
122
|
+
const CAP = 10_000; // generous; a real infinite loop blows past this and fails
|
|
123
|
+
while (steps < CAP) {
|
|
124
|
+
steps++;
|
|
125
|
+
const open = ledger.hasOpen();
|
|
126
|
+
// Receive a fresh inbound (interleave: maybe receive while something is open,
|
|
127
|
+
// exercising multi-open). Always receive if nothing is open and work remains.
|
|
128
|
+
if (pending.length > 0 && (!open || r() < 0.5)) {
|
|
129
|
+
const m = pending.shift()!;
|
|
130
|
+
received.add(m.id);
|
|
131
|
+
// OPEN at receipt — keyed origin id; idempotent.
|
|
132
|
+
ledger.openIfAbsent({
|
|
133
|
+
originTurnId: m.id,
|
|
134
|
+
chatId: "-100123",
|
|
135
|
+
threadId: 3,
|
|
136
|
+
messageId: m.msgId,
|
|
137
|
+
text: `msg ${m.id}`,
|
|
138
|
+
openedAt: 1000 + steps,
|
|
139
|
+
});
|
|
140
|
+
deliverTurn(m.id); // original turn (attempt 0)
|
|
141
|
+
} else if (open) {
|
|
142
|
+
const decision = ledger.decideAtIdle();
|
|
143
|
+
const o = decision.obligation as Obligation;
|
|
144
|
+
// INVARIANT (no double-ask): a terminated obligation must never resurface.
|
|
145
|
+
expect(terminals.has(o.originTurnId)).toBe(false);
|
|
146
|
+
if (decision.action === "represent") {
|
|
147
|
+
ledger.markRepresented(o.originTurnId);
|
|
148
|
+
deliverTurn(o.originTurnId); // the re-present turn
|
|
149
|
+
} else if (decision.action === "escalate") {
|
|
150
|
+
if (ESC_IN_FLIGHT.has(o.originTurnId)) continue;
|
|
151
|
+
const attempt = ledger.markEscalateAttempt(o.originTurnId);
|
|
152
|
+
const willSucceed = byId.get(o.originTurnId)!.escalateFailsFor < attempt;
|
|
153
|
+
if (willSucceed) {
|
|
154
|
+
close(o.originTurnId, "escalation-delivered");
|
|
155
|
+
} else if (attempt >= ESCALATE_MAX) {
|
|
156
|
+
close(o.originTurnId, "escalation-give-up");
|
|
157
|
+
}
|
|
158
|
+
// else: transient failure — stays OPEN, retried next sweep.
|
|
159
|
+
}
|
|
160
|
+
} else {
|
|
161
|
+
break; // idle: nothing pending, nothing open → done
|
|
162
|
+
}
|
|
163
|
+
|
|
164
|
+
// Random restart: the durable snapshot is the only thing that survives.
|
|
165
|
+
// A fresh ledger hydrated from disk must resume exactly where we left off.
|
|
166
|
+
if (r() < 0.15) {
|
|
167
|
+
ledger = new ObligationLedger(MAX_REPRESENTS, {
|
|
168
|
+
onChange: (snap) => persistObligations(PATH, store.fs, snap),
|
|
169
|
+
});
|
|
170
|
+
ledger.hydrate(loadObligations(PATH, store.fs));
|
|
171
|
+
}
|
|
172
|
+
}
|
|
173
|
+
|
|
174
|
+
return { terminals, steps };
|
|
175
|
+
}
|
|
176
|
+
|
|
177
|
+
function pick<T>(arr: T[], r: () => number): T {
|
|
178
|
+
return arr[Math.floor(r() * arr.length)];
|
|
179
|
+
}
|
|
180
|
+
|
|
181
|
+
describe("obligation determinism — every inbound reaches a terminal, no silent loss, no double-ask", () => {
|
|
182
|
+
it("holds across 3000 random {model-behavior × timing × restart} schedules", () => {
|
|
183
|
+
const ANSWER = [0, 1, 2, 3, 99]; // 0..2 = answered via ladder; 3/99 = never → escalate
|
|
184
|
+
const ESCFAIL = [0, 1, 2, 3, 5]; // 0 = first send ok; ≥3 = permanently undeliverable
|
|
185
|
+
for (let seed = 1; seed <= 3000; seed++) {
|
|
186
|
+
const r = rng(seed * 7919);
|
|
187
|
+
const n = 1 + Math.floor(r() * 5); // 1..5 messages
|
|
188
|
+
const msgs: Msg[] = [];
|
|
189
|
+
for (let i = 0; i < n; i++) {
|
|
190
|
+
const msgId = seed * 100 + i; // real positive integer id
|
|
191
|
+
msgs.push({
|
|
192
|
+
id: `c:3#${msgId}`,
|
|
193
|
+
msgId,
|
|
194
|
+
answerOnAttempt: pick(ANSWER, r),
|
|
195
|
+
escalateFailsFor: pick(ESCFAIL, r),
|
|
196
|
+
});
|
|
197
|
+
}
|
|
198
|
+
const { terminals, steps } = runSchedule(msgs, seed * 104729);
|
|
199
|
+
|
|
200
|
+
// 1. TERMINATION: the engine settled well within the cap (no infinite loop).
|
|
201
|
+
expect(steps).toBeLessThan(10_000);
|
|
202
|
+
|
|
203
|
+
// 2. NO SILENT LOSS: every message received reached a terminal.
|
|
204
|
+
for (const m of msgs) {
|
|
205
|
+
const t = terminals.get(m.id);
|
|
206
|
+
expect(t, `seed=${seed} msg=${m.id} answer=${m.answerOnAttempt} escFail=${m.escalateFailsFor}`).toBeDefined();
|
|
207
|
+
|
|
208
|
+
// 3. CORRECT TERMINAL per behaviour:
|
|
209
|
+
if (m.answerOnAttempt <= MAX_REPRESENTS) {
|
|
210
|
+
// answerable within the represent ladder → answered (never escalated early)
|
|
211
|
+
expect(t).toBe("answered");
|
|
212
|
+
} else if (m.escalateFailsFor < ESCALATE_MAX) {
|
|
213
|
+
// never answered, escalation eventually lands
|
|
214
|
+
expect(t).toBe("escalation-delivered");
|
|
215
|
+
} else {
|
|
216
|
+
// never answered, escalation permanently undeliverable → bounded give-up
|
|
217
|
+
expect(t).toBe("escalation-give-up");
|
|
218
|
+
}
|
|
219
|
+
}
|
|
220
|
+
}
|
|
221
|
+
});
|
|
222
|
+
|
|
223
|
+
it("a delivered-but-unanswered obligation survives a restart and is escalated, not lost", () => {
|
|
224
|
+
// Deterministic single case: model NEVER answers, escalation succeeds first try,
|
|
225
|
+
// with a restart forced mid-life via a seed that triggers the 0.15 branch.
|
|
226
|
+
const { terminals } = runSchedule(
|
|
227
|
+
[{ id: "c:3#715", msgId: 715, answerOnAttempt: 99, escalateFailsFor: 0 }],
|
|
228
|
+
42,
|
|
229
|
+
);
|
|
230
|
+
expect(terminals.get("c:3#715")).toBe("escalation-delivered");
|
|
231
|
+
});
|
|
232
|
+
|
|
233
|
+
it("escalation that is permanently undeliverable is bounded (give-up), never an infinite loop", () => {
|
|
234
|
+
const { terminals, steps } = runSchedule(
|
|
235
|
+
[{ id: "c:3#900", msgId: 900, answerOnAttempt: 99, escalateFailsFor: 99 }],
|
|
236
|
+
7,
|
|
237
|
+
);
|
|
238
|
+
expect(terminals.get("c:3#900")).toBe("escalation-give-up");
|
|
239
|
+
expect(steps).toBeLessThan(10_000);
|
|
240
|
+
});
|
|
241
|
+
});
|
|
@@ -165,3 +165,72 @@ describe("buildObligationRepresentInbound", () => {
|
|
|
165
165
|
expect(obligationEscalationText(ob)).toMatch(/re-?send/i);
|
|
166
166
|
});
|
|
167
167
|
});
|
|
168
|
+
|
|
169
|
+
describe("ObligationLedger — durability hooks + escalate-attempt counter", () => {
|
|
170
|
+
function input(id: string, openedAt: number, text = "do the thing") {
|
|
171
|
+
return { originTurnId: id, chatId: "-100123", threadId: 3, messageId: Number(id.split("#").pop() ?? 0), text, openedAt };
|
|
172
|
+
}
|
|
173
|
+
|
|
174
|
+
it("fires onChange after every mutation with the full open snapshot", () => {
|
|
175
|
+
const snapshots: Obligation[][] = [];
|
|
176
|
+
const L = new ObligationLedger(2, { onChange: (s) => snapshots.push(s) });
|
|
177
|
+
L.openIfAbsent(input("c:3#1", 1000)); // open
|
|
178
|
+
L.openIfAbsent(input("c:3#2", 1001)); // open
|
|
179
|
+
L.markRepresented("c:3#1"); // represent
|
|
180
|
+
L.markEscalateAttempt("c:3#1"); // escalate-attempt
|
|
181
|
+
L.close("c:3#1"); // close
|
|
182
|
+
// open, open, represent, escalate-attempt, close = 5 mutations.
|
|
183
|
+
expect(snapshots.length).toBe(5);
|
|
184
|
+
expect(snapshots[1].map((o) => o.originTurnId).sort()).toEqual(["c:3#1", "c:3#2"]);
|
|
185
|
+
// last snapshot reflects the close.
|
|
186
|
+
expect(snapshots[4].map((o) => o.originTurnId)).toEqual(["c:3#2"]);
|
|
187
|
+
});
|
|
188
|
+
|
|
189
|
+
it("does NOT fire onChange for an idempotent (already-open) openIfAbsent", () => {
|
|
190
|
+
const snapshots: Obligation[][] = [];
|
|
191
|
+
const L = new ObligationLedger(2, { onChange: (s) => snapshots.push(s) });
|
|
192
|
+
expect(L.openIfAbsent(input("c:3#1", 1000))).toBe(true);
|
|
193
|
+
expect(L.openIfAbsent(input("c:3#1", 9999))).toBe(false); // dup
|
|
194
|
+
expect(snapshots.length).toBe(1);
|
|
195
|
+
});
|
|
196
|
+
|
|
197
|
+
it("does NOT fire onChange for a close of an unknown id", () => {
|
|
198
|
+
const snapshots: Obligation[][] = [];
|
|
199
|
+
const L = new ObligationLedger(2, { onChange: (s) => snapshots.push(s) });
|
|
200
|
+
expect(L.close("nope")).toBe(false);
|
|
201
|
+
expect(snapshots.length).toBe(0);
|
|
202
|
+
});
|
|
203
|
+
|
|
204
|
+
it("markEscalateAttempt increments per call and persists", () => {
|
|
205
|
+
const snapshots: Obligation[][] = [];
|
|
206
|
+
const L = new ObligationLedger(2, { onChange: (s) => snapshots.push(s) });
|
|
207
|
+
L.openIfAbsent(input("c:3#1", 1000));
|
|
208
|
+
expect(L.markEscalateAttempt("c:3#1")).toBe(1);
|
|
209
|
+
expect(L.markEscalateAttempt("c:3#1")).toBe(2);
|
|
210
|
+
expect(L.list()[0].escalateAttempts).toBe(2);
|
|
211
|
+
expect(L.markEscalateAttempt("missing")).toBe(0);
|
|
212
|
+
});
|
|
213
|
+
|
|
214
|
+
it("hydrate restores the open set WITH counters and does not fire onChange", () => {
|
|
215
|
+
const snapshots: Obligation[][] = [];
|
|
216
|
+
const L = new ObligationLedger(2, { onChange: (s) => snapshots.push(s) });
|
|
217
|
+
L.hydrate([
|
|
218
|
+
{ originTurnId: "c:3#715", chatId: "-100123", threadId: 3, messageId: 715, text: "x", openedAt: 1000, representCount: 2, escalateAttempts: 1 },
|
|
219
|
+
]);
|
|
220
|
+
expect(snapshots.length).toBe(0); // hydrate is restoration, not a mutation
|
|
221
|
+
expect(L.isOpen("c:3#715")).toBe(true);
|
|
222
|
+
expect(L.list()[0].representCount).toBe(2);
|
|
223
|
+
expect(L.list()[0].escalateAttempts).toBe(1);
|
|
224
|
+
// a represented obligation at/over max decides 'escalate', preserving count across restart
|
|
225
|
+
expect(L.decideAtIdle().action).toBe("escalate");
|
|
226
|
+
});
|
|
227
|
+
|
|
228
|
+
it("hydrate skips malformed rows", () => {
|
|
229
|
+
const L = new ObligationLedger();
|
|
230
|
+
L.hydrate([
|
|
231
|
+
{ originTurnId: "c:3#1", chatId: "-100123", messageId: 1, text: "x", openedAt: 1000, representCount: 0 },
|
|
232
|
+
{ originTurnId: "", chatId: "x", messageId: 0, text: "", openedAt: 0, representCount: 0 } as Obligation,
|
|
233
|
+
]);
|
|
234
|
+
expect(L.size()).toBe(1);
|
|
235
|
+
});
|
|
236
|
+
});
|
|
@@ -0,0 +1,117 @@
|
|
|
1
|
+
import { describe, it, expect } from "vitest";
|
|
2
|
+
import {
|
|
3
|
+
loadObligations,
|
|
4
|
+
persistObligations,
|
|
5
|
+
type ObligationStoreFsSeam,
|
|
6
|
+
} from "../gateway/obligation-store.js";
|
|
7
|
+
import type { Obligation } from "../gateway/obligation-ledger.js";
|
|
8
|
+
|
|
9
|
+
/** In-memory fs seam with an atomic rename, so the store's tmp→rename
|
|
10
|
+
* crash-safety contract is exercised without touching the real disk. */
|
|
11
|
+
function memFs(seed: Record<string, string> = {}) {
|
|
12
|
+
const files = new Map<string, string>(Object.entries(seed));
|
|
13
|
+
const calls: string[] = [];
|
|
14
|
+
const fs: ObligationStoreFsSeam = {
|
|
15
|
+
readFileSync: (p) => {
|
|
16
|
+
if (!files.has(p)) throw new Error(`ENOENT ${p}`);
|
|
17
|
+
return files.get(p)!;
|
|
18
|
+
},
|
|
19
|
+
writeFileSync: (p, d) => {
|
|
20
|
+
calls.push(`write:${p}`);
|
|
21
|
+
files.set(p, d);
|
|
22
|
+
},
|
|
23
|
+
renameSync: (a, b) => {
|
|
24
|
+
calls.push(`rename:${a}->${b}`);
|
|
25
|
+
if (!files.has(a)) throw new Error(`ENOENT ${a}`);
|
|
26
|
+
files.set(b, files.get(a)!);
|
|
27
|
+
files.delete(a);
|
|
28
|
+
},
|
|
29
|
+
existsSync: (p) => files.has(p),
|
|
30
|
+
};
|
|
31
|
+
return { fs, files, calls };
|
|
32
|
+
}
|
|
33
|
+
|
|
34
|
+
const PATH = "/state/agent/telegram/obligations.json";
|
|
35
|
+
|
|
36
|
+
function ob(id: string, over: Partial<Obligation> = {}): Obligation {
|
|
37
|
+
return {
|
|
38
|
+
originTurnId: id,
|
|
39
|
+
chatId: "-100123",
|
|
40
|
+
threadId: 3,
|
|
41
|
+
messageId: Number(id.split("#").pop() ?? 1),
|
|
42
|
+
text: "do the thing",
|
|
43
|
+
openedAt: 1000,
|
|
44
|
+
representCount: 0,
|
|
45
|
+
...over,
|
|
46
|
+
};
|
|
47
|
+
}
|
|
48
|
+
|
|
49
|
+
describe("obligation-store", () => {
|
|
50
|
+
it("round-trips the open set, preserving representCount + escalateAttempts", () => {
|
|
51
|
+
const { fs } = memFs();
|
|
52
|
+
const snap: Obligation[] = [
|
|
53
|
+
ob("c:3#715", { representCount: 2, escalateAttempts: 1 }),
|
|
54
|
+
ob("c:5#900", { representCount: 0, openedAt: 2000 }),
|
|
55
|
+
];
|
|
56
|
+
persistObligations(PATH, fs, snap);
|
|
57
|
+
const loaded = loadObligations(PATH, fs);
|
|
58
|
+
expect(loaded).toEqual(snap);
|
|
59
|
+
expect(loaded[0].escalateAttempts).toBe(1);
|
|
60
|
+
expect(loaded[0].representCount).toBe(2);
|
|
61
|
+
});
|
|
62
|
+
|
|
63
|
+
it("persists atomically: writes a sibling .tmp then renames over the path", () => {
|
|
64
|
+
const { fs, calls, files } = memFs();
|
|
65
|
+
persistObligations(PATH, fs, [ob("c:3#1")]);
|
|
66
|
+
expect(calls).toEqual([`write:${PATH}.tmp`, `rename:${PATH}.tmp->${PATH}`]);
|
|
67
|
+
// The tmp is gone (renamed); only the real path remains.
|
|
68
|
+
expect(files.has(PATH)).toBe(true);
|
|
69
|
+
expect(files.has(`${PATH}.tmp`)).toBe(false);
|
|
70
|
+
});
|
|
71
|
+
|
|
72
|
+
it("returns [] for a missing file", () => {
|
|
73
|
+
const { fs } = memFs();
|
|
74
|
+
expect(loadObligations(PATH, fs)).toEqual([]);
|
|
75
|
+
});
|
|
76
|
+
|
|
77
|
+
it("returns [] for a torn / non-JSON file (crash mid-write tolerance)", () => {
|
|
78
|
+
const { fs } = memFs({ [PATH]: '{"v":1,"obligations":[{"originTurnId":"c:3#7' });
|
|
79
|
+
expect(loadObligations(PATH, fs)).toEqual([]);
|
|
80
|
+
});
|
|
81
|
+
|
|
82
|
+
it("returns [] for a wrong-version or wrong-shape envelope", () => {
|
|
83
|
+
const a = memFs({ [PATH]: JSON.stringify({ v: 2, obligations: [ob("c:3#1")] }) });
|
|
84
|
+
expect(loadObligations(PATH, a.fs)).toEqual([]);
|
|
85
|
+
const b = memFs({ [PATH]: JSON.stringify({ v: 1, obligations: "nope" }) });
|
|
86
|
+
expect(loadObligations(PATH, b.fs)).toEqual([]);
|
|
87
|
+
});
|
|
88
|
+
|
|
89
|
+
it("filters out malformed rows but keeps valid ones", () => {
|
|
90
|
+
const raw = JSON.stringify({
|
|
91
|
+
v: 1,
|
|
92
|
+
obligations: [
|
|
93
|
+
ob("c:3#715"),
|
|
94
|
+
{ originTurnId: "", chatId: "x" }, // empty id → dropped
|
|
95
|
+
{ nope: true }, // missing fields → dropped
|
|
96
|
+
ob("c:5#900", { openedAt: 2000 }),
|
|
97
|
+
],
|
|
98
|
+
});
|
|
99
|
+
const { fs } = memFs({ [PATH]: raw });
|
|
100
|
+
const loaded = loadObligations(PATH, fs);
|
|
101
|
+
expect(loaded.map((o) => o.originTurnId)).toEqual(["c:3#715", "c:5#900"]);
|
|
102
|
+
});
|
|
103
|
+
|
|
104
|
+
it("never throws on a write failure — degrades to in-memory (logs)", () => {
|
|
105
|
+
const logs: string[] = [];
|
|
106
|
+
const fs: ObligationStoreFsSeam = {
|
|
107
|
+
readFileSync: () => "",
|
|
108
|
+
writeFileSync: () => {
|
|
109
|
+
throw new Error("EROFS read-only fs");
|
|
110
|
+
},
|
|
111
|
+
renameSync: () => {},
|
|
112
|
+
existsSync: () => false,
|
|
113
|
+
};
|
|
114
|
+
expect(() => persistObligations(PATH, fs, [ob("c:3#1")], (l) => logs.push(l))).not.toThrow();
|
|
115
|
+
expect(logs.join("")).toContain("persist FAILED");
|
|
116
|
+
});
|
|
117
|
+
});
|
|
@@ -0,0 +1,61 @@
|
|
|
1
|
+
import { describe, it, expect } from "vitest";
|
|
2
|
+
import { withDeadline } from "../gateway/with-deadline.js";
|
|
3
|
+
|
|
4
|
+
const tick = (ms: number) => new Promise((r) => setTimeout(r, ms));
|
|
5
|
+
|
|
6
|
+
describe("withDeadline — bounds the obligation escalation send so a hang can't leak the in-flight flag", () => {
|
|
7
|
+
it("resolves with the inner value when the promise settles before the deadline", async () => {
|
|
8
|
+
await expect(withDeadline(Promise.resolve("ok"), 1000, "timed out")).resolves.toBe("ok");
|
|
9
|
+
});
|
|
10
|
+
|
|
11
|
+
it("rejects with the inner error when the promise rejects before the deadline", async () => {
|
|
12
|
+
await expect(withDeadline(Promise.reject(new Error("boom")), 1000, "timed out")).rejects.toThrow(
|
|
13
|
+
"boom",
|
|
14
|
+
);
|
|
15
|
+
});
|
|
16
|
+
|
|
17
|
+
it("rejects with the timeout message when the promise NEVER settles (the hang case)", async () => {
|
|
18
|
+
// The whole point: a promise that never resolves/rejects (a stalled send)
|
|
19
|
+
// must still settle the chain so the caller's .finally clears the in-flight flag.
|
|
20
|
+
const neverSettles = new Promise<string>(() => {});
|
|
21
|
+
await expect(withDeadline(neverSettles, 20, "obligation escalation send timed out")).rejects.toThrow(
|
|
22
|
+
"obligation escalation send timed out",
|
|
23
|
+
);
|
|
24
|
+
});
|
|
25
|
+
|
|
26
|
+
it("the .finally chained after it ALWAYS runs even when the inner promise hangs", async () => {
|
|
27
|
+
// This mirrors the gateway's obligationEscalateInFlight clear: it lives in a
|
|
28
|
+
// .finally on withDeadline(...), and must fire within the deadline regardless.
|
|
29
|
+
let flagCleared = false;
|
|
30
|
+
await withDeadline(new Promise<void>(() => {}), 20, "timed out")
|
|
31
|
+
.catch(() => {})
|
|
32
|
+
.finally(() => {
|
|
33
|
+
flagCleared = true;
|
|
34
|
+
});
|
|
35
|
+
expect(flagCleared).toBe(true);
|
|
36
|
+
});
|
|
37
|
+
|
|
38
|
+
it("a hung-then-late-rejecting inner promise does not produce an unhandled rejection", async () => {
|
|
39
|
+
const seen: unknown[] = [];
|
|
40
|
+
const onUnhandled = (reason: unknown) => seen.push(reason);
|
|
41
|
+
process.on("unhandledRejection", onUnhandled);
|
|
42
|
+
try {
|
|
43
|
+
const lateReject = new Promise<void>((_, reject) => {
|
|
44
|
+
setTimeout(() => reject(new Error("late-zombie-rejection")), 30);
|
|
45
|
+
});
|
|
46
|
+
// withDeadline rejects at 10ms; the inner promise rejects later at 30ms.
|
|
47
|
+
await withDeadline(lateReject, 10, "timed out").catch(() => {});
|
|
48
|
+
await tick(60); // let the late rejection fire
|
|
49
|
+
} finally {
|
|
50
|
+
process.off("unhandledRejection", onUnhandled);
|
|
51
|
+
}
|
|
52
|
+
expect(seen.some((r) => r instanceof Error && r.message === "late-zombie-rejection")).toBe(false);
|
|
53
|
+
});
|
|
54
|
+
|
|
55
|
+
it("clears its timer on a fast settle (no dangling work keeps the loop alive)", async () => {
|
|
56
|
+
// Sanity: a fast resolve settles immediately, not after the deadline.
|
|
57
|
+
const start = Date.now();
|
|
58
|
+
await withDeadline(Promise.resolve(1), 5000, "timed out");
|
|
59
|
+
expect(Date.now() - start).toBeLessThan(500);
|
|
60
|
+
});
|
|
61
|
+
});
|