@rubytech/create-realagent-code 0.1.591 → 0.1.592

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 (53) hide show
  1. package/package.json +1 -1
  2. package/payload/platform/lib/routine-templates/dist/index.d.ts +2 -2
  3. package/payload/platform/lib/routine-templates/dist/index.d.ts.map +1 -1
  4. package/payload/platform/lib/routine-templates/dist/index.js +2 -9
  5. package/payload/platform/lib/routine-templates/dist/index.js.map +1 -1
  6. package/payload/platform/lib/routine-templates/dist/roster.d.ts +1 -11
  7. package/payload/platform/lib/routine-templates/dist/roster.d.ts.map +1 -1
  8. package/payload/platform/lib/routine-templates/dist/roster.js +11 -15
  9. package/payload/platform/lib/routine-templates/dist/roster.js.map +1 -1
  10. package/payload/platform/lib/routine-templates/src/__tests__/roster.test.ts +0 -0
  11. package/payload/platform/lib/routine-templates/src/__tests__/seed.test.ts +8 -3
  12. package/payload/platform/lib/routine-templates/src/index.ts +1 -8
  13. package/payload/platform/lib/routine-templates/src/roster.ts +10 -14
  14. package/payload/platform/plugins/admin/skills/whats-new/SKILL.md +4 -0
  15. package/payload/platform/plugins/scheduling/PLUGIN.md +6 -6
  16. package/payload/platform/plugins/scheduling/mcp/dist/index.js +4 -12
  17. package/payload/platform/plugins/scheduling/mcp/dist/index.js.map +1 -1
  18. package/payload/platform/plugins/scheduling/mcp/dist/lib/__tests__/gate-audits.test.js +85 -1
  19. package/payload/platform/plugins/scheduling/mcp/dist/lib/__tests__/gate-audits.test.js.map +1 -1
  20. package/payload/platform/plugins/scheduling/mcp/dist/lib/__tests__/gate.test.js +55 -1
  21. package/payload/platform/plugins/scheduling/mcp/dist/lib/__tests__/gate.test.js.map +1 -1
  22. package/payload/platform/plugins/scheduling/mcp/dist/lib/gate.d.ts +10 -1
  23. package/payload/platform/plugins/scheduling/mcp/dist/lib/gate.d.ts.map +1 -1
  24. package/payload/platform/plugins/scheduling/mcp/dist/lib/gate.js +42 -6
  25. package/payload/platform/plugins/scheduling/mcp/dist/lib/gate.js.map +1 -1
  26. package/payload/platform/plugins/scheduling/mcp/dist/lib/schedule-audit.d.ts +22 -0
  27. package/payload/platform/plugins/scheduling/mcp/dist/lib/schedule-audit.d.ts.map +1 -1
  28. package/payload/platform/plugins/scheduling/mcp/dist/lib/schedule-audit.js +73 -0
  29. package/payload/platform/plugins/scheduling/mcp/dist/lib/schedule-audit.js.map +1 -1
  30. package/payload/platform/plugins/scheduling/mcp/dist/scripts/__tests__/agent-turn-dispatch.test.js +79 -0
  31. package/payload/platform/plugins/scheduling/mcp/dist/scripts/__tests__/agent-turn-dispatch.test.js.map +1 -1
  32. package/payload/platform/plugins/scheduling/mcp/dist/scripts/agent-turn-dispatch.d.ts +2 -1
  33. package/payload/platform/plugins/scheduling/mcp/dist/scripts/agent-turn-dispatch.d.ts.map +1 -1
  34. package/payload/platform/plugins/scheduling/mcp/dist/scripts/agent-turn-dispatch.js +40 -1
  35. package/payload/platform/plugins/scheduling/mcp/dist/scripts/agent-turn-dispatch.js.map +1 -1
  36. package/payload/platform/plugins/scheduling/mcp/dist/scripts/check-due-events.js +19 -2
  37. package/payload/platform/plugins/scheduling/mcp/dist/scripts/check-due-events.js.map +1 -1
  38. package/payload/platform/plugins/scheduling/mcp/dist/tools/__tests__/schedule-gate-write.test.js +27 -280
  39. package/payload/platform/plugins/scheduling/mcp/dist/tools/__tests__/schedule-gate-write.test.js.map +1 -1
  40. package/payload/platform/plugins/scheduling/mcp/dist/tools/__tests__/scheduling-gate.test.js +191 -208
  41. package/payload/platform/plugins/scheduling/mcp/dist/tools/__tests__/scheduling-gate.test.js.map +1 -1
  42. package/payload/platform/plugins/scheduling/mcp/dist/tools/schedule-event.d.ts +1 -31
  43. package/payload/platform/plugins/scheduling/mcp/dist/tools/schedule-event.d.ts.map +1 -1
  44. package/payload/platform/plugins/scheduling/mcp/dist/tools/schedule-event.js +15 -95
  45. package/payload/platform/plugins/scheduling/mcp/dist/tools/schedule-event.js.map +1 -1
  46. package/payload/platform/plugins/scheduling/mcp/dist/tools/schedule-update.d.ts.map +1 -1
  47. package/payload/platform/plugins/scheduling/mcp/dist/tools/schedule-update.js +5 -36
  48. package/payload/platform/plugins/scheduling/mcp/dist/tools/schedule-update.js.map +1 -1
  49. package/payload/platform/plugins/scheduling/mcp/dist/tools/scheduling-gate.d.ts +58 -54
  50. package/payload/platform/plugins/scheduling/mcp/dist/tools/scheduling-gate.d.ts.map +1 -1
  51. package/payload/platform/plugins/scheduling/mcp/dist/tools/scheduling-gate.js +85 -98
  52. package/payload/platform/plugins/scheduling/mcp/dist/tools/scheduling-gate.js.map +1 -1
  53. package/payload/server/public/operator.html +1 -1
@@ -17,7 +17,7 @@ vi.mock("../../lib/neo4j.js", () => ({
17
17
  close: vi.fn(async () => { }),
18
18
  }),
19
19
  }));
20
- import { runSchedulingGate, watermarkPath, observationPath, readWatermarks, foldMailWatermarks, parseD1Count, digestBusy, } from "../scheduling-gate.js";
20
+ import { runSchedulingGate, advanceGateMark, watermarkPath, readWatermarks, foldMailWatermarks, parseD1Count, digestBusy, } from "../scheduling-gate.js";
21
21
  const ACCOUNT = "20028fb5-0000-4000-8000-000000000000";
22
22
  const EVENT = "65ef758c-0000-4000-8000-000000000000";
23
23
  let platformRoot;
@@ -58,14 +58,14 @@ afterEach(() => {
58
58
  vi.doUnmock("imapflow");
59
59
  delete process.env.PLATFORM_PORT;
60
60
  });
