@rubytech/create-realagent-code 0.1.591 → 0.1.593

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 (89) 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/platform-architecture/SKILL.md +7 -3
  15. package/payload/platform/plugins/admin/skills/whats-new/SKILL.md +10 -0
  16. package/payload/platform/plugins/docs/references/internals.md +4 -2
  17. package/payload/platform/plugins/docs/references/telegram-guide.md +2 -0
  18. package/payload/platform/plugins/scheduling/PLUGIN.md +6 -6
  19. package/payload/platform/plugins/scheduling/mcp/dist/index.js +4 -12
  20. package/payload/platform/plugins/scheduling/mcp/dist/index.js.map +1 -1
  21. package/payload/platform/plugins/scheduling/mcp/dist/lib/__tests__/gate-audits.test.js +85 -1
  22. package/payload/platform/plugins/scheduling/mcp/dist/lib/__tests__/gate-audits.test.js.map +1 -1
  23. package/payload/platform/plugins/scheduling/mcp/dist/lib/__tests__/gate.test.js +55 -1
  24. package/payload/platform/plugins/scheduling/mcp/dist/lib/__tests__/gate.test.js.map +1 -1
  25. package/payload/platform/plugins/scheduling/mcp/dist/lib/gate.d.ts +10 -1
  26. package/payload/platform/plugins/scheduling/mcp/dist/lib/gate.d.ts.map +1 -1
  27. package/payload/platform/plugins/scheduling/mcp/dist/lib/gate.js +42 -6
  28. package/payload/platform/plugins/scheduling/mcp/dist/lib/gate.js.map +1 -1
  29. package/payload/platform/plugins/scheduling/mcp/dist/lib/schedule-audit.d.ts +22 -0
  30. package/payload/platform/plugins/scheduling/mcp/dist/lib/schedule-audit.d.ts.map +1 -1
  31. package/payload/platform/plugins/scheduling/mcp/dist/lib/schedule-audit.js +73 -0
  32. package/payload/platform/plugins/scheduling/mcp/dist/lib/schedule-audit.js.map +1 -1
  33. package/payload/platform/plugins/scheduling/mcp/dist/scripts/__tests__/agent-turn-dispatch.test.js +93 -1
  34. package/payload/platform/plugins/scheduling/mcp/dist/scripts/__tests__/agent-turn-dispatch.test.js.map +1 -1
  35. package/payload/platform/plugins/scheduling/mcp/dist/scripts/agent-turn-dispatch.d.ts +2 -1
  36. package/payload/platform/plugins/scheduling/mcp/dist/scripts/agent-turn-dispatch.d.ts.map +1 -1
  37. package/payload/platform/plugins/scheduling/mcp/dist/scripts/agent-turn-dispatch.js +45 -1
  38. package/payload/platform/plugins/scheduling/mcp/dist/scripts/agent-turn-dispatch.js.map +1 -1
  39. package/payload/platform/plugins/scheduling/mcp/dist/scripts/check-due-events.js +19 -2
  40. package/payload/platform/plugins/scheduling/mcp/dist/scripts/check-due-events.js.map +1 -1
  41. package/payload/platform/plugins/scheduling/mcp/dist/tools/__tests__/schedule-gate-write.test.js +27 -280
  42. package/payload/platform/plugins/scheduling/mcp/dist/tools/__tests__/schedule-gate-write.test.js.map +1 -1
  43. package/payload/platform/plugins/scheduling/mcp/dist/tools/__tests__/scheduling-gate.test.js +315 -208
  44. package/payload/platform/plugins/scheduling/mcp/dist/tools/__tests__/scheduling-gate.test.js.map +1 -1
  45. package/payload/platform/plugins/scheduling/mcp/dist/tools/schedule-event.d.ts +1 -31
  46. package/payload/platform/plugins/scheduling/mcp/dist/tools/schedule-event.d.ts.map +1 -1
  47. package/payload/platform/plugins/scheduling/mcp/dist/tools/schedule-event.js +15 -95
  48. package/payload/platform/plugins/scheduling/mcp/dist/tools/schedule-event.js.map +1 -1
  49. package/payload/platform/plugins/scheduling/mcp/dist/tools/schedule-update.d.ts.map +1 -1
  50. package/payload/platform/plugins/scheduling/mcp/dist/tools/schedule-update.js +5 -36
  51. package/payload/platform/plugins/scheduling/mcp/dist/tools/schedule-update.js.map +1 -1
  52. package/payload/platform/plugins/scheduling/mcp/dist/tools/scheduling-gate.d.ts +83 -51
  53. package/payload/platform/plugins/scheduling/mcp/dist/tools/scheduling-gate.d.ts.map +1 -1
  54. package/payload/platform/plugins/scheduling/mcp/dist/tools/scheduling-gate.js +152 -105
  55. package/payload/platform/plugins/scheduling/mcp/dist/tools/scheduling-gate.js.map +1 -1
  56. package/payload/platform/services/claude-session-manager/dist/http-server.d.ts +37 -0
  57. package/payload/platform/services/claude-session-manager/dist/http-server.d.ts.map +1 -1
  58. package/payload/platform/services/claude-session-manager/dist/http-server.js +178 -13
  59. package/payload/platform/services/claude-session-manager/dist/http-server.js.map +1 -1
  60. package/payload/platform/services/claude-session-manager/dist/rc-public-lockdown.d.ts +13 -5
  61. package/payload/platform/services/claude-session-manager/dist/rc-public-lockdown.d.ts.map +1 -1
  62. package/payload/platform/services/claude-session-manager/dist/rc-public-lockdown.js +23 -9
  63. package/payload/platform/services/claude-session-manager/dist/rc-public-lockdown.js.map +1 -1
  64. package/payload/platform/services/claude-session-manager/dist/telegram-channel-mcp.d.ts +7 -4
  65. package/payload/platform/services/claude-session-manager/dist/telegram-channel-mcp.d.ts.map +1 -1
  66. package/payload/platform/services/claude-session-manager/dist/telegram-channel-mcp.js +5 -3
  67. package/payload/platform/services/claude-session-manager/dist/telegram-channel-mcp.js.map +1 -1
  68. package/payload/platform/services/claude-session-manager/dist/telegram-channel-store.d.ts +15 -1
  69. package/payload/platform/services/claude-session-manager/dist/telegram-channel-store.d.ts.map +1 -1
  70. package/payload/platform/services/claude-session-manager/dist/telegram-channel-store.js +16 -7
  71. package/payload/platform/services/claude-session-manager/dist/telegram-channel-store.js.map +1 -1
  72. package/payload/platform/services/claude-session-manager/dist/telegram-plugin-mcp.d.ts +28 -0
  73. package/payload/platform/services/claude-session-manager/dist/telegram-plugin-mcp.d.ts.map +1 -0
  74. package/payload/platform/services/claude-session-manager/dist/telegram-plugin-mcp.js +53 -0
  75. package/payload/platform/services/claude-session-manager/dist/telegram-plugin-mcp.js.map +1 -0
  76. package/payload/platform/services/claude-session-manager/dist/wa-channel-mcp.d.ts +9 -5
  77. package/payload/platform/services/claude-session-manager/dist/wa-channel-mcp.d.ts.map +1 -1
  78. package/payload/platform/services/claude-session-manager/dist/wa-channel-mcp.js +12 -6
  79. package/payload/platform/services/claude-session-manager/dist/wa-channel-mcp.js.map +1 -1
  80. package/payload/platform/services/claude-session-manager/dist/webchat-channel-mcp.d.ts +7 -3
  81. package/payload/platform/services/claude-session-manager/dist/webchat-channel-mcp.d.ts.map +1 -1
  82. package/payload/platform/services/claude-session-manager/dist/webchat-channel-mcp.js +5 -1
  83. package/payload/platform/services/claude-session-manager/dist/webchat-channel-mcp.js.map +1 -1
  84. package/payload/platform/services/telegram-channel/dist/instructions.d.ts +14 -2
  85. package/payload/platform/services/telegram-channel/dist/instructions.d.ts.map +1 -1
  86. package/payload/platform/services/telegram-channel/dist/instructions.js +34 -3
  87. package/payload/platform/services/telegram-channel/dist/instructions.js.map +1 -1
  88. package/payload/server/public/operator.html +1 -1
  89. package/payload/server/server.js +74 -9