61
- const run = (checks, sources, commit = false) => runSchedulingGate({ eventId: EVENT, checks, commit }, ACCOUNT, platformRoot, sources);
61
+ const run = (checks, sources) => runSchedulingGate({ eventId: EVENT, checks }, ACCOUNT, platformRoot, sources);
62
62
  describe("d1 check", () => {
63
63
  const CHECK = { type: "d1", database: "bookings", sql: "SELECT count(*) FROM b" };
64
64
  it("is false on a zero count", async () => {
65
- expect(await run([CHECK], quietSources())).toEqual({ hasWork: false, reasons: [], errored: [] });
65
+ expect(await run([CHECK], quietSources())).toMatchObject({ hasWork: false, reasons: [], errored: [] });
66
66
  });
67
67
  it("is true on one row", async () => {
68
- expect(await run([CHECK], quietSources({ d1: async () => 1 }))).toEqual({
68
+ expect(await run([CHECK], quietSources({ d1: async () => 1 }))).toMatchObject({
69
69
  hasWork: true,
70
70
  reasons: ["d1"],
71
71
  errored: [],
@@ -77,7 +77,7 @@ describe("d1 check", () => {
77
77
  throw new Error("no cloudflare credentials");
78
78
  },
79
79
  });
80
- expect(await run([CHECK], sources)).toEqual({
80
+ expect(await run([CHECK], sources)).toMatchObject({
81
81
  hasWork: true,
82
82
  reasons: ["d1"],
83
83
  errored: [{ kind: "d1", detail: "no cloudflare credentials" }],
@@ -88,14 +88,14 @@ describe("mail check", () => {
88
88
  const CHECK = { type: "mail", mailbox: "ops@example.com" };
89
89
  it("is false when nothing sits above the watermark", async () => {
90
90
  seedWatermarks({ mail: { "ops@example.com|INBOX": { uid: 100, uidValidity: 1 } } });
91
- expect(await run([CHECK], quietSources())).toEqual({ hasWork: false, reasons: [], errored: [] });
91
+ expect(await run([CHECK], quietSources())).toMatchObject({ hasWork: false, reasons: [], errored: [] });
92
92
  });
93
93
  it("is true when a UID above the watermark matches", async () => {
94
94
  seedWatermarks({ mail: { "ops@example.com|INBOX": { uid: 100, uidValidity: 1 } } });
95
95
  const sources = quietSources({
96
96
  mail: async () => ({ matched: true, highestUid: 104, uidValidity: 1 }),
97
97
  });
98
- expect(await run([CHECK], sources)).toEqual({ hasWork: true, reasons: ["mail"], errored: [] });
98
+ expect(await run([CHECK], sources)).toMatchObject({ hasWork: true, reasons: ["mail"], errored: [] });
99
99
  });
100
100
  it("probes from UID 0 on a first run, so nothing already there is missed", async () => {
101
101
  const seen = [];
@@ -113,7 +113,7 @@ describe("mail check", () => {
113
113
  const sources = quietSources({
114
114
  mail: async () => ({ matched: false, highestUid: 100, uidValidity: 7 }),
115
115
  });
116
- expect(await run([CHECK], sources)).toEqual({ hasWork: true, reasons: ["mail"], errored: [] });
116
+ expect(await run([CHECK], sources)).toMatchObject({ hasWork: true, reasons: ["mail"], errored: [] });
117
117
  });
118
118
  it("keys the watermark by folder, so two folders of one mailbox never collide", async () => {
119
119
  seedWatermarks({ mail: { "ops@example.com|Archive": { uid: 100, uidValidity: 1 } } });
@@ -131,12 +131,12 @@ describe("mail check", () => {
131
131
  describe("whatsapp check", () => {
132
132
  it("status is false while connected", async () => {
133
133
  const check = { type: "whatsapp", scope: "status" };
134
- expect(await run([check], quietSources())).toEqual({ hasWork: false, reasons: [], errored: [] });
134
+ expect(await run([check], quietSources())).toMatchObject({ hasWork: false, reasons: [], errored: [] });
135
135
  });
136
136
  it("status is true while disconnected", async () => {
137
137
  const check = { type: "whatsapp", scope: "status" };
138
138
  const sources = quietSources({ whatsappStatus: async () => ({ connected: false }) });
139
- expect(await run([check], sources)).toEqual({ hasWork: true, reasons: ["whatsapp"], errored: [] });
139
+ expect(await run([check], sources)).toMatchObject({ hasWork: true, reasons: ["whatsapp"], errored: [] });
140
140
  });
141
141
  it("inbound is true only on a message received since the watermark", async () => {
142
142
  const check = { type: "whatsapp", scope: "inbound" };
@@ -144,7 +144,7 @@ describe("whatsapp check", () => {
144
144
  const sources = quietSources({
145
145
  whatsappInbound: async () => ({ latest: 1_754_000_900, newer: true }),
146
146
  });
147
- expect(await run([check], sources)).toEqual({ hasWork: true, reasons: ["whatsapp"], errored: [] });
147
+ expect(await run([check], sources)).toMatchObject({ hasWork: true, reasons: ["whatsapp"], errored: [] });
148
148
  });
149
149
  it("inbound passes the stored watermark to the source", async () => {
150
150
  const check = { type: "whatsapp", scope: "inbound" };
@@ -164,15 +164,15 @@ describe("calendar check", () => {
164
164
  const CHECK = { type: "calendar", connector: "google" };
165
165
  it("is false on an unchanged digest", async () => {
166
166
  seedWatermarks({ calendar: { digest: "DIGEST-A" } });
167
- expect(await run([CHECK], quietSources())).toEqual({ hasWork: false, reasons: [], errored: [] });
167
+ expect(await run([CHECK], quietSources())).toMatchObject({ hasWork: false, reasons: [], errored: [] });
168
168
  });
169
169
  it("is true on a changed digest", async () => {
170
170
  seedWatermarks({ calendar: { digest: "DIGEST-A" } });
171
171
  const sources = quietSources({ calendarDigest: async () => "DIGEST-B" });
172
- expect(await run([CHECK], sources)).toEqual({ hasWork: true, reasons: ["calendar"], errored: [] });
172
+ expect(await run([CHECK], sources)).toMatchObject({ hasWork: true, reasons: ["calendar"], errored: [] });
173
173
  });
174
174
  it("is true on a first run, when nothing has been committed yet", async () => {
175
- expect(await run([CHECK], quietSources())).toEqual({ hasWork: true, reasons: ["calendar"], errored: [] });
175
+ expect(await run([CHECK], quietSources())).toMatchObject({ hasWork: true, reasons: ["calendar"], errored: [] });
176
176
  });
177
177
  });
178
178
  describe("digestBusy", () => {
@@ -229,48 +229,49 @@ describe("all four together", () => {
229
229
  mail: { "ops@example.com|INBOX": { uid: 100, uidValidity: 1 } },
230
230
  calendar: { digest: "DIGEST-A" },
231
231
  });
232
- expect(await run(ALL, quietSources())).toEqual({ hasWork: false, reasons: [], errored: [] });
232
+ expect(await run(ALL, quietSources())).toMatchObject({ hasWork: false, reasons: [], errored: [] });
233
233
  });
234
234
  it("one noisy source is enough, and names only itself", async () => {
235
235
  seedWatermarks({
236
236
  mail: { "ops@example.com|INBOX": { uid: 100, uidValidity: 1 } },
237
237
  calendar: { digest: "DIGEST-A" },
238
238
  });
239
- expect(await run(ALL, quietSources({ d1: async () => 2 }))).toEqual({
239
+ expect(await run(ALL, quietSources({ d1: async () => 2 }))).toMatchObject({
240
240
  hasWork: true,
241
241
  reasons: ["d1"],
242
242
  errored: [],
243
243
  });
244
244
  });
245
245
  });
246
- describe("commit", () => {
246
+ // Task 2663 — the advance is the dispatcher's, so it is tested through
247
+ // `advanceGateMark` against the readings a real gate call returned. The old
248
+ // `commit` describe drove the same ground through a parameter the agent set.
249
+ describe("advanceGateMark", () => {
247
250
  const ALL = [
248
251
  { type: "mail", mailbox: "ops@example.com" },
249
252
  { type: "d1", database: "bookings", sql: "SELECT count(*) FROM b" },
250
253
  { type: "whatsapp", scope: "inbound" },
251
254
  { type: "calendar", connector: "google" },
252
255
  ];
253
- it("a plain call leaves the file bytes unchanged", async () => {
256
+ it("the gate itself leaves the file bytes unchanged", async () => {
254
257
  seedWatermarks({ calendar: { digest: "DIGEST-A" } });
255
258
  const path = watermarkPath(EVENT, platformRoot);
256
259
  const before = readFileSync(path);
257
260
  await run(ALL, quietSources({ calendarDigest: async () => "DIGEST-B" }));
258
261
  expect(readFileSync(path).equals(before)).toBe(true);
259
262
  });
260
- it("a plain call on a first run writes no file at all", async () => {
263
+ it("the gate on a first run writes no file at all", async () => {
261
264
  await run(ALL, quietSources());
262
265
  expect(existsSync(watermarkPath(EVENT, platformRoot))).toBe(false);
263
266
  });
264
- it("advances every watermark named in checks", async () => {
267
+ it("advances every watermark the gate read", async () => {
265
268
  const sources = quietSources({
266
269
  mail: async () => ({ matched: true, highestUid: 250, uidValidity: 9 }),
267
270
  whatsappInbound: async () => ({ latest: 1_754_009_999, newer: true }),
268
271
  calendarDigest: async () => "DIGEST-Z",
269
272
  });
270
- // Task 2654 the commit promotes what the gate read, so the gate call is
271
- // part of the sequence. A bare commit advances nothing, by design.
272
- await run(ALL, sources);
273
- const result = await run(ALL, sources, true);
273
+ const gate = await run(ALL, sources);
274
+ const { advanced } = advanceGateMark(EVENT, platformRoot, gate.observed);
274
275
  const marks = JSON.parse(readFileSync(watermarkPath(EVENT, platformRoot), "utf-8"));
275
276
  expect(marks.mail["ops@example.com|inbox"]).toEqual({ uid: 250, uidValidity: 9 });
276
277
  expect(marks.whatsapp.inbound).toBe(1_754_009_999);
@@ -278,77 +279,60 @@ describe("commit", () => {
278
279
  expect(marks.lastCommitAt).toMatch(/^\d{4}-\d{2}-\d{2}T/);
279
280
  // d1 has no watermark: its answer is a property of the present, not a
280
281
  // position in a stream.
281
- expect(result.advanced).toEqual(["ops@example.com|inbox", "whatsapp", "calendar"]);
282
+ expect(advanced).toEqual(["ops@example.com|inbox", "whatsapp", "calendar"]);
283
+ });
284
+ it("names the position it wrote per advanced key", async () => {
285
+ const gate = await run([{ type: "calendar", connector: "google" }], quietSources({ calendarDigest: async () => "DIGEST-Z" }));
286
+ const { positions } = advanceGateMark(EVENT, platformRoot, gate.observed);
287
+ expect(positions).toEqual(["calendar:DIGEST-Z"]);
288
+ });
289
+ it("stamps lastCommitAt only when something moved", async () => {
290
+ // Stamping it on every accepted wake would make `auditGateMarkStalled`,
291
+ // which counts accepted wakes SINCE this instant, read 0 or 1 for ever — the
292
+ // check built to catch a mark that stopped moving could then never fire, and
293
+ // `auditGateWatermarkAge` would go permanently green for the same reason.
294
+ const { advanced } = advanceGateMark(EVENT, platformRoot, {});
295
+ expect(advanced).toEqual([]);
296
+ expect(existsSync(watermarkPath(EVENT, platformRoot))).toBe(false);
282
297
  });
283
- it("a commit whose sources are quiet still stamps lastCommitAt", async () => {
284
- // Otherwise a routine that ran and found nothing would look, to the
285
- // watermark-age audit, exactly like one whose commits had stopped.
286
- await run(ALL, quietSources(), true);
287
- const marks = JSON.parse(readFileSync(watermarkPath(EVENT, platformRoot), "utf-8"));
288
- expect(marks.lastCommitAt).toMatch(/^\d{4}-\d{2}-\d{2}T/);
298
+ it("leaves an existing file untouched when nothing moved", async () => {
299
+ seedWatermarks({ calendar: { digest: "DIGEST-A" }, lastCommitAt: "2026-08-10T10:00:00.000Z" });
300
+ const before = readFileSync(watermarkPath(EVENT, platformRoot));
301
+ advanceGateMark(EVENT, platformRoot, {});
302
+ expect(readFileSync(watermarkPath(EVENT, platformRoot)).equals(before)).toBe(true);
289
303
  });
290
- it("a committed watermark makes the next plain call quiet", async () => {
304
+ it("an advanced watermark makes the next gate call quiet", async () => {
291
305
  const sources = quietSources({ calendarDigest: async () => "DIGEST-Z" });
292
306
  const checks = [{ type: "calendar", connector: "google" }];
293
- expect(await run(checks, sources)).toEqual({ hasWork: true, reasons: ["calendar"], errored: [] });
294
- await run(checks, sources, true);
295
- expect(await run(checks, sources)).toEqual({ hasWork: false, reasons: [], errored: [] });
307
+ const first = await run(checks, sources);
308
+ expect(first).toMatchObject({ hasWork: true, reasons: ["calendar"], errored: [] });
309
+ advanceGateMark(EVENT, platformRoot, first.observed);
310
+ expect(await run(checks, sources)).toMatchObject({ hasWork: false, reasons: [], errored: [] });
296
311
  });
297
- it("a check that errors is not committed, so its work is not marked consumed", async () => {
312
+ it("a check that errored is not advanced, so its work is not marked consumed", async () => {
298
313
  const checks = [{ type: "calendar", connector: "google" }];
299
314
  const sources = quietSources({
300
315
  calendarDigest: async () => {
301
316
  throw new Error("neo4j down");
302
317
  },
303
318
  });
304
- const result = await run(checks, sources, true);
305
- expect(result).toMatchObject({ hasWork: true, reasons: ["calendar"], advanced: [] });
306
- const marks = JSON.parse(readFileSync(watermarkPath(EVENT, platformRoot), "utf-8"));
307
- expect(marks.calendar).toBeUndefined();
319
+ const gate = await run(checks, sources);
320
+ expect(gate).toMatchObject({ hasWork: true, reasons: ["calendar"], observed: {} });
321
+ const { advanced } = advanceGateMark(EVENT, platformRoot, gate.observed);
322
+ expect(advanced).toEqual([]);
323
+ expect(existsSync(watermarkPath(EVENT, platformRoot))).toBe(false);
308
324
  });
309
- // Task 2633 the agent that raised it read `advanced: []` from a commit over
310
- // four d1 checks, inferred a broken watermark, reported an unfixable platform
311
- // fault, and deleted all four checks.
312
- it("names the checks that hold no watermark, instead of omitting them in silence", async () => {
313
- const checks = [
314
- { type: "mail", mailbox: "ops@example.com" },
315
- { type: "d1", database: "bookings", sql: "SELECT count(*) FROM b" },
316
- ];
317
- // Task 2654 — the gate call records the position the commit promotes.
318
- await run(checks, quietSources());
319
- const result = await run(checks, quietSources(), true);
320
- // Task 2635 folds the folder segment to lower case on write.
321
- expect(result.advanced).toEqual(["ops@example.com|inbox"]);
322
- expect(result.skipped).toEqual(["d1"]);
323
- });
324
- it("counts every skipped check, not every skipped kind", async () => {
325
- const checks = [
325
+ it("a check holding no position contributes no key, so nothing advances for it", async () => {
326
+ // Task 2633 an agent read an empty `advanced` over four d1 checks,
327
+ // inferred a broken watermark and deleted all four. `d1` and `whatsapp` in
328
+ // `status` scope read a current value; there is no position to record.
329
+ const gate = await run([
326
330
  { type: "d1", database: "bookings", sql: "SELECT max(id) FROM b" },
327
331
  { type: "d1", database: "leads", sql: "SELECT max(id) FROM l" },
328
332
  { type: "whatsapp", scope: "status" },
329
- ];
330
- const result = await run(checks, quietSources(), true);
331
- expect(result.skipped).toEqual(["d1", "d1", "whatsapp"]);
332
- });
333
- it("a plain call reports no skipped array at all", async () => {
334
- const checks = [
335
- { type: "d1", database: "bookings", sql: "SELECT count(*) FROM b" },
336
- ];
337
- expect(await run(checks, quietSources())).toEqual({ hasWork: false, reasons: [], errored: [] });
338
- });
339
- it("a check that errored is neither advanced nor skipped, because its state is unknown", async () => {
340
- const checks = [
341
- { type: "d1", database: "bookings", sql: "SELECT count(*) FROM b" },
342
- { type: "calendar", connector: "google" },
343
- ];
344
- const sources = quietSources({
345
- calendarDigest: async () => {
346
- throw new Error("neo4j down");
347
- },
348
- });
349
- const result = await run(checks, sources, true);
350
- expect(result.advanced).toEqual([]);
351
- expect(result.skipped).toEqual(["d1"]);
333
+ ], quietSources());
334
+ expect(gate.observed).toEqual({});
335
+ expect(advanceGateMark(EVENT, platformRoot, gate.observed).advanced).toEqual([]);
352
336
  });
353
337
  });
354
338
  describe("watermark location", () => {
@@ -599,14 +583,16 @@ describe("watermark folder-case fold", () => {
599
583
  const sources = quietSources({
600
584
  mail: async (_c, since) => ({ matched: false, highestUid: since + 1, uidValidity: 1 }),
601
585
  });
602
- // Task 2654 — each commit promotes what its own gate call read, so both
586
+ // Task 2663 — each advance promotes what its own gate call read, so both
603
587
  // fires run the real two-step sequence.
604
- await run([lower], sources);
605
- const first = await run([lower], sources, true);
606
- expect(first.advanced).toEqual(["ops@example.com|inbox"]);
607
- await run([upper], sources);
608
- const second = await run([upper], sources, true);
609
- expect(second.advanced).toEqual(["ops@example.com|inbox"]);
588
+ const firstGate = await run([lower], sources);
589
+ expect(advanceGateMark(EVENT, platformRoot, firstGate.observed).advanced).toEqual([
590
+ "ops@example.com|inbox",
591
+ ]);
592
+ const secondGate = await run([upper], sources);
593
+ expect(advanceGateMark(EVENT, platformRoot, secondGate.observed).advanced).toEqual([
594
+ "ops@example.com|inbox",
595
+ ]);
610
596
  const marks = JSON.parse(readFileSync(watermarkPath(EVENT, platformRoot), "utf-8"));
611
597
  expect(Object.keys(marks.mail)).toEqual(["ops@example.com|inbox"]);
612
598
  // The second call read the first call's position rather than restarting at 0.
@@ -664,11 +650,11 @@ describe("telegram check", () => {
664
650
  };
665
651
  const measured = (lines) => lines.every((l) => !l.includes("op=check-error"));
666
652
  it("is false when the account holds no inbound message at all", async () => {
667
- expect(await run([CHECK], quietSources())).toEqual({ hasWork: false, reasons: [], errored: [] });
653
+ expect(await run([CHECK], quietSources())).toMatchObject({ hasWork: false, reasons: [], errored: [] });
668
654
  });
669
655
  it("is false when nothing arrived after the watermark", async () => {
670
656
  seedWatermarks({ telegram: { inbound: "2026-08-05T10:00:00.000Z" } });
671
- expect(await run([CHECK], quietSources(at("2026-08-05T09:00:00.000Z")))).toEqual({
657
+ expect(await run([CHECK], quietSources(at("2026-08-05T09:00:00.000Z")))).toMatchObject({
672
658
  hasWork: false,
673
659
  reasons: [],
674
660
  errored: [],
@@ -677,7 +663,7 @@ describe("telegram check", () => {
677
663
  it("is true when a message arrived after the watermark", async () => {
678
664
  seedWatermarks({ telegram: { inbound: "2026-08-05T10:00:00.000Z" } });
679
665
  const lines = linesFrom();
680
- expect(await run([CHECK], quietSources(at("2026-08-05T11:00:00.000Z")))).toEqual({
666
+ expect(await run([CHECK], quietSources(at("2026-08-05T11:00:00.000Z")))).toMatchObject({
681
667
  hasWork: true,
682
668
  reasons: ["telegram"],
683
669
  errored: [],
@@ -688,7 +674,7 @@ describe("telegram check", () => {
688
674
  // The boundary is `>`, not `>=`. A `>=` would re-report the last handled
689
675
  // message on every fire and the gate could never go quiet.
690
676
  seedWatermarks({ telegram: { inbound: "2026-08-05T10:00:00.000Z" } });
691
- expect(await run([CHECK], quietSources(at("2026-08-05T10:00:00.000Z")))).toEqual({
677
+ expect(await run([CHECK], quietSources(at("2026-08-05T10:00:00.000Z")))).toMatchObject({
692
678
  hasWork: false,
693
679
  reasons: [],
694
680
  errored: [],
@@ -696,7 +682,7 @@ describe("telegram check", () => {
696
682
  });
697
683
  it("is true on a first run, so nothing already waiting is missed", async () => {
698
684
  const lines = linesFrom();
699
- expect(await run([CHECK], quietSources(at("2026-08-05T09:00:00.000Z")))).toEqual({
685
+ expect(await run([CHECK], quietSources(at("2026-08-05T09:00:00.000Z")))).toMatchObject({
700
686
  hasWork: true,
701
687
  reasons: ["telegram"],
702
688
  errored: [],
@@ -710,7 +696,7 @@ describe("telegram check", () => {
710
696
  throw new Error("HTTP 500 on GET /api/telegram-reader/gate-probe");
711
697
  },
712
698
  });
713
- expect(await run([CHECK], sources)).toEqual({
699
+ expect(await run([CHECK], sources)).toMatchObject({
714
700
  hasWork: true,
715
701
  reasons: ["telegram"],
716
702
  errored: [{ kind: "telegram", detail: "HTTP 500 on GET /api/telegram-reader/gate-probe" }],
@@ -724,13 +710,9 @@ describe("telegram check", () => {
724
710
  expect(line).toContain("kind=telegram");
725
711
  expect(line).toContain("/api/telegram-reader/gate-probe");
726
712
  });
727
- it("a commit advances the watermark to what the gate's probe read", async () => {
728
- // Task 2654 the gate's probe, not the commit's. The commit runs at the end
729
- // of the turn, so its own reading already includes anything that arrived
730
- // while the turn ran.
731
- await run([CHECK], quietSources(at("2026-08-05T11:00:00.000Z")));
732
- const result = await run([CHECK], quietSources(at("2026-08-05T11:00:00.000Z")), true);
733
- expect(result.advanced).toEqual(["telegram"]);
713
+ it("the advance writes the watermark to what the gate's probe read", async () => {
714
+ const gate = await run([CHECK], quietSources(at("2026-08-05T11:00:00.000Z")));
715
+ expect(advanceGateMark(EVENT, platformRoot, gate.observed).advanced).toEqual(["telegram"]);
734
716
  const marks = readWatermarks(EVENT, platformRoot);
735
717
  expect(marks.telegram).toEqual({ inbound: "2026-08-05T11:00:00.000Z" });
736
718
  });
@@ -770,21 +752,18 @@ describe("telegram check", () => {
770
752
  // nobody has read. That is the mute direction, and it loses a booking.
771
753
  seedWatermarks({ telegram: { inbound: "2026-08-05T18:00:00.000Z" } });
772
754
  const check = { type: "telegram", scope: "inbound", botId: "111" };
773
- expect(await run([check], quietSources(at("2026-08-05T12:00:00.000Z")))).toEqual({
755
+ expect(await run([check], quietSources(at("2026-08-05T12:00:00.000Z")))).toMatchObject({
774
756
  hasWork: true,
775
757
  reasons: ["telegram"],
776
758
  errored: [],
777
759
  });
778
760
  });
779
- it("a scoped commit writes under the bot, leaving the account-wide mark alone", async () => {
761
+ it("a scoped advance writes under the bot, leaving the account-wide mark alone", async () => {
780
762
  seedWatermarks({ telegram: { inbound: "2026-08-05T18:00:00.000Z" } });
781
763
  const check = { type: "telegram", scope: "inbound", botId: "111" };
782
764
  const sources = quietSources(at("2026-08-05T12:00:00.000Z"));
783
- // Task 2654 the commit promotes what the GATE read, so the gate call is
784
- // part of the sequence. A bare commit advances nothing, by design.
785
- await run([check], sources);
786
- const result = await run([check], sources, true);
787
- expect(result.advanced).toEqual(["telegram:111"]);
765
+ const gate = await run([check], sources);
766
+ expect(advanceGateMark(EVENT, platformRoot, gate.observed).advanced).toEqual(["telegram:111"]);
788
767
  const marks = readWatermarks(EVENT, platformRoot);
789
768
  expect(marks.telegram?.inbound).toBe("2026-08-05T18:00:00.000Z");
790
769
  expect(marks.telegram?.byBot).toEqual({ "111": "2026-08-05T12:00:00.000Z" });
@@ -828,14 +807,15 @@ describe("telegram check", () => {
828
807
  expect(line).toContain("kind=telegram");
829
808
  expect(line).toContain("0000000000");
830
809
  });
831
- it("a commit on a quiet account is skipped, not advanced onto nothing", async () => {
810
+ it("a quiet account records no position, so nothing advances onto nothing", async () => {
832
811
  // A null probe is "nothing to measure from", not "position zero". There is
833
- // no position to record, so the check reports as `skipped` exactly as `d1`
834
- // does, and the stored mark is left alone. Writing null would drop the mark
835
- // and re-report every stored message on the next fire.
812
+ // no position to record, so the check contributes no key and the stored mark
813
+ // is left alone. Writing null would drop the mark and re-report every stored
814
+ // message on the next fire.
836
815
  seedWatermarks({ telegram: { inbound: "2026-08-05T10:00:00.000Z" } });
837
- const result = await run([CHECK], quietSources(), true);
838
- expect(result).toMatchObject({ hasWork: false, advanced: [], skipped: ["telegram"] });
816
+ const gate = await run([CHECK], quietSources());
817
+ expect(gate).toMatchObject({ hasWork: false, observed: {} });
818
+ expect(advanceGateMark(EVENT, platformRoot, gate.observed).advanced).toEqual([]);
839
819
  expect(readWatermarks(EVENT, platformRoot).telegram).toEqual({
840
820
  inbound: "2026-08-05T10:00:00.000Z",
841
821
  });
@@ -849,7 +829,7 @@ describe("telegram check", () => {
849
829
  describe("unrecognised check type", () => {
850
830
  const CHECK = { type: "nonsense" };
851
831
  it("resolves rather than throwing, and counts the unknown type as work", async () => {
852
- expect(await run([CHECK], quietSources())).toEqual({
832
+ expect(await run([CHECK], quietSources())).toMatchObject({
853
833
  hasWork: true,
854
834
  reasons: ["nonsense"],
855
835
  errored: [
@@ -873,7 +853,7 @@ describe("unrecognised check type", () => {
873
853
  });
874
854
  it("still runs the sibling checks — one bad type does not abort the gate", async () => {
875
855
  const checks = [CHECK, { type: "d1", database: "b", sql: "SELECT 1" }];
876
- expect(await run(checks, quietSources())).toEqual({
856
+ expect(await run(checks, quietSources())).toMatchObject({
877
857
  hasWork: true,
878
858
  reasons: ["nonsense"],
879
859
  errored: [
@@ -938,8 +918,8 @@ describe("errored", () => {
938
918
  { kind: "calendar", detail: "calendar down" },
939
919
  ]);
940
920
  });
941
- it("is present on a commit too", async () => {
942
- const res = await run([D1], quietSources(), true);
921
+ it("is present and empty when every check answered", async () => {
922
+ const res = await run([D1], quietSources());
943
923
  expect(res.errored).toEqual([]);
944
924
  });
945
925
  });
@@ -959,23 +939,16 @@ describe("mail check — outlook provider", () => {
959
939
  const sources = quietSources({
960
940
  mailOutlook: async () => ({ newestReceivedAt: "2026-08-10T09:00:00Z" }),
961
941
  });
962
- expect(await run([CHECK], sources)).toEqual({ hasWork: false, reasons: [], errored: [] });
942
+ expect(await run([CHECK], sources)).toMatchObject({ hasWork: false, reasons: [], errored: [] });
963
943
  });
964
- it("is true on a message above the watermark, and advances that key on commit", async () => {
944
+ it("is true on a message above the watermark, and the advance writes that key", async () => {
965
945
  seedWatermarks({ mailOutlook: { [KEY]: { receivedAt: "2026-08-10T09:00:00Z" } } });
966
946
  const sources = quietSources({
967
947
  mailOutlook: async () => ({ newestReceivedAt: "2026-08-10T09:05:00Z" }),
968
948
  });
969
- // Task 2654 the commit promotes what the gate read, so the gate call is
970
- // part of the sequence. A bare commit advances nothing, by design.
971
- await run([CHECK], sources);
972
- expect(await run([CHECK], sources, true)).toEqual({
973
- hasWork: true,
974
- reasons: ["mail"],
975
- errored: [],
976
- advanced: [KEY],
977
- skipped: [],
978
- });
949
+ const gate = await run([CHECK], sources);
950
+ expect(gate).toMatchObject({ hasWork: true, reasons: ["mail"], errored: [] });
951
+ expect(advanceGateMark(EVENT, platformRoot, gate.observed).advanced).toEqual([KEY]);
979
952
  const written = JSON.parse(readFileSync(watermarkPath(EVENT, platformRoot), "utf-8"));
980
953
  expect(written.mailOutlook[KEY]).toEqual({ receivedAt: "2026-08-10T09:05:00Z" });
981
954
  });
@@ -983,7 +956,7 @@ describe("mail check — outlook provider", () => {
983
956
  const sources = quietSources({
984
957
  mailOutlook: async () => ({ newestReceivedAt: "2026-08-10T09:00:00Z" }),
985
958
  });
986
- expect(await run([CHECK], sources)).toEqual({ hasWork: true, reasons: ["mail"], errored: [] });
959
+ expect(await run([CHECK], sources)).toMatchObject({ hasWork: true, reasons: ["mail"], errored: [] });
987
960
  });
988
961
  it("orders by instant, not lexically, so a fractional second is not read as older", async () => {
989
962
  seedWatermarks({ mailOutlook: { [KEY]: { receivedAt: "2026-08-10T09:00:00Z" } } });
@@ -991,17 +964,13 @@ describe("mail check — outlook provider", () => {
991
964
  // Lexically "…00.500Z" sorts BELOW "…00Z" ('.' is 0x2E, 'Z' is 0x5A).
992
965
  mailOutlook: async () => ({ newestReceivedAt: "2026-08-10T09:00:00.500Z" }),
993
966
  });
994
- expect(await run([CHECK], sources)).toEqual({ hasWork: true, reasons: ["mail"], errored: [] });
967
+ expect(await run([CHECK], sources)).toMatchObject({ hasWork: true, reasons: ["mail"], errored: [] });
995
968
  });
996
- it("reports an empty folder as skipped on a commit rather than advancing onto nothing", async () => {
969
+ it("records no position for an empty folder rather than advancing onto nothing", async () => {
997
970
  const sources = quietSources({ mailOutlook: async () => ({ newestReceivedAt: null }) });
998
- expect(await run([CHECK], sources, true)).toEqual({
999
- hasWork: false,
1000
- reasons: [],
1001
- errored: [],
1002
- advanced: [],
1003
- skipped: ["mail"],
1004
- });
971
+ const gate = await run([CHECK], sources);
972
+ expect(gate).toEqual({ hasWork: false, reasons: [], errored: [], observed: {} });
973
+ expect(advanceGateMark(EVENT, platformRoot, gate.observed).advanced).toEqual([]);
1005
974
  });
1006
975
  it("keys the folder segment lower-cased, so two spellings address one entry", async () => {
1007
976
  seedWatermarks({ mailOutlook: { [KEY]: { receivedAt: "2026-08-10T09:00:00Z" } } });
@@ -1009,7 +978,7 @@ describe("mail check — outlook provider", () => {
1009
978
  const sources = quietSources({
1010
979
  mailOutlook: async () => ({ newestReceivedAt: "2026-08-10T09:00:00Z" }),
1011
980
  });
1012
- expect(await run([check], sources)).toEqual({ hasWork: false, reasons: [], errored: [] });
981
+ expect(await run([check], sources)).toMatchObject({ hasWork: false, reasons: [], errored: [] });
1013
982
  });
1014
983
  it("counts a probe that throws as work, and never as quiet", async () => {
1015
984
  const sources = quietSources({
@@ -1017,7 +986,7 @@ describe("mail check — outlook provider", () => {
1017
986
  throw new Error("No Outlook mailbox admin@muvin.co.uk is connected");
1018
987
  },
1019
988
  });
1020
- expect(await run([CHECK], sources)).toEqual({
989
+ expect(await run([CHECK], sources)).toMatchObject({
1021
990
  hasWork: true,
1022
991
  reasons: ["mail"],
1023
992
  errored: [{ kind: "mail", detail: "No Outlook mailbox admin@muvin.co.uk is connected" }],
@@ -1025,7 +994,7 @@ describe("mail check — outlook provider", () => {
1025
994
  });
1026
995
  it("throws on a provider outside the accepted list", async () => {
1027
996
  const check = { type: "mail", mailbox: "a@b.com", provider: "pop3" };
1028
- expect(await run([check], quietSources())).toEqual({
997
+ expect(await run([check], quietSources())).toMatchObject({
1029
998
  hasWork: true,
1030
999
  reasons: ["mail"],
1031
1000
  errored: [{ kind: "mail", detail: 'unknown mail provider "pop3" — accepted: imap, outlook' }],
@@ -1036,9 +1005,9 @@ describe("mail check — outlook provider", () => {
1036
1005
  const sources = quietSources({
1037
1006
  mailOutlook: async () => ({ newestReceivedAt: "2026-08-10T09:00:00Z" }),
1038
1007
  });
1039
- // Task 2654 — the gate call records the position the commit promotes.
1040
- await run([CHECK], sources);
1041
- await run([CHECK], sources, true);
1008
+ // Task 2663 — the gate call records the position the advance promotes.
1009
+ const gate = await run([CHECK], sources);
1010
+ advanceGateMark(EVENT, platformRoot, gate.observed);
1042
1011
  const written = JSON.parse(readFileSync(watermarkPath(EVENT, platformRoot), "utf-8"));
1043
1012
  expect(written.mail[KEY]).toEqual({ uid: 42, uidValidity: 7 });
1044
1013
  expect(written.mailOutlook[KEY]).toEqual({ receivedAt: "2026-08-10T09:00:00Z" });
@@ -1056,18 +1025,21 @@ describe("mail check — outlook provider", () => {
1056
1025
  });
1057
1026
  });
1058
1027
  // ---------------------------------------------------------------------------
1059
- // Task 2654 — a source that moves between the gate call and the commit.
1028
+ // Task 2654, kept under Task 2663 — a source that moves between the gate call
1029
+ // and the advance.
1060
1030
  //
1061
- // The commit runs at the END of the agent turn, so its own probe already
1062
- // includes whatever arrived while the turn ran. Writing that position puts the
1063
- // arrival BELOW the watermark and no later fire reports it. The commit writes
1064
- // what the GATE read instead, so the mover stays above.
1031
+ // The advance happens after the wake was accepted, by which time the source may
1032
+ // have moved again. Writing what the source holds THEN would put the arrival
1033
+ // below the mark and no later fire would report it. The dispatcher writes what
1034
+ // the GATE returned instead, so the mover stays above. Under 2654 that guarantee
1035
+ // depended on a sidecar file being fresh; it is now structural, because the
1036
+ // positions travel with the result.
1065
1037
  //
1066
- // Each check is driven alone, and each test runs the real sequence: gate,
1067
- // source moves, commit, gate again.
1038
+ // Each check is driven alone, and each test runs the real sequence: gate, source
1039
+ // moves, advance, gate again.
1068
1040
  // ---------------------------------------------------------------------------
1069
- describe("a source that moves between the gate call and the commit", () => {
1070
- it("mail: a UID that arrives mid-turn is still reported after the commit", async () => {
1041
+ describe("a source that moves between the gate call and the advance", () => {
1042
+ it("mail: a UID that arrives mid-turn is still reported after the advance", async () => {
1071
1043
  let highest = 105;
1072
1044
  const sources = quietSources({
1073
1045
  mail: async (_c, sinceUid) => ({
@@ -1077,14 +1049,15 @@ describe("a source that moves between the gate call and the commit", () => {
1077
1049
  }),
1078
1050
  });
1079
1051
  const checks = [{ type: "mail", mailbox: "ops@example.com" }];
1080
- expect(await run(checks, sources)).toEqual({ hasWork: true, reasons: ["mail"], errored: [] });
1052
+ const gate = await run(checks, sources);
1053
+ expect(gate).toMatchObject({ hasWork: true, reasons: ["mail"], errored: [] });
1081
1054
  highest = 106; // arrives while the turn runs
1082
- await run(checks, sources, true);
1055
+ advanceGateMark(EVENT, platformRoot, gate.observed);
1083
1056
  const marks = JSON.parse(readFileSync(watermarkPath(EVENT, platformRoot), "utf-8"));
1084
1057
  expect(marks.mail["ops@example.com|inbox"].uid).toBe(105);
1085
- expect(await run(checks, sources)).toEqual({ hasWork: true, reasons: ["mail"], errored: [] });
1058
+ expect(await run(checks, sources)).toMatchObject({ hasWork: true, reasons: ["mail"] });
1086
1059
  });
1087
- it("whatsapp: an inbound message mid-turn is still reported after the commit", async () => {
1060
+ it("whatsapp: an inbound message mid-turn is still reported after the advance", async () => {
1088
1061
  let latest = 1_754_000_100;
1089
1062
  const sources = quietSources({
1090
1063
  whatsappInbound: async (_a, since) => ({
@@ -1093,86 +1066,96 @@ describe("a source that moves between the gate call and the commit", () => {
1093
1066
  }),
1094
1067
  });
1095
1068
  const checks = [{ type: "whatsapp", scope: "inbound" }];
1096
- expect(await run(checks, sources)).toEqual({ hasWork: true, reasons: ["whatsapp"], errored: [] });
1069
+ const gate = await run(checks, sources);
1070
+ expect(gate).toMatchObject({ hasWork: true, reasons: ["whatsapp"], errored: [] });
1097
1071
  latest = 1_754_000_200;
1098
- await run(checks, sources, true);
1072
+ advanceGateMark(EVENT, platformRoot, gate.observed);
1099
1073
  const marks = JSON.parse(readFileSync(watermarkPath(EVENT, platformRoot), "utf-8"));
1100
1074
  expect(marks.whatsapp.inbound).toBe(1_754_000_100);
1101
- expect(await run(checks, sources)).toEqual({ hasWork: true, reasons: ["whatsapp"], errored: [] });
1075
+ expect(await run(checks, sources)).toMatchObject({ hasWork: true, reasons: ["whatsapp"] });
1102
1076
  });
1103
- it("telegram: an inbound message mid-turn is still reported after the commit", async () => {
1077
+ it("telegram: an inbound message mid-turn is still reported after the advance", async () => {
1104
1078
  let newest = "2026-08-10T10:00:00.000Z";
1105
1079
  const sources = quietSources({ telegramInbound: async () => ({ newestInboundAt: newest }) });
1106
1080
  const checks = [{ type: "telegram", scope: "inbound" }];
1107
- expect(await run(checks, sources)).toEqual({ hasWork: true, reasons: ["telegram"], errored: [] });
1081
+ const gate = await run(checks, sources);
1082
+ expect(gate).toMatchObject({ hasWork: true, reasons: ["telegram"], errored: [] });
1108
1083
  newest = "2026-08-10T10:30:00.000Z";
1109
- await run(checks, sources, true);
1084
+ advanceGateMark(EVENT, platformRoot, gate.observed);
1110
1085
  const marks = JSON.parse(readFileSync(watermarkPath(EVENT, platformRoot), "utf-8"));
1111
1086
  expect(marks.telegram.inbound).toBe("2026-08-10T10:00:00.000Z");
1112
- expect(await run(checks, sources)).toEqual({ hasWork: true, reasons: ["telegram"], errored: [] });
1087
+ expect(await run(checks, sources)).toMatchObject({ hasWork: true, reasons: ["telegram"] });
1113
1088
  });
1114
- it("calendar: a connector change mid-turn is still reported after the commit", async () => {
1089
+ it("calendar: a connector change mid-turn is still reported after the advance", async () => {
1115
1090
  let digest = "DIGEST-A";
1116
1091
  const sources = quietSources({ calendarDigest: async () => digest });
1117
1092
  const checks = [{ type: "calendar", connector: "google" }];
1118
- expect(await run(checks, sources)).toEqual({ hasWork: true, reasons: ["calendar"], errored: [] });
1093
+ const gate = await run(checks, sources);
1094
+ expect(gate).toMatchObject({ hasWork: true, reasons: ["calendar"], errored: [] });
1119
1095
  digest = "DIGEST-B";
1120
- await run(checks, sources, true);
1096
+ advanceGateMark(EVENT, platformRoot, gate.observed);
1121
1097
  const marks = JSON.parse(readFileSync(watermarkPath(EVENT, platformRoot), "utf-8"));
1122
1098
  expect(marks.calendar.digest).toBe("DIGEST-A");
1123
- expect(await run(checks, sources)).toEqual({ hasWork: true, reasons: ["calendar"], errored: [] });
1124
- });
1125
- // Task 2654 — the no-event case. A message swallowed in production emitted
1126
- // nothing at all, and re-running the gate reproduced nothing, so the pair below
1127
- // is the only way to see a watermark that jumped past an unseen window.
1128
- describe("gate-read and commit positions pair on one fire", () => {
1099
+ expect(await run(checks, sources)).toMatchObject({ hasWork: true, reasons: ["calendar"] });
1100
+ });
1101
+ // Task 2663 — the no-event case. The gate that could never go quiet emitted a
1102
+ // line on every fire that looked exactly like a healthy one, because it named
1103
+ // only what the probe had just read and never the value it was compared
1104
+ // against. Both sides now print, so a mark stuck on an empty slot is legible
1105
+ // from one line without reproducing anything.
1106
+ describe("gate-read names both sides of the comparison", () => {
1129
1107
  // `console.error` is already spied in `beforeEach`, so its recorded calls are
1130
1108
  // the log. Re-mocking it here would drop the lines written before this point.
1131
1109
  const errorLines = () => vi.mocked(console.error).mock.calls.map((c) => c.map(String).join(" "));
1132
- it("names the position read at gate time and the position written at commit time", async () => {
1110
+ it("names null stored on a first fire, and the observed position beside it", async () => {
1133
1111
  const sources = quietSources({ calendarDigest: async () => "abcdef0123456789" });
1134
- const checks = [{ type: "calendar", connector: "google" }];
1135
- await run(checks, sources);
1136
- await run(checks, sources, true);
1137
- expect(errorLines()).toContainEqual(expect.stringContaining("op=gate-read eventId=65ef758c check=calendar position=abcdef01"));
1138
- expect(errorLines()).toContainEqual(expect.stringContaining("positions=calendar:abcdef01"));
1112
+ await run([{ type: "calendar", connector: "google" }], sources);
1113
+ expect(errorLines()).toContainEqual(expect.stringContaining("op=gate-read eventId=65ef758c check=calendar stored=null observed=abcdef01"));
1139
1114
  });
1140
- it("names an unreadable observation, which otherwise reads as a first commit", async () => {
1141
- // A present-but-unusable file stops every watermark advancing while
1142
- // `op=commit` still prints an empty `advanced=` — indistinguishable from a
1143
- // healthy first commit without a line of its own.
1144
- const sources = quietSources({ calendarDigest: async () => "DIGEST-Z" });
1115
+ it("names the stored position once the mark has advanced", async () => {
1116
+ const sources = quietSources({ calendarDigest: async () => "abcdef0123456789" });
1145
1117
  const checks = [{ type: "calendar", connector: "google" }];
1118
+ const gate = await run(checks, sources);
1119
+ advanceGateMark(EVENT, platformRoot, gate.observed);
1146
1120
  await run(checks, sources);
1147
- writeFileSync(observationPath(EVENT, platformRoot), "{ not json");
1148
- const result = await run(checks, sources, true);
1149
- expect(result.advanced).toEqual([]);
1150
- expect(errorLines()).toContainEqual(expect.stringContaining("op=observation-unreadable eventId=65ef758c reason="));
1151
- });
1152
- it("says nothing when no observation file exists, which is a first fire", async () => {
1153
- const sources = quietSources({ calendarDigest: async () => "DIGEST-Z" });
1154
- await run([{ type: "calendar", connector: "google" }], sources, true);
1155
- expect(errorLines().filter((l) => l.includes("op=observation-unreadable"))).toEqual([]);
1121
+ expect(errorLines()).toContainEqual(expect.stringContaining("op=gate-read eventId=65ef758c check=calendar stored=abcdef01 observed=abcdef01"));
1156
1122
  });
1157
1123
  it("emits no gate-read for a check that holds no position", async () => {
1158
- // `op=check-error` and `skipped=` already cover a check with no position;
1159
- // a `position=null` line would only add a value nobody can compare.
1124
+ // `op=check-error` already covers a check with no position; a line of two
1125
+ // nulls would only add a value nobody can compare.
1160
1126
  await run([{ type: "d1", database: "b", sql: "SELECT count(*) FROM b" }], quietSources());
1161
1127
  expect(errorLines().filter((l) => l.includes("op=gate-read"))).toEqual([]);
1162
1128
  });
1163
1129
  });
1164
- it("a commit with no gate call before it advances nothing", async () => {
1165
- // Route two (PLUGIN.md) lets a separate action event issue the commit. With
1166
- // no observation on file there is no position anyone saw, so the watermark
1167
- // stays put and the next fire reports the work again the fail-open
1168
- // direction the whole tool holds to.
1169
- const sources = quietSources({ calendarDigest: async () => "DIGEST-Z" });
1170
- const checks = [{ type: "calendar", connector: "google" }];
1171
- const result = await run(checks, sources, true);
1172
- expect(result.advanced).toEqual([]);
1173
- const marks = JSON.parse(readFileSync(watermarkPath(EVENT, platformRoot), "utf-8"));
1174
- expect(marks.calendar).toBeUndefined();
1175
- expect(await run(checks, sources)).toEqual({ hasWork: true, reasons: ["calendar"], errored: [] });
1130
+ });
1131
+ // Task 2663 the gate hands its readings back to the dispatcher and touches no
1132
+ // sidecar. The commit and the `<eventId>.observed.json` file it promoted from
1133
+ // are both gone: a sidecar survives a gate call that errored, so the stale
1134
+ // positions from an earlier fire would be promoted on the next accepted wake and
1135
+ // mark as seen messages nobody read.
1136
+ describe("observed positions (Task 2663)", () => {
1137
+ it("returns the positions it read, and writes no sidecar", async () => {
1138
+ const result = await run([{ type: "telegram", scope: "inbound", botId: "555" }], quietSources({ telegramInbound: async () => ({ newestInboundAt: "2026-08-10T18:00:00.000Z" }) }));
1139
+ expect(result.observed).toEqual({ telegram: { byBot: { "555": "2026-08-10T18:00:00.000Z" } } });
1140
+ expect(existsSync(join(platformRoot, "..", "state", "scheduling-gate", `${EVENT}.observed.json`))).toBe(false);
1141
+ });
1142
+ it("reports no position for a check that holds none", async () => {
1143
+ // `d1` reads a current value rather than a position in a stream, so there is
1144
+ // nothing for the dispatcher to promote and `observed` is empty rather than
1145
+ // carrying a fabricated key.
1146
+ const result = await run([{ type: "d1", database: "b", sql: "SELECT 1" }], quietSources({ d1: async () => 1 }));
1147
+ expect(result.observed).toEqual({});
1148
+ });
1149
+ it("reports no position for a check that threw", async () => {
1150
+ // A check that errored knows nothing about its source. Recording a position
1151
+ // for it would let the dispatcher advance past work the gate never saw.
1152
+ const result = await run([{ type: "telegram", scope: "inbound", botId: "555" }], quietSources({
1153
+ telegramInbound: async () => {
1154
+ throw new Error("fetch failed");
1155
+ },
1156
+ }));
1157
+ expect(result.hasWork).toBe(true);
1158
+ expect(result.observed).toEqual({});
1176
1159
  });
1177
1160
  });
1178
1161
  //# sourceMappingURL=scheduling-gate.test.js.map