@@ -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, readWatermarksChecked, 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,79 @@ 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"]);
333
+ ], quietSources());
334
+ expect(gate.observed).toEqual({});
335
+ expect(advanceGateMark(EVENT, platformRoot, gate.observed).advanced).toEqual([]);
336
+ });
337
+ // Task 2664 — the destructive half. `readWatermarks` returned `{}` for a file
338
+ // it could not parse, this function promoted into that empty object and wrote
339
+ // it, and the file's real positions were destroyed rather than repaired.
340
+ it("does not overwrite a file it could not parse", async () => {
341
+ const path = watermarkPath(EVENT, platformRoot);
342
+ mkdirSync(join(path, ".."), { recursive: true });
343
+ writeFileSync(path, "{ not json");
344
+ const before = readFileSync(path);
345
+ const gate = await run([{ type: "calendar", connector: "google" }], quietSources({ calendarDigest: async () => "DIGEST-Z" }));
346
+ const { advanced, unreadable } = advanceGateMark(EVENT, platformRoot, gate.observed);
347
+ expect({ advanced, unreadable }).toEqual({ advanced: [], unreadable: true });
348
+ expect(readFileSync(path).equals(before)).toBe(true);
332
349
  });
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"]);
350
+ it("still writes a first fire, which has no file at all", async () => {
351
+ const gate = await run([{ type: "calendar", connector: "google" }], quietSources({ calendarDigest: async () => "DIGEST-Z" }));
352
+ const { advanced, unreadable } = advanceGateMark(EVENT, platformRoot, gate.observed);
353
+ expect({ advanced, unreadable }).toEqual({ advanced: ["calendar"], unreadable: false });
354
+ expect(existsSync(watermarkPath(EVENT, platformRoot))).toBe(true);
352
355
  });
353
356
  });
354
357
  describe("watermark location", () => {
@@ -599,14 +602,16 @@ describe("watermark folder-case fold", () => {
599
602
  const sources = quietSources({
600
603
  mail: async (_c, since) => ({ matched: false, highestUid: since + 1, uidValidity: 1 }),
601
604
  });
602
- // Task 2654 — each commit promotes what its own gate call read, so both
605
+ // Task 2663 — each advance promotes what its own gate call read, so both
603
606
  // 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"]);
607
+ const firstGate = await run([lower], sources);
608
+ expect(advanceGateMark(EVENT, platformRoot, firstGate.observed).advanced).toEqual([
609
+ "ops@example.com|inbox",
610
+ ]);
611
+ const secondGate = await run([upper], sources);
612
+ expect(advanceGateMark(EVENT, platformRoot, secondGate.observed).advanced).toEqual([
613
+ "ops@example.com|inbox",
614
+ ]);
610
615
  const marks = JSON.parse(readFileSync(watermarkPath(EVENT, platformRoot), "utf-8"));
611
616
  expect(Object.keys(marks.mail)).toEqual(["ops@example.com|inbox"]);
612
617
  // The second call read the first call's position rather than restarting at 0.
@@ -628,6 +633,111 @@ describe("watermark folder-case fold", () => {
628
633
  await run([check], sources);
629
634
  expect(seen).toEqual([106]);
630
635
  });
636
+ // Task 2664 — a file that is present and unusable is a third state. Today it
637
+ // reads identically to a healthy first fire: `{}` and no line.
638
+ describe("readWatermarksChecked", () => {
639
+ /** The lines console.error saw, with the suite's default silencer replaced. */
640
+ function captureErrors() {
641
+ const lines = [];
642
+ vi.spyOn(console, "error").mockImplementation((...a) => void lines.push(a.join(" ")));
643
+ return lines;
644
+ }
645
+ function writeRaw(body) {
646
+ const path = watermarkPath(EVENT, platformRoot);
647
+ mkdirSync(join(path, ".."), { recursive: true });
648
+ writeFileSync(path, body);
649
+ }
650
+ it("is silent on a missing file and does not call it unreadable", () => {
651
+ const lines = captureErrors();
652
+ expect(readWatermarksChecked(EVENT, platformRoot)).toEqual({ marks: {}, unreadable: false });
653
+ expect(lines.filter((l) => l.includes("op=watermark-unreadable"))).toEqual([]);
654
+ });
655
+ it("names a file that does not parse, by its id8", () => {
656
+ writeRaw("{ not json");
657
+ const lines = captureErrors();
658
+ const { marks, unreadable } = readWatermarksChecked(EVENT, platformRoot);
659
+ expect({ marks, unreadable }).toEqual({ marks: {}, unreadable: true });
660
+ expect(lines.join("\n")).toContain(`[scheduling-gate] op=watermark-unreadable eventId=${EVENT.slice(0, 8)} reason=`);
661
+ });
662
+ it("names a file that parses to a non-object", () => {
663
+ writeRaw('"a string"');
664
+ const lines = captureErrors();
665
+ expect(readWatermarksChecked(EVENT, platformRoot)).toEqual({ marks: {}, unreadable: true });
666
+ expect(lines.join("\n")).toContain("op=watermark-unreadable");
667
+ expect(lines.join("\n")).toContain("reason=not an object");
668
+ });
669
+ // Task 2667 — an array is an object to `typeof`, so it passed the guard
670
+ // above. `foldMailWatermarks` returned it unchanged (`marks.mail` is
671
+ // undefined), `advanceGateMark` promoted into it, and `JSON.stringify`
672
+ // dropped every non-index property on the way to disk, so the file was
673
+ // written back as `[]` while `op=mark-advanced` named the positions it had
674
+ // just lost.
675
+ it("names a file that parses to an array, by its shape", () => {
676
+ writeRaw("[]");
677
+ const lines = captureErrors();
678
+ expect(readWatermarksChecked(EVENT, platformRoot)).toEqual({ marks: {}, unreadable: true });
679
+ expect(lines.join("\n")).toContain(`[scheduling-gate] op=watermark-unreadable eventId=${EVENT.slice(0, 8)} ` +
680
+ `reason=an array, not a watermark object`);
681
+ });
682
+ it("leaves an array file byte-identical across an advance that observed a position", async () => {
683
+ // An array holding an element, written compactly. A bare `[]` cannot tell
684
+ // the two behaviours apart: the promoted properties are dropped by
685
+ // `JSON.stringify`, so the destructive write reproduces `[]` byte for byte
686
+ // and the assertion passes while the file is being rewritten on every fire.
687
+ writeRaw('["a stray element"]');
688
+ captureErrors();
689
+ const path = watermarkPath(EVENT, platformRoot);
690
+ const before = readFileSync(path);
691
+ const gate = await run([{ type: "calendar", connector: "google" }], quietSources());
692
+ // The advance has something to promote, which is what made this destructive.
693
+ expect(gate.observed.calendar?.digest).toBeDefined();
694
+ const { advanced, unreadable } = advanceGateMark(EVENT, platformRoot, gate.observed);
695
+ // The bytes first: the defect is the WRITE, and asserting the return value
696
+ // first would leave the emptied file unobserved on a red run.
697
+ expect(readFileSync(path).equals(before)).toBe(true);
698
+ expect({ advanced, unreadable }).toEqual({ advanced: [], unreadable: true });
699
+ });
700
+ it("a gate over an array file still reports work", async () => {
701
+ writeRaw("[]");
702
+ captureErrors();
703
+ const gate = await run([{ type: "calendar", connector: "google" }], quietSources());
704
+ expect(gate).toMatchObject({ hasWork: true, reasons: ["calendar"] });
705
+ });
706
+ it("names a read error that is not a missing file", () => {
707
+ // A directory where the file should be: present, and unreadable for a
708
+ // reason that is not ENOENT.
709
+ mkdirSync(watermarkPath(EVENT, platformRoot), { recursive: true });
710
+ const lines = captureErrors();
711
+ expect(readWatermarksChecked(EVENT, platformRoot)).toEqual({ marks: {}, unreadable: true });
712
+ expect(lines.join("\n")).toContain("op=watermark-unreadable");
713
+ });
714
+ it("emits on EVERY read, so it repeats until the file is fixed", () => {
715
+ writeRaw("{ not json");
716
+ const lines = captureErrors();
717
+ readWatermarksChecked(EVENT, platformRoot);
718
+ readWatermarksChecked(EVENT, platformRoot);
719
+ expect(lines.filter((l) => l.includes("op=watermark-unreadable"))).toHaveLength(2);
720
+ });
721
+ it("reports a readable file as readable and folds it", () => {
722
+ seedWatermarks({ mail: { "a@b.com|INBOX": { uid: 106, uidValidity: 11 } } });
723
+ const lines = captureErrors();
724
+ const { marks, unreadable } = readWatermarksChecked(EVENT, platformRoot);
725
+ expect(unreadable).toBe(false);
726
+ expect(marks.mail).toEqual({ "a@b.com|inbox": { uid: 106, uidValidity: 11 } });
727
+ expect(lines.filter((l) => l.includes("op=watermark-unreadable"))).toEqual([]);
728
+ });
729
+ it("readWatermarks still returns just the marks, so its callers are untouched", () => {
730
+ writeRaw("{ not json");
731
+ captureErrors();
732
+ expect(readWatermarks(EVENT, platformRoot)).toEqual({});
733
+ });
734
+ it("a gate over an unreadable file still reports work", async () => {
735
+ writeRaw("{ not json");
736
+ captureErrors();
737
+ const gate = await run([{ type: "calendar", connector: "google" }], quietSources());
738
+ expect(gate).toMatchObject({ hasWork: true, reasons: ["calendar"] });
739
+ });
740
+ });
631
741
  it("readWatermarks writes nothing, so the audit path stays read-only", () => {
632
742
  // schedule-audit.ts:218 reads every gated event's file and never writes.
633
743
  seedWatermarks({
@@ -664,11 +774,11 @@ describe("telegram check", () => {
664
774
  };
665
775
  const measured = (lines) => lines.every((l) => !l.includes("op=check-error"));
666
776
  it("is false when the account holds no inbound message at all", async () => {
667
- expect(await run([CHECK], quietSources())).toEqual({ hasWork: false, reasons: [], errored: [] });
777
+ expect(await run([CHECK], quietSources())).toMatchObject({ hasWork: false, reasons: [], errored: [] });
668
778
  });
669
779
  it("is false when nothing arrived after the watermark", async () => {
670
780
  seedWatermarks({ telegram: { inbound: "2026-08-05T10:00:00.000Z" } });
671
- expect(await run([CHECK], quietSources(at("2026-08-05T09:00:00.000Z")))).toEqual({
781
+ expect(await run([CHECK], quietSources(at("2026-08-05T09:00:00.000Z")))).toMatchObject({
672
782
  hasWork: false,
673
783
  reasons: [],
674
784
  errored: [],
@@ -677,7 +787,7 @@ describe("telegram check", () => {
677
787
  it("is true when a message arrived after the watermark", async () => {
678
788
  seedWatermarks({ telegram: { inbound: "2026-08-05T10:00:00.000Z" } });
679
789
  const lines = linesFrom();
680
- expect(await run([CHECK], quietSources(at("2026-08-05T11:00:00.000Z")))).toEqual({
790
+ expect(await run([CHECK], quietSources(at("2026-08-05T11:00:00.000Z")))).toMatchObject({
681
791
  hasWork: true,
682
792
  reasons: ["telegram"],
683
793
  errored: [],
@@ -688,7 +798,7 @@ describe("telegram check", () => {
688
798
  // The boundary is `>`, not `>=`. A `>=` would re-report the last handled
689
799
  // message on every fire and the gate could never go quiet.
690
800
  seedWatermarks({ telegram: { inbound: "2026-08-05T10:00:00.000Z" } });
691
- expect(await run([CHECK], quietSources(at("2026-08-05T10:00:00.000Z")))).toEqual({
801
+ expect(await run([CHECK], quietSources(at("2026-08-05T10:00:00.000Z")))).toMatchObject({
692
802
  hasWork: false,
693
803
  reasons: [],
694
804
  errored: [],
@@ -696,7 +806,7 @@ describe("telegram check", () => {
696
806
  });
697
807
  it("is true on a first run, so nothing already waiting is missed", async () => {
698
808
  const lines = linesFrom();
699
- expect(await run([CHECK], quietSources(at("2026-08-05T09:00:00.000Z")))).toEqual({
809
+ expect(await run([CHECK], quietSources(at("2026-08-05T09:00:00.000Z")))).toMatchObject({
700
810
  hasWork: true,
701
811
  reasons: ["telegram"],
702
812
  errored: [],
@@ -710,7 +820,7 @@ describe("telegram check", () => {
710
820
  throw new Error("HTTP 500 on GET /api/telegram-reader/gate-probe");
711
821
  },
712
822
  });
713
- expect(await run([CHECK], sources)).toEqual({
823
+ expect(await run([CHECK], sources)).toMatchObject({
714
824
  hasWork: true,
715
825
  reasons: ["telegram"],
716
826
  errored: [{ kind: "telegram", detail: "HTTP 500 on GET /api/telegram-reader/gate-probe" }],
@@ -724,13 +834,9 @@ describe("telegram check", () => {
724
834
  expect(line).toContain("kind=telegram");
725
835
  expect(line).toContain("/api/telegram-reader/gate-probe");
726
836
  });
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"]);
837
+ it("the advance writes the watermark to what the gate's probe read", async () => {
838
+ const gate = await run([CHECK], quietSources(at("2026-08-05T11:00:00.000Z")));
839
+ expect(advanceGateMark(EVENT, platformRoot, gate.observed).advanced).toEqual(["telegram"]);
734
840
  const marks = readWatermarks(EVENT, platformRoot);
735
841
  expect(marks.telegram).toEqual({ inbound: "2026-08-05T11:00:00.000Z" });
736
842
  });
@@ -770,21 +876,18 @@ describe("telegram check", () => {
770
876
  // nobody has read. That is the mute direction, and it loses a booking.
771
877
  seedWatermarks({ telegram: { inbound: "2026-08-05T18:00:00.000Z" } });
772
878
  const check = { type: "telegram", scope: "inbound", botId: "111" };
773
- expect(await run([check], quietSources(at("2026-08-05T12:00:00.000Z")))).toEqual({
879
+ expect(await run([check], quietSources(at("2026-08-05T12:00:00.000Z")))).toMatchObject({
774
880
  hasWork: true,
775
881
  reasons: ["telegram"],
776
882
  errored: [],
777
883
  });
778
884
  });
779
- it("a scoped commit writes under the bot, leaving the account-wide mark alone", async () => {
885
+ it("a scoped advance writes under the bot, leaving the account-wide mark alone", async () => {
780
886
  seedWatermarks({ telegram: { inbound: "2026-08-05T18:00:00.000Z" } });
781
887
  const check = { type: "telegram", scope: "inbound", botId: "111" };
782
888
  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"]);
889
+ const gate = await run([check], sources);
890
+ expect(advanceGateMark(EVENT, platformRoot, gate.observed).advanced).toEqual(["telegram:111"]);
788
891
  const marks = readWatermarks(EVENT, platformRoot);
789
892
  expect(marks.telegram?.inbound).toBe("2026-08-05T18:00:00.000Z");
790
893
  expect(marks.telegram?.byBot).toEqual({ "111": "2026-08-05T12:00:00.000Z" });
@@ -828,14 +931,15 @@ describe("telegram check", () => {
828
931
  expect(line).toContain("kind=telegram");
829
932
  expect(line).toContain("0000000000");
830
933
  });
831
- it("a commit on a quiet account is skipped, not advanced onto nothing", async () => {
934
+ it("a quiet account records no position, so nothing advances onto nothing", async () => {
832
935
  // 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.
936
+ // no position to record, so the check contributes no key and the stored mark
937
+ // is left alone. Writing null would drop the mark and re-report every stored
938
+ // message on the next fire.
836
939
  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"] });
940
+ const gate = await run([CHECK], quietSources());
941
+ expect(gate).toMatchObject({ hasWork: false, observed: {} });
942
+ expect(advanceGateMark(EVENT, platformRoot, gate.observed).advanced).toEqual([]);
839
943
  expect(readWatermarks(EVENT, platformRoot).telegram).toEqual({
840
944
  inbound: "2026-08-05T10:00:00.000Z",
841
945
  });
@@ -849,7 +953,7 @@ describe("telegram check", () => {
849
953
  describe("unrecognised check type", () => {
850
954
  const CHECK = { type: "nonsense" };
851
955
  it("resolves rather than throwing, and counts the unknown type as work", async () => {
852
- expect(await run([CHECK], quietSources())).toEqual({
956
+ expect(await run([CHECK], quietSources())).toMatchObject({
853
957
  hasWork: true,
854
958
  reasons: ["nonsense"],
855
959
  errored: [
@@ -873,7 +977,7 @@ describe("unrecognised check type", () => {
873
977
  });
874
978
  it("still runs the sibling checks — one bad type does not abort the gate", async () => {
875
979
  const checks = [CHECK, { type: "d1", database: "b", sql: "SELECT 1" }];
876
- expect(await run(checks, quietSources())).toEqual({
980
+ expect(await run(checks, quietSources())).toMatchObject({
877
981
  hasWork: true,
878
982
  reasons: ["nonsense"],
879
983
  errored: [
@@ -938,8 +1042,8 @@ describe("errored", () => {
938
1042
  { kind: "calendar", detail: "calendar down" },
939
1043
  ]);
940
1044
  });
941
- it("is present on a commit too", async () => {
942
- const res = await run([D1], quietSources(), true);
1045
+ it("is present and empty when every check answered", async () => {
1046
+ const res = await run([D1], quietSources());
943
1047
  expect(res.errored).toEqual([]);
944
1048
  });
945
1049
  });
@@ -959,23 +1063,16 @@ describe("mail check — outlook provider", () => {
959
1063
  const sources = quietSources({
960
1064
  mailOutlook: async () => ({ newestReceivedAt: "2026-08-10T09:00:00Z" }),
961
1065
  });
962
- expect(await run([CHECK], sources)).toEqual({ hasWork: false, reasons: [], errored: [] });
1066
+ expect(await run([CHECK], sources)).toMatchObject({ hasWork: false, reasons: [], errored: [] });
963
1067
  });
964
- it("is true on a message above the watermark, and advances that key on commit", async () => {
1068
+ it("is true on a message above the watermark, and the advance writes that key", async () => {
965
1069
  seedWatermarks({ mailOutlook: { [KEY]: { receivedAt: "2026-08-10T09:00:00Z" } } });
966
1070
  const sources = quietSources({
967
1071
  mailOutlook: async () => ({ newestReceivedAt: "2026-08-10T09:05:00Z" }),
968
1072
  });
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
- });
1073
+ const gate = await run([CHECK], sources);
1074
+ expect(gate).toMatchObject({ hasWork: true, reasons: ["mail"], errored: [] });
1075
+ expect(advanceGateMark(EVENT, platformRoot, gate.observed).advanced).toEqual([KEY]);
979
1076
  const written = JSON.parse(readFileSync(watermarkPath(EVENT, platformRoot), "utf-8"));
980
1077
  expect(written.mailOutlook[KEY]).toEqual({ receivedAt: "2026-08-10T09:05:00Z" });
981
1078
  });
@@ -983,7 +1080,7 @@ describe("mail check — outlook provider", () => {
983
1080
  const sources = quietSources({
984
1081
  mailOutlook: async () => ({ newestReceivedAt: "2026-08-10T09:00:00Z" }),
985
1082
  });
986
- expect(await run([CHECK], sources)).toEqual({ hasWork: true, reasons: ["mail"], errored: [] });
1083
+ expect(await run([CHECK], sources)).toMatchObject({ hasWork: true, reasons: ["mail"], errored: [] });
987
1084
  });
988
1085
  it("orders by instant, not lexically, so a fractional second is not read as older", async () => {
989
1086
  seedWatermarks({ mailOutlook: { [KEY]: { receivedAt: "2026-08-10T09:00:00Z" } } });
@@ -991,17 +1088,13 @@ describe("mail check — outlook provider", () => {
991
1088
  // Lexically "…00.500Z" sorts BELOW "…00Z" ('.' is 0x2E, 'Z' is 0x5A).
992
1089
  mailOutlook: async () => ({ newestReceivedAt: "2026-08-10T09:00:00.500Z" }),
993
1090
  });
994
- expect(await run([CHECK], sources)).toEqual({ hasWork: true, reasons: ["mail"], errored: [] });
1091
+ expect(await run([CHECK], sources)).toMatchObject({ hasWork: true, reasons: ["mail"], errored: [] });
995
1092
  });
996
- it("reports an empty folder as skipped on a commit rather than advancing onto nothing", async () => {
1093
+ it("records no position for an empty folder rather than advancing onto nothing", async () => {
997
1094
  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
- });
1095
+ const gate = await run([CHECK], sources);
1096
+ expect(gate).toEqual({ hasWork: false, reasons: [], errored: [], observed: {} });
1097
+ expect(advanceGateMark(EVENT, platformRoot, gate.observed).advanced).toEqual([]);
1005
1098
  });
1006
1099
  it("keys the folder segment lower-cased, so two spellings address one entry", async () => {
1007
1100
  seedWatermarks({ mailOutlook: { [KEY]: { receivedAt: "2026-08-10T09:00:00Z" } } });
@@ -1009,7 +1102,7 @@ describe("mail check — outlook provider", () => {
1009
1102
  const sources = quietSources({
1010
1103
  mailOutlook: async () => ({ newestReceivedAt: "2026-08-10T09:00:00Z" }),
1011
1104
  });
1012
- expect(await run([check], sources)).toEqual({ hasWork: false, reasons: [], errored: [] });
1105
+ expect(await run([check], sources)).toMatchObject({ hasWork: false, reasons: [], errored: [] });
1013
1106
  });
1014
1107
  it("counts a probe that throws as work, and never as quiet", async () => {
1015
1108
  const sources = quietSources({
@@ -1017,7 +1110,7 @@ describe("mail check — outlook provider", () => {
1017
1110
  throw new Error("No Outlook mailbox admin@muvin.co.uk is connected");
1018
1111
  },
1019
1112
  });
1020
- expect(await run([CHECK], sources)).toEqual({
1113
+ expect(await run([CHECK], sources)).toMatchObject({
1021
1114
  hasWork: true,
1022
1115
  reasons: ["mail"],
1023
1116
  errored: [{ kind: "mail", detail: "No Outlook mailbox admin@muvin.co.uk is connected" }],
@@ -1025,7 +1118,7 @@ describe("mail check — outlook provider", () => {
1025
1118
  });
1026
1119
  it("throws on a provider outside the accepted list", async () => {
1027
1120
  const check = { type: "mail", mailbox: "a@b.com", provider: "pop3" };
1028
- expect(await run([check], quietSources())).toEqual({
1121
+ expect(await run([check], quietSources())).toMatchObject({
1029
1122
  hasWork: true,
1030
1123
  reasons: ["mail"],
1031
1124
  errored: [{ kind: "mail", detail: 'unknown mail provider "pop3" — accepted: imap, outlook' }],
@@ -1036,9 +1129,9 @@ describe("mail check — outlook provider", () => {
1036
1129
  const sources = quietSources({
1037
1130
  mailOutlook: async () => ({ newestReceivedAt: "2026-08-10T09:00:00Z" }),
1038
1131
  });
1039
- // Task 2654 — the gate call records the position the commit promotes.
1040
- await run([CHECK], sources);
1041
- await run([CHECK], sources, true);
1132
+ // Task 2663 — the gate call records the position the advance promotes.
1133
+ const gate = await run([CHECK], sources);
1134
+ advanceGateMark(EVENT, platformRoot, gate.observed);
1042
1135
  const written = JSON.parse(readFileSync(watermarkPath(EVENT, platformRoot), "utf-8"));
1043
1136
  expect(written.mail[KEY]).toEqual({ uid: 42, uidValidity: 7 });
1044
1137
  expect(written.mailOutlook[KEY]).toEqual({ receivedAt: "2026-08-10T09:00:00Z" });
@@ -1056,18 +1149,21 @@ describe("mail check — outlook provider", () => {
1056
1149
  });
1057
1150
  });
1058
1151
  // ---------------------------------------------------------------------------
1059
- // Task 2654 — a source that moves between the gate call and the commit.
1152
+ // Task 2654, kept under Task 2663 — a source that moves between the gate call
1153
+ // and the advance.
1060
1154
  //
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.
1155
+ // The advance happens after the wake was accepted, by which time the source may
1156
+ // have moved again. Writing what the source holds THEN would put the arrival
1157
+ // below the mark and no later fire would report it. The dispatcher writes what
1158
+ // the GATE returned instead, so the mover stays above. Under 2654 that guarantee
1159
+ // depended on a sidecar file being fresh; it is now structural, because the
1160
+ // positions travel with the result.
1065
1161
  //
1066
- // Each check is driven alone, and each test runs the real sequence: gate,
1067
- // source moves, commit, gate again.
1162
+ // Each check is driven alone, and each test runs the real sequence: gate, source
1163
+ // moves, advance, gate again.
1068
1164
  // ---------------------------------------------------------------------------
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 () => {
1165
+ describe("a source that moves between the gate call and the advance", () => {
1166
+ it("mail: a UID that arrives mid-turn is still reported after the advance", async () => {
1071
1167
  let highest = 105;
1072
1168
  const sources = quietSources({
1073
1169
  mail: async (_c, sinceUid) => ({
@@ -1077,14 +1173,15 @@ describe("a source that moves between the gate call and the commit", () => {
1077
1173
  }),
1078
1174
  });
1079
1175
  const checks = [{ type: "mail", mailbox: "ops@example.com" }];
1080
- expect(await run(checks, sources)).toEqual({ hasWork: true, reasons: ["mail"], errored: [] });
1176
+ const gate = await run(checks, sources);
1177
+ expect(gate).toMatchObject({ hasWork: true, reasons: ["mail"], errored: [] });
1081
1178
  highest = 106; // arrives while the turn runs
1082
- await run(checks, sources, true);
1179
+ advanceGateMark(EVENT, platformRoot, gate.observed);
1083
1180
  const marks = JSON.parse(readFileSync(watermarkPath(EVENT, platformRoot), "utf-8"));
1084
1181
  expect(marks.mail["ops@example.com|inbox"].uid).toBe(105);
1085
- expect(await run(checks, sources)).toEqual({ hasWork: true, reasons: ["mail"], errored: [] });
1182
+ expect(await run(checks, sources)).toMatchObject({ hasWork: true, reasons: ["mail"] });
1086
1183
  });
1087
- it("whatsapp: an inbound message mid-turn is still reported after the commit", async () => {
1184
+ it("whatsapp: an inbound message mid-turn is still reported after the advance", async () => {
1088
1185
  let latest = 1_754_000_100;
1089
1186
  const sources = quietSources({
1090
1187
  whatsappInbound: async (_a, since) => ({
@@ -1093,86 +1190,96 @@ describe("a source that moves between the gate call and the commit", () => {
1093
1190
  }),
1094
1191
  });
1095
1192
  const checks = [{ type: "whatsapp", scope: "inbound" }];
1096
- expect(await run(checks, sources)).toEqual({ hasWork: true, reasons: ["whatsapp"], errored: [] });
1193
+ const gate = await run(checks, sources);
1194
+ expect(gate).toMatchObject({ hasWork: true, reasons: ["whatsapp"], errored: [] });
1097
1195
  latest = 1_754_000_200;
1098
- await run(checks, sources, true);
1196
+ advanceGateMark(EVENT, platformRoot, gate.observed);
1099
1197
  const marks = JSON.parse(readFileSync(watermarkPath(EVENT, platformRoot), "utf-8"));
1100
1198
  expect(marks.whatsapp.inbound).toBe(1_754_000_100);
1101
- expect(await run(checks, sources)).toEqual({ hasWork: true, reasons: ["whatsapp"], errored: [] });
1199
+ expect(await run(checks, sources)).toMatchObject({ hasWork: true, reasons: ["whatsapp"] });
1102
1200
  });
1103
- it("telegram: an inbound message mid-turn is still reported after the commit", async () => {
1201
+ it("telegram: an inbound message mid-turn is still reported after the advance", async () => {
1104
1202
  let newest = "2026-08-10T10:00:00.000Z";
1105
1203
  const sources = quietSources({ telegramInbound: async () => ({ newestInboundAt: newest }) });
1106
1204
  const checks = [{ type: "telegram", scope: "inbound" }];
1107
- expect(await run(checks, sources)).toEqual({ hasWork: true, reasons: ["telegram"], errored: [] });
1205
+ const gate = await run(checks, sources);
1206
+ expect(gate).toMatchObject({ hasWork: true, reasons: ["telegram"], errored: [] });
1108
1207
  newest = "2026-08-10T10:30:00.000Z";
1109
- await run(checks, sources, true);
1208
+ advanceGateMark(EVENT, platformRoot, gate.observed);
1110
1209
  const marks = JSON.parse(readFileSync(watermarkPath(EVENT, platformRoot), "utf-8"));
1111
1210
  expect(marks.telegram.inbound).toBe("2026-08-10T10:00:00.000Z");
1112
- expect(await run(checks, sources)).toEqual({ hasWork: true, reasons: ["telegram"], errored: [] });
1211
+ expect(await run(checks, sources)).toMatchObject({ hasWork: true, reasons: ["telegram"] });
1113
1212
  });
1114
- it("calendar: a connector change mid-turn is still reported after the commit", async () => {
1213
+ it("calendar: a connector change mid-turn is still reported after the advance", async () => {
1115
1214
  let digest = "DIGEST-A";
1116
1215
  const sources = quietSources({ calendarDigest: async () => digest });
1117
1216
  const checks = [{ type: "calendar", connector: "google" }];
1118
- expect(await run(checks, sources)).toEqual({ hasWork: true, reasons: ["calendar"], errored: [] });
1217
+ const gate = await run(checks, sources);
1218
+ expect(gate).toMatchObject({ hasWork: true, reasons: ["calendar"], errored: [] });
1119
1219
  digest = "DIGEST-B";
1120
- await run(checks, sources, true);
1220
+ advanceGateMark(EVENT, platformRoot, gate.observed);
1121
1221
  const marks = JSON.parse(readFileSync(watermarkPath(EVENT, platformRoot), "utf-8"));
1122
1222
  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", () => {
1223
+ expect(await run(checks, sources)).toMatchObject({ hasWork: true, reasons: ["calendar"] });
1224
+ });
1225
+ // Task 2663 — the no-event case. The gate that could never go quiet emitted a
1226
+ // line on every fire that looked exactly like a healthy one, because it named
1227
+ // only what the probe had just read and never the value it was compared
1228
+ // against. Both sides now print, so a mark stuck on an empty slot is legible
1229
+ // from one line without reproducing anything.
1230
+ describe("gate-read names both sides of the comparison", () => {
1129
1231
  // `console.error` is already spied in `beforeEach`, so its recorded calls are
1130
1232
  // the log. Re-mocking it here would drop the lines written before this point.
1131
1233
  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 () => {
1234
+ it("names null stored on a first fire, and the observed position beside it", async () => {
1235
+ const sources = quietSources({ calendarDigest: async () => "abcdef0123456789" });
1236
+ await run([{ type: "calendar", connector: "google" }], sources);
1237
+ expect(errorLines()).toContainEqual(expect.stringContaining("op=gate-read eventId=65ef758c check=calendar stored=null observed=abcdef01"));
1238
+ });
1239
+ it("names the stored position once the mark has advanced", async () => {
1133
1240
  const sources = quietSources({ calendarDigest: async () => "abcdef0123456789" });
1134
1241
  const checks = [{ type: "calendar", connector: "google" }];
1242
+ const gate = await run(checks, sources);
1243
+ advanceGateMark(EVENT, platformRoot, gate.observed);
1135
1244
  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"));
1139
- });
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" });
1145
- const checks = [{ type: "calendar", connector: "google" }];
1146
- 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([]);
1245
+ expect(errorLines()).toContainEqual(expect.stringContaining("op=gate-read eventId=65ef758c check=calendar stored=abcdef01 observed=abcdef01"));
1156
1246
  });
1157
1247
  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.
1248
+ // `op=check-error` already covers a check with no position; a line of two
1249
+ // nulls would only add a value nobody can compare.
1160
1250
  await run([{ type: "d1", database: "b", sql: "SELECT count(*) FROM b" }], quietSources());
1161
1251
  expect(errorLines().filter((l) => l.includes("op=gate-read"))).toEqual([]);
1162
1252
  });
1163
1253
  });
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: [] });
1254
+ });
1255
+ // Task 2663 the gate hands its readings back to the dispatcher and touches no
1256
+ // sidecar. The commit and the `<eventId>.observed.json` file it promoted from
1257
+ // are both gone: a sidecar survives a gate call that errored, so the stale
1258
+ // positions from an earlier fire would be promoted on the next accepted wake and
1259
+ // mark as seen messages nobody read.
1260
+ describe("observed positions (Task 2663)", () => {
1261
+ it("returns the positions it read, and writes no sidecar", async () => {
1262
+ const result = await run([{ type: "telegram", scope: "inbound", botId: "555" }], quietSources({ telegramInbound: async () => ({ newestInboundAt: "2026-08-10T18:00:00.000Z" }) }));
1263
+ expect(result.observed).toEqual({ telegram: { byBot: { "555": "2026-08-10T18:00:00.000Z" } } });
1264
+ expect(existsSync(join(platformRoot, "..", "state", "scheduling-gate", `${EVENT}.observed.json`))).toBe(false);
1265
+ });
1266
+ it("reports no position for a check that holds none", async () => {
1267
+ // `d1` reads a current value rather than a position in a stream, so there is
1268
+ // nothing for the dispatcher to promote and `observed` is empty rather than
1269
+ // carrying a fabricated key.
1270
+ const result = await run([{ type: "d1", database: "b", sql: "SELECT 1" }], quietSources({ d1: async () => 1 }));
1271
+ expect(result.observed).toEqual({});
1272
+ });
1273
+ it("reports no position for a check that threw", async () => {
1274
+ // A check that errored knows nothing about its source. Recording a position
1275
+ // for it would let the dispatcher advance past work the gate never saw.
1276
+ const result = await run([{ type: "telegram", scope: "inbound", botId: "555" }], quietSources({
1277
+ telegramInbound: async () => {
1278
+ throw new Error("fetch failed");
1279
+ },
1280
+ }));
1281
+ expect(result.hasWork).toBe(true);
1282
+ expect(result.observed).toEqual({});
1176
1283
  });
1177
1284
  });
1178
1285
  //# sourceMappingURL=scheduling-gate.test.js.map