aibroker 0.58.1 → 0.59.0

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 (49) hide show
  1. package/dist/adapters/iterm/core.d.ts +12 -1
  2. package/dist/adapters/iterm/core.d.ts.map +1 -1
  3. package/dist/adapters/iterm/core.js +32 -5
  4. package/dist/adapters/iterm/core.js.map +1 -1
  5. package/dist/adapters/iterm/sessions.d.ts.map +1 -1
  6. package/dist/adapters/iterm/sessions.js +36 -4
  7. package/dist/adapters/iterm/sessions.js.map +1 -1
  8. package/dist/core/state.d.ts +7 -1
  9. package/dist/core/state.d.ts.map +1 -1
  10. package/dist/core/state.js +13 -3
  11. package/dist/core/state.js.map +1 -1
  12. package/dist/daemon/core-handlers.d.ts +28 -0
  13. package/dist/daemon/core-handlers.d.ts.map +1 -1
  14. package/dist/daemon/core-handlers.js +150 -50
  15. package/dist/daemon/core-handlers.js.map +1 -1
  16. package/dist/daemon/dispatch.d.ts +39 -0
  17. package/dist/daemon/dispatch.d.ts.map +1 -1
  18. package/dist/daemon/dispatch.js +331 -2
  19. package/dist/daemon/dispatch.js.map +1 -1
  20. package/dist/daemon/index.d.ts.map +1 -1
  21. package/dist/daemon/index.js +10 -0
  22. package/dist/daemon/index.js.map +1 -1
  23. package/dist/daemon/manage.d.ts +37 -0
  24. package/dist/daemon/manage.d.ts.map +1 -1
  25. package/dist/daemon/manage.js +157 -12
  26. package/dist/daemon/manage.js.map +1 -1
  27. package/dist/daemon/todoist-pending.d.ts +45 -0
  28. package/dist/daemon/todoist-pending.d.ts.map +1 -0
  29. package/dist/daemon/todoist-pending.js +58 -0
  30. package/dist/daemon/todoist-pending.js.map +1 -0
  31. package/dist/daemon/todoist-reply.d.ts +10 -0
  32. package/dist/daemon/todoist-reply.d.ts.map +1 -1
  33. package/dist/daemon/todoist-reply.js +1 -0
  34. package/dist/daemon/todoist-reply.js.map +1 -1
  35. package/dist/daemon/todoist-webhook.d.ts +29 -0
  36. package/dist/daemon/todoist-webhook.d.ts.map +1 -1
  37. package/dist/daemon/todoist-webhook.js +331 -208
  38. package/dist/daemon/todoist-webhook.js.map +1 -1
  39. package/dist/ipc/client.d.ts.map +1 -1
  40. package/dist/ipc/client.js +24 -3
  41. package/dist/ipc/client.js.map +1 -1
  42. package/dist/mcp/index.js +19 -2
  43. package/dist/mcp/index.js.map +1 -1
  44. package/dist/transport/sync-facade.d.ts +15 -2
  45. package/dist/transport/sync-facade.d.ts.map +1 -1
  46. package/dist/transport/sync-facade.js +16 -3
  47. package/dist/transport/sync-facade.js.map +1 -1
  48. package/hooks/drain-mailbox.mjs +34 -8
  49. package/package.json +1 -1
@@ -211,6 +211,34 @@ export interface WebhookDeps {
211
211
  */
212
212
  knownOwners?: () => string[] | Promise<string[]>;
213
213
  }
214
+ /**
215
+ * Transient (network blip, timeout, 5xx, 429) vs permanent (404, 401/403 —
216
+ * the task or the grant is actually gone). `fetchParentTask` encodes an HTTP
217
+ * status in its message for anything that reached Todoist; anything without
218
+ * one — "fetch failed", a timeout, DNS — never got an answer at all, so it
219
+ * cannot mean "no such task" and is treated as transient.
220
+ */
221
+ export declare function isTransientTodoistError(err: unknown): boolean;
222
+ type ResolveOutcome = {
223
+ ok: true;
224
+ } | {
225
+ ok: false;
226
+ transient: boolean;
227
+ reason: string;
228
+ };
229
+ /**
230
+ * The inline attempts a live request gets before its event is handed to the
231
+ * disk queue. A permanent error (or the last attempt) returns immediately;
232
+ * `sleep` is injected so tests do not wait a minute for it.
233
+ */
234
+ export declare function resolveParentWithRetry(event: TodoistEvent, kind: "reminder" | "comment", parentId: string, delaysMs?: number[], sleep?: (ms: number) => Promise<void>, fetchImpl?: typeof fetch): Promise<ResolveOutcome>;
235
+ /**
236
+ * Drain the disk queue: one resolve attempt per pending event, no inline
237
+ * backoff — the interval between sweeps already spaces attempts out. Runs
238
+ * once shortly after the listener starts (covers a daemon restart) and again
239
+ * on every tick after that (covers an outage longer than the inline retries).
240
+ */
241
+ export declare function sweepPendingEvents(cfg: WebhookConfig, deps: WebhookDeps, fetchImpl?: typeof fetch): Promise<void>;
214
242
  /**
215
243
  * `cfg` is null when Todoist itself is not configured (or was refused —
216
244
  * see the empty-ingress case in `startTodoistWebhook`). The listener still
@@ -239,4 +267,5 @@ export declare function todoistConfigured(): boolean;
239
267
  export declare function sharedListenerShouldStart(exposureFile?: string): boolean;
240
268
  /** Start the shared listener if either Todoist or A2A is configured. No-op otherwise. */
241
269
  export declare function startTodoistWebhook(deps: WebhookDeps): Server | null;
270
+ export {};
242
271
  //# sourceMappingURL=todoist-webhook.d.ts.map
@@ -1 +1 @@
1
- {"version":3,"file":"todoist-webhook.d.ts","sourceRoot":"","sources":["../../src/daemon/todoist-webhook.ts"],"names":[],"mappings":"AAAA;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;GAiCG;AAEH,OAAO,EAA2D,KAAK,MAAM,EAAE,MAAM,WAAW,CAAC;AA+DjG,qFAAqF;AACrF,eAAO,MAAM,UAAU,iBAAO,CAAC;AAE/B;;;;;;;GAOG;AACH,eAAO,MAAM,aAAa,gBAAgB,CAAC;AAE3C;;;;;;;;GAQG;AACH,wBAAgB,SAAS,CAAC,IAAI,EAAE,MAAM,CAAC,MAAM,EAAE,OAAO,CAAC,GAAG,OAAO,CAIhE;AAED;;;;;;;;;;;GAWG;AACH,wBAAgB,qBAAqB,CACnC,CAAC,EAAE,YAAY,EACf,GAAG,EAAE,aAAa,GACjB;IAAE,cAAc,EAAE,OAAO,CAAC;IAAC,MAAM,EAAE,MAAM,CAAA;CAAE,CAmC7C;AAED,MAAM,WAAW,YAAY;IAC3B,UAAU,EAAE,MAAM,CAAC;IACnB,OAAO,CAAC,EAAE,MAAM,CAAC;IACjB,UAAU,CAAC,EAAE,MAAM,CAAC,MAAM,EAAE,OAAO,CAAC,CAAC;IACrC,gBAAgB,CAAC,EAAE,MAAM,CAAC,MAAM,EAAE,OAAO,CAAC,CAAC;IAC3C,SAAS,CAAC,EAAE;QAAE,KAAK,CAAC,EAAE,MAAM,CAAC;QAAC,SAAS,CAAC,EAAE,MAAM,CAAC;QAAC,EAAE,CAAC,EAAE,MAAM,CAAA;KAAE,CAAC;IAChE,YAAY,CAAC,EAAE,MAAM,CAAC;IACtB,OAAO,CAAC,EAAE,MAAM,CAAC;CAClB;AAED,MAAM,WAAW,aAAa;IAC5B,2EAA2E;IAC3E,MAAM,EAAE,MAAM,CAAC;IACf,IAAI,EAAE,MAAM,CAAC;IACb;;;;;;;;;OASG;IACH,IAAI,EAAE,MAAM,CAAC;IACb,wDAAwD;IACxD,IAAI,EAAE,MAAM,CAAC;IACb;;;;;OAKG;IACH,QAAQ,CAAC,EAAE,MAAM,CAAC;IAClB;;;;;;OAMG;IACH,SAAS,EAAE,MAAM,CAAC;IAClB;;;;OAIG;IACH,iBAAiB,EAAE,GAAG,CAAC,MAAM,CAAC,CAAC;IAC/B,mFAAmF;IACnF,aAAa,EAAE,GAAG,CAAC,MAAM,EAAE,MAAM,CAAC,CAAC;IACnC;;;;OAIG;IACH,YAAY,CAAC,EAAE,MAAM,CAAC;CACvB;AAED,yEAAyE;AACzE,MAAM,MAAM,SAAS,GAAG,OAAO,GAAG,SAAS,GAAG,MAAM,GAAG,SAAS,GAAG,YAAY,GAAG,SAAS,CAAC;AAE5F,MAAM,MAAM,aAAa,GACrB;IAAE,GAAG,EAAE,IAAI,CAAC;IAAC,OAAO,EAAE,MAAM,CAAC;IAAC,IAAI,EAAE,MAAM,CAAC;IAAC,MAAM,EAAE,MAAM,CAAC;IAAC,IAAI,EAAE,SAAS,CAAC;IAAC,QAAQ,CAAC,EAAE,MAAM,CAAA;CAAE,GAChG;IAAE,GAAG,EAAE,KAAK,CAAC;IAAC,MAAM,EAAE,MAAM,CAAC;IAAC,QAAQ,CAAC,EAAE,MAAM,CAAA;CAAE,CAAC;AAgDtD;;;;;;;;;GASG;AACH,wBAAgB,YAAY,CAC1B,OAAO,EAAE,MAAM,EACf,KAAK,EAAE,GAAG,CAAC,MAAM,CAAC,GACjB;IAAE,KAAK,CAAC,EAAE,MAAM,CAAC;IAAC,IAAI,EAAE,MAAM,CAAC;IAAC,UAAU,EAAE,MAAM,GAAG,IAAI,CAAC;IAAC,SAAS,EAAE,OAAO,CAAA;CAAE,CAOjF;AAED;;;;;GAKG;AACH,wBAAgB,eAAe,CAAC,GAAG,EAAE,MAAM,EAAE,MAAM,EAAE,MAAM,GAAG,SAAS,EAAE,MAAM,EAAE,MAAM,GAAG,OAAO,CAOhG;AAED,sEAAsE;AACtE,wBAAgB,QAAQ,CAAC,CAAC,EAAE,YAAY,GAAG,MAAM,CAGhD;AAED;;;;;GAKG;AACH,wBAAgB,KAAK,CACnB,CAAC,EAAE,YAAY,EACf,GAAG,EAAE,aAAa,EAClB,WAAW,GAAE,QAAQ,CAAC,MAAM,CAAM;AAClC;;;;;GAKG;AACH,MAAM,CAAC,EAAE,MAAM,GACd,aAAa,CAqKf;AAmBD,MAAM,WAAW,WAAW;IAC1B;;;;;;OAMG;IACH,OAAO,EAAE,CAAC,OAAO,EAAE,MAAM,EAAE,IAAI,EAAE,MAAM,EAAE,IAAI,CAAC,EAAE;QAAE,MAAM,CAAC,EAAE,MAAM,CAAA;KAAE,KACjE,OAAO,CAAC;QAAE,OAAO,EAAE,MAAM,CAAC;QAAC,OAAO,EAAE,MAAM,CAAC;QAAC,MAAM,CAAC,EAAE,MAAM,CAAA;KAAE,CAAC,CAAC;IACjE;;;;;;;;;;OAUG;IACH,WAAW,CAAC,EAAE,MAAM,MAAM,EAAE,GAAG,OAAO,CAAC,MAAM,EAAE,CAAC,CAAC;CAClD;AA6GD;;;;;;;;GAQG;AACH,wBAAgB,mBAAmB,CAAC,GAAG,EAAE,aAAa,GAAG,IAAI,EAAE,IAAI,EAAE,WAAW,GAAG,MAAM,CAqWxF;AAED,0EAA0E;AAC1E,wBAAgB,oBAAoB,IAAI,aAAa,GAAG,IAAI,CAyB3D;AAED,qFAAqF;AACrF,wBAAgB,iBAAiB,IAAI,OAAO,CAE3C;AAED;;;;;;;;;;GAUG;AACH,wBAAgB,yBAAyB,CAAC,YAAY,CAAC,EAAE,MAAM,GAAG,OAAO,CAExE;AAED,yFAAyF;AACzF,wBAAgB,mBAAmB,CAAC,IAAI,EAAE,WAAW,GAAG,MAAM,GAAG,IAAI,CA+CpE"}
1
+ {"version":3,"file":"todoist-webhook.d.ts","sourceRoot":"","sources":["../../src/daemon/todoist-webhook.ts"],"names":[],"mappings":"AAAA;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;GAiCG;AAEH,OAAO,EAA2D,KAAK,MAAM,EAAE,MAAM,WAAW,CAAC;AAmEjG,qFAAqF;AACrF,eAAO,MAAM,UAAU,iBAAO,CAAC;AAE/B;;;;;;;GAOG;AACH,eAAO,MAAM,aAAa,gBAAgB,CAAC;AAE3C;;;;;;;;GAQG;AACH,wBAAgB,SAAS,CAAC,IAAI,EAAE,MAAM,CAAC,MAAM,EAAE,OAAO,CAAC,GAAG,OAAO,CAIhE;AAED;;;;;;;;;;;GAWG;AACH,wBAAgB,qBAAqB,CACnC,CAAC,EAAE,YAAY,EACf,GAAG,EAAE,aAAa,GACjB;IAAE,cAAc,EAAE,OAAO,CAAC;IAAC,MAAM,EAAE,MAAM,CAAA;CAAE,CAmC7C;AAED,MAAM,WAAW,YAAY;IAC3B,UAAU,EAAE,MAAM,CAAC;IACnB,OAAO,CAAC,EAAE,MAAM,CAAC;IACjB,UAAU,CAAC,EAAE,MAAM,CAAC,MAAM,EAAE,OAAO,CAAC,CAAC;IACrC,gBAAgB,CAAC,EAAE,MAAM,CAAC,MAAM,EAAE,OAAO,CAAC,CAAC;IAC3C,SAAS,CAAC,EAAE;QAAE,KAAK,CAAC,EAAE,MAAM,CAAC;QAAC,SAAS,CAAC,EAAE,MAAM,CAAC;QAAC,EAAE,CAAC,EAAE,MAAM,CAAA;KAAE,CAAC;IAChE,YAAY,CAAC,EAAE,MAAM,CAAC;IACtB,OAAO,CAAC,EAAE,MAAM,CAAC;CAClB;AAED,MAAM,WAAW,aAAa;IAC5B,2EAA2E;IAC3E,MAAM,EAAE,MAAM,CAAC;IACf,IAAI,EAAE,MAAM,CAAC;IACb;;;;;;;;;OASG;IACH,IAAI,EAAE,MAAM,CAAC;IACb,wDAAwD;IACxD,IAAI,EAAE,MAAM,CAAC;IACb;;;;;OAKG;IACH,QAAQ,CAAC,EAAE,MAAM,CAAC;IAClB;;;;;;OAMG;IACH,SAAS,EAAE,MAAM,CAAC;IAClB;;;;OAIG;IACH,iBAAiB,EAAE,GAAG,CAAC,MAAM,CAAC,CAAC;IAC/B,mFAAmF;IACnF,aAAa,EAAE,GAAG,CAAC,MAAM,EAAE,MAAM,CAAC,CAAC;IACnC;;;;OAIG;IACH,YAAY,CAAC,EAAE,MAAM,CAAC;CACvB;AAED,yEAAyE;AACzE,MAAM,MAAM,SAAS,GAAG,OAAO,GAAG,SAAS,GAAG,MAAM,GAAG,SAAS,GAAG,YAAY,GAAG,SAAS,CAAC;AAE5F,MAAM,MAAM,aAAa,GACrB;IAAE,GAAG,EAAE,IAAI,CAAC;IAAC,OAAO,EAAE,MAAM,CAAC;IAAC,IAAI,EAAE,MAAM,CAAC;IAAC,MAAM,EAAE,MAAM,CAAC;IAAC,IAAI,EAAE,SAAS,CAAC;IAAC,QAAQ,CAAC,EAAE,MAAM,CAAA;CAAE,GAChG;IAAE,GAAG,EAAE,KAAK,CAAC;IAAC,MAAM,EAAE,MAAM,CAAC;IAAC,QAAQ,CAAC,EAAE,MAAM,CAAA;CAAE,CAAC;AAgDtD;;;;;;;;;GASG;AACH,wBAAgB,YAAY,CAC1B,OAAO,EAAE,MAAM,EACf,KAAK,EAAE,GAAG,CAAC,MAAM,CAAC,GACjB;IAAE,KAAK,CAAC,EAAE,MAAM,CAAC;IAAC,IAAI,EAAE,MAAM,CAAC;IAAC,UAAU,EAAE,MAAM,GAAG,IAAI,CAAC;IAAC,SAAS,EAAE,OAAO,CAAA;CAAE,CAOjF;AAED;;;;;GAKG;AACH,wBAAgB,eAAe,CAAC,GAAG,EAAE,MAAM,EAAE,MAAM,EAAE,MAAM,GAAG,SAAS,EAAE,MAAM,EAAE,MAAM,GAAG,OAAO,CAOhG;AAED,sEAAsE;AACtE,wBAAgB,QAAQ,CAAC,CAAC,EAAE,YAAY,GAAG,MAAM,CAGhD;AAED;;;;;GAKG;AACH,wBAAgB,KAAK,CACnB,CAAC,EAAE,YAAY,EACf,GAAG,EAAE,aAAa,EAClB,WAAW,GAAE,QAAQ,CAAC,MAAM,CAAM;AAClC;;;;;GAKG;AACH,MAAM,CAAC,EAAE,MAAM,GACd,aAAa,CAqKf;AAmBD,MAAM,WAAW,WAAW;IAC1B;;;;;;OAMG;IACH,OAAO,EAAE,CAAC,OAAO,EAAE,MAAM,EAAE,IAAI,EAAE,MAAM,EAAE,IAAI,CAAC,EAAE;QAAE,MAAM,CAAC,EAAE,MAAM,CAAA;KAAE,KACjE,OAAO,CAAC;QAAE,OAAO,EAAE,MAAM,CAAC;QAAC,OAAO,EAAE,MAAM,CAAC;QAAC,MAAM,CAAC,EAAE,MAAM,CAAA;KAAE,CAAC,CAAC;IACjE;;;;;;;;;;OAUG;IACH,WAAW,CAAC,EAAE,MAAM,MAAM,EAAE,GAAG,OAAO,CAAC,MAAM,EAAE,CAAC,CAAC;CAClD;AA6GD;;;;;;GAMG;AACH,wBAAgB,uBAAuB,CAAC,GAAG,EAAE,OAAO,GAAG,OAAO,CAM7D;AAED,KAAK,cAAc,GAAG;IAAE,EAAE,EAAE,IAAI,CAAA;CAAE,GAAG;IAAE,EAAE,EAAE,KAAK,CAAC;IAAC,SAAS,EAAE,OAAO,CAAC;IAAC,MAAM,EAAE,MAAM,CAAA;CAAE,CAAC;AAqCvF;;;;GAIG;AACH,wBAAsB,sBAAsB,CAC1C,KAAK,EAAE,YAAY,EAAE,IAAI,EAAE,UAAU,GAAG,SAAS,EAAE,QAAQ,EAAE,MAAM,EACnE,QAAQ,GAAE,MAAM,EAA2B,EAC3C,KAAK,GAAE,CAAC,EAAE,EAAE,MAAM,KAAK,OAAO,CAAC,IAAI,CAAiD,EACpF,SAAS,GAAE,OAAO,KAAa,GAC9B,OAAO,CAAC,cAAc,CAAC,CAMzB;AAED;;;;;GAKG;AACH,wBAAsB,kBAAkB,CACtC,GAAG,EAAE,aAAa,EAAE,IAAI,EAAE,WAAW,EAAE,SAAS,GAAE,OAAO,KAAa,GACrE,OAAO,CAAC,IAAI,CAAC,CA4Bf;AAED;;;;;;;;GAQG;AACH,wBAAgB,mBAAmB,CAAC,GAAG,EAAE,aAAa,GAAG,IAAI,EAAE,IAAI,EAAE,WAAW,GAAG,MAAM,CAyJxF;AA8ND,0EAA0E;AAC1E,wBAAgB,oBAAoB,IAAI,aAAa,GAAG,IAAI,CAyB3D;AAED,qFAAqF;AACrF,wBAAgB,iBAAiB,IAAI,OAAO,CAE3C;AAED;;;;;;;;;;GAUG;AACH,wBAAgB,yBAAyB,CAAC,YAAY,CAAC,EAAE,MAAM,GAAG,OAAO,CAExE;AAED,yFAAyF;AACzF,wBAAgB,mBAAmB,CAAC,IAAI,EAAE,WAAW,GAAG,MAAM,GAAG,IAAI,CA6DpE"}
@@ -42,6 +42,7 @@ import { audit } from "./audit.js";
42
42
  import { handleOAuthCallback } from "./todoist-oauth.js";
43
43
  import { handleA2A, a2aConfigured } from "../a2a/server.js";
44
44
  import { funnelHostname } from "./funnel-watchdog.js";
45
+ import { queuePendingEvent, removePendingEvent, bumpPendingEvent, listPendingEvents, PENDING_MAX_AGE_MS, } from "./todoist-pending.js";
45
46
  const __a2a_dirname = dirname(fileURLToPath(import.meta.url));
46
47
  function a2aPackageVersion() {
47
48
  try {
@@ -565,6 +566,102 @@ async function handleInbound(name, req, res) {
565
566
  }
566
567
  /** Mirrors inbound.ts — kept local so this module has no load-time dependency on it. */
567
568
  const HOOK_PREFIX = "/hook/";
569
+ /**
570
+ * Transient (network blip, timeout, 5xx, 429) vs permanent (404, 401/403 —
571
+ * the task or the grant is actually gone). `fetchParentTask` encodes an HTTP
572
+ * status in its message for anything that reached Todoist; anything without
573
+ * one — "fetch failed", a timeout, DNS — never got an answer at all, so it
574
+ * cannot mean "no such task" and is treated as transient.
575
+ */
576
+ export function isTransientTodoistError(err) {
577
+ const msg = err instanceof Error ? err.message : String(err);
578
+ const status = /task lookup failed with (\d+)/.exec(msg)?.[1];
579
+ if (!status)
580
+ return true;
581
+ const code = Number(status);
582
+ return code === 429 || code >= 500;
583
+ }
584
+ /** One attempt at resolving the parent, mutating `event.event_data` in place on success. */
585
+ async function resolveParentOnce(event, kind, parentId, fetchImpl = fetch) {
586
+ try {
587
+ const { fetchParentTask } = await import("./todoist-reply.js");
588
+ const parent = await fetchParentTask(parentId, fetchImpl);
589
+ event.event_data = {
590
+ ...event.event_data,
591
+ id: parentId,
592
+ project_id: parent.projectId,
593
+ labels: parent.labels,
594
+ // A reminder has no text of its own, and routing needs some: the
595
+ // task IS the instruction here, so its title is the content. A
596
+ // comment brings its own text and keeps it — the title is only the
597
+ // context that text makes sense in.
598
+ //
599
+ // The reminder branch also carries `due`: a reminder on a recurring
600
+ // trigger must be claimable as one, and isTrigger() reads due.is_recurring
601
+ // off the merged event data.
602
+ ...(kind === "reminder"
603
+ ? { content: parent.content, due: parent.due }
604
+ : { description: parent.content ? `(comment on "${parent.content}")` : "" }),
605
+ };
606
+ return { ok: true };
607
+ }
608
+ catch (err) {
609
+ const reason = `could not resolve the task a ${kind} belongs to — ${err instanceof Error ? err.message : String(err)}`;
610
+ return { ok: false, transient: isTransientTodoistError(err), reason };
611
+ }
612
+ }
613
+ /** Spread over ~1 minute: enough to ride out a blocked event loop or a short outage. */
614
+ const INLINE_RETRY_DELAYS_MS = [5_000, 15_000, 40_000];
615
+ /**
616
+ * The inline attempts a live request gets before its event is handed to the
617
+ * disk queue. A permanent error (or the last attempt) returns immediately;
618
+ * `sleep` is injected so tests do not wait a minute for it.
619
+ */
620
+ export async function resolveParentWithRetry(event, kind, parentId, delaysMs = INLINE_RETRY_DELAYS_MS, sleep = (ms) => new Promise((r) => setTimeout(r, ms)), fetchImpl = fetch) {
621
+ for (let attempt = 0;; attempt++) {
622
+ const result = await resolveParentOnce(event, kind, parentId, fetchImpl);
623
+ if (result.ok || !result.transient || attempt >= delaysMs.length)
624
+ return result;
625
+ await sleep(delaysMs[attempt]);
626
+ }
627
+ }
628
+ /**
629
+ * Drain the disk queue: one resolve attempt per pending event, no inline
630
+ * backoff — the interval between sweeps already spaces attempts out. Runs
631
+ * once shortly after the listener starts (covers a daemon restart) and again
632
+ * on every tick after that (covers an outage longer than the inline retries).
633
+ */
634
+ export async function sweepPendingEvents(cfg, deps, fetchImpl = fetch) {
635
+ for (const p of listPendingEvents()) {
636
+ const ageMs = Date.now() - Date.parse(p.firstSeenAt);
637
+ if (ageMs > PENDING_MAX_AGE_MS) {
638
+ removePendingEvent(p.key);
639
+ audit({
640
+ action: "webhook", actor: "todoist", target: "aibroker", outcome: "ignored",
641
+ reason: `${p.lastError} — still unresolved after ${Math.round(ageMs / 3600_000)}h, giving up`,
642
+ meta: { event: p.event.event_name, taskId: p.parentId },
643
+ });
644
+ log(`todoist-webhook: giving up on a ${p.kind} for ${p.parentId}, unresolved after ${Math.round(ageMs / 3600_000)}h`);
645
+ continue;
646
+ }
647
+ const result = await resolveParentOnce(p.event, p.kind, p.parentId, fetchImpl);
648
+ if (result.ok) {
649
+ removePendingEvent(p.key);
650
+ await processResolvedEvent(p.event, cfg, deps);
651
+ }
652
+ else if (!result.transient) {
653
+ removePendingEvent(p.key);
654
+ audit({
655
+ action: "webhook", actor: "todoist", target: "aibroker", outcome: "ignored",
656
+ reason: result.reason, meta: { event: p.event.event_name, taskId: p.parentId },
657
+ });
658
+ log(`todoist-webhook: ${result.reason}`);
659
+ }
660
+ else {
661
+ bumpPendingEvent(p.key, result.reason);
662
+ }
663
+ }
664
+ }
568
665
  /**
569
666
  * `cfg` is null when Todoist itself is not configured (or was refused —
570
667
  * see the empty-ingress case in `startTodoistWebhook`). The listener still
@@ -696,221 +793,236 @@ export function createWebhookServer(cfg, deps) {
696
793
  });
697
794
  return;
698
795
  }
699
- try {
700
- const { fetchParentTask } = await import("./todoist-reply.js");
701
- const parent = await fetchParentTask(parentId);
702
- event.event_data = {
703
- ...event.event_data,
704
- id: parentId,
705
- project_id: parent.projectId,
706
- labels: parent.labels,
707
- // A reminder has no text of its own, and routing needs some: the
708
- // task IS the instruction here, so its title is the content. A
709
- // comment brings its own text and keeps it — the title is only the
710
- // context that text makes sense in.
711
- ...(isReminder
712
- ? { content: parent.content }
713
- : { description: parent.content ? `(comment on "${parent.content}")` : "" }),
714
- };
715
- }
716
- catch (err) {
717
- const reason = `could not resolve the task a ${kind} belongs to — ${err instanceof Error ? err.message : String(err)}`;
718
- audit({
719
- action: "webhook", actor: "todoist", target: "aibroker",
720
- outcome: "ignored", reason, meta: { event: event.event_name, taskId: parentId },
721
- });
722
- log(`todoist-webhook: ${reason}`);
723
- return;
724
- }
725
- }
726
- // A comment written on a MIRROR entry is meant for the real task.
727
- //
728
- // The mirror is where the reader's attention is, so replying there rather
729
- // than on the source is the natural mistake — and Todoist has no way to
730
- // deep-link an individual comment that would make the source easier to
731
- // reach. Carry it across instead of losing it in a project nothing reads.
732
- // Checked before routing: the mirror project is deliberately NOT an
733
- // ingress project, so routing would refuse this and say nothing useful.
734
- if (event.event_name === "note:added") {
735
- const { mirrorProjectId, mirrorBack } = await import("./todoist-mirror.js");
736
- const mirror = mirrorProjectId();
737
- if (mirror && String(event.event_data?.project_id ?? "") === mirror) {
738
- const r = await mirrorBack({
739
- taskId: String(event.event_data?.id ?? ""),
740
- text: String(event.event_data?.content ?? ""),
741
- projectId: mirror,
742
- });
743
- audit({
744
- action: "webhook", actor: "todoist", target: "aibroker",
745
- outcome: r.carried ? "carried-back" : "ignored",
746
- reason: r.reason ?? "comment on a mirror entry carried to its source task",
747
- });
748
- return;
749
- }
750
- }
751
- const isComment = event.event_name === "note:added";
752
- const parentId = String(event.event_data?.id ?? "");
753
- const { ownerOf, rememberOwner } = await import("./todoist-owners.js");
754
- // Grants made since the daemon started take effect now, not at the next
755
- // restart. A project created and granted while you are using the system
756
- // has to work immediately, or the grant is indistinguishable from a
757
- // project that routes nowhere.
758
- const { applyGrants, expandThroughSubtree } = await import("./todoist-ingress.js");
759
- let live = applyGrants(cfg);
760
- // A sub-project is a folder, not a second owner. Before this, organising
761
- // tasks into "Task Bus / Archive" moved them outside the
762
- // allowlist and every one was refused — silently, and precisely when
763
- // someone tidied up. Only ancestors granted WITH a subtree flag apply, so
764
- // nothing becomes an ingress that nobody granted.
765
- const eventProject = String(event.event_data?.project_id ?? "");
766
- if (eventProject && !live.ingressProjectIds.has(eventProject)) {
767
- try {
768
- const { projectTree, ancestorsOf } = await import("./todoist-projects.js");
769
- const tree = await projectTree();
770
- live = expandThroughSubtree(live, eventProject, ancestorsOf(eventProject, tree), {
771
- name: tree.get(eventProject)?.name,
772
- known: await (deps.knownOwners?.() ?? []),
773
- });
774
- }
775
- catch (err) {
776
- log(`todoist-webhook: subtree lookup failed — ${err instanceof Error ? err.message : String(err)}`);
777
- }
778
- }
779
- const decision = route(event, live, await (deps.knownOwners?.() ?? []), isComment ? ownerOf(parentId) : undefined);
780
- const who = event.initiator?.email ?? event.initiator?.full_name ?? "todoist";
781
- if (decision.nearMiss) {
782
- // Recorded whether or not the task was delivered: the point is that
783
- // something was asked for and not honoured.
784
- log(`todoist-webhook: near miss — ${decision.nearMiss}`);
785
- }
786
- // A completion has nothing to dispatch, but it is not nothing. The
787
- // comment thread on a ticked task leaves every list at that moment, so a
788
- // configured hook gets its chance before this is recorded as "ignored" —
789
- // and its exit code decides what the record says.
790
- let hook = { ran: false, ok: true };
791
- if (event.event_name === "item:completed") {
792
- const { runCompletedHook } = await import("./todoist-completed-hook.js");
793
- hook = await runCompletedHook(String(event.event_data?.id ?? ""));
794
- }
795
- if (!decision.act) {
796
- audit({
797
- action: "webhook", actor: `todoist:${who}`, target: "aibroker",
798
- outcome: hook.ran ? (hook.ok ? "archived" : "hook-failed") : "ignored",
799
- reason: hook.ran
800
- ? (hook.ok ? `${decision.reason} — completion hook ran` : `${decision.reason} — completion hook FAILED: ${hook.detail}`)
801
- : decision.reason,
802
- meta: {
803
- event: event.event_name,
804
- task: event.event_data?.content ?? undefined,
805
- nearMiss: decision.nearMiss,
806
- },
807
- });
808
- return;
809
- }
810
- // Claim a trigger before dispatching, and release it if the dispatch
811
- // does not land. Honouring another runner's claim is only half an
812
- // interlock: a path that dispatches and leaves the task unclaimed lets
813
- // the next poller see an advanced due date with nothing on it, conclude
814
- // the box was ticked, and run the same sweep again.
815
- const claiming = event.event_name === "item:completed" && isTrigger(event.event_data ?? {});
816
- if (claiming) {
817
- try {
818
- const { setTaskLabel } = await import("./todoist-reply.js");
819
- await setTaskLabel(decision.taskId, RUNNING_LABEL, true);
820
- audit({
821
- action: "todoist-claim", actor: "aibroker", target: `todoist:task:${decision.taskId}`,
822
- outcome: "claimed",
823
- });
824
- // Remember when, so a claim nobody comes back for can be released.
825
- const { recordClaim } = await import("./todoist-claims.js");
826
- recordClaim(decision.taskId, undefined,
827
- // Todoist has already advanced the due date by the time a completion
828
- // reaches us, so this is the NEXT occurrence — the point past which
829
- // a surviving claim would block the very trigger it guards.
830
- event.event_data?.due?.date);
831
- }
832
- catch (err) {
833
- // Audited, not just logged. A successful claim and a failed one used
834
- // to be indistinguishable from outside — the label was simply absent,
835
- // and the only record was a line in a log file nobody reads until
836
- // something has already gone wrong downstream. The dispatch still
837
- // proceeds: the claim is a hint, and refusing to run the sweep
838
- // because a label write failed would be the worse trade.
839
- const reason = err instanceof Error ? err.message : String(err);
840
- audit({
841
- action: "todoist-claim", actor: "aibroker", target: `todoist:task:${decision.taskId}`,
842
- outcome: "failed",
843
- reason: `${reason} — dispatching UNCLAIMED, a poller may read this tick as a fresh request`,
844
- });
845
- log(`todoist-webhook: could not claim ${decision.taskId} — ${reason}`);
846
- }
847
- }
848
- try {
849
- // The id rides along so the session can answer on the task it came
850
- // from. Without it the reply has nowhere to go but a terminal the
851
- // asker is not looking at.
852
- // Warn when the title is not unique in its project. Two tasks with the
853
- // same name are indistinguishable in a list, so an answer posted on one
854
- // looks — to whoever is watching the other — exactly like being ignored.
855
- // The session is told, so it can say which id it answered on.
856
- let twins = 0;
857
- if (!isComment && !claiming) {
858
- try {
859
- const { countTasksWithTitle } = await import("./todoist-reply.js");
860
- twins = await countTasksWithTitle(String(event.event_data?.project_id ?? ""), String(event.event_data?.content ?? ""));
796
+ const result = await resolveParentWithRetry(event, kind, parentId);
797
+ if (!result.ok) {
798
+ if (result.transient) {
799
+ // A daemon restart mid-retry must not lose this either, so it goes
800
+ // to disk, not just to another in-process timer.
801
+ queuePendingEvent({ key, event, kind, parentId, lastError: result.reason });
802
+ audit({
803
+ action: "webhook", actor: "todoist", target: "aibroker", outcome: "queued",
804
+ reason: `${result.reason} — queued for retry`, meta: { event: event.event_name, taskId: parentId },
805
+ });
806
+ log(`todoist-webhook: ${result.reason} — queued for retry`);
861
807
  }
862
- catch { /* a lookup failure must not block a delivery */ }
863
- }
864
- const twinWarning = twins > 1
865
- ? `\n\n[note: ${twins} open tasks in this project share this title — say which id you answered on]`
866
- : "";
867
- // The project rides along with the task id. A session asked to file a
868
- // follow-up otherwise has to guess which project is "its own", and a
869
- // guess from its alias creates a second project the user never sees.
870
- const fromProject = String(event.event_data?.project_id ?? "");
871
- const delivered = `${decision.body}${twinWarning}\n\n[todoist:${decision.taskId}${fromProject ? ` in:${fromProject}` : ""}]`;
872
- const r = await deps.deliver(decision.project, delivered, isComment ? { prefix: "[Task:comment]" } : undefined);
873
- // Remember who took it, so a later comment reaches the same session.
874
- // Recorded on the way out and only on a real delivery: a task nobody
875
- // accepted has no owner to inherit.
876
- if (!isComment && r.outcome === "delivered")
877
- rememberOwner(decision.taskId, decision.project);
878
- // Release a claim the dispatch did not earn. "queued" keeps it — the
879
- // work IS in flight, the session is simply mid-turn — but a trigger
880
- // that never reached anyone must go back to being tickable, or the
881
- // button is dead until someone removes the label by hand.
882
- if (claiming && r.outcome !== "delivered" && r.outcome !== "queued" && r.outcome !== "spawned") {
883
- try {
884
- const { setTaskLabel } = await import("./todoist-reply.js");
885
- await setTaskLabel(decision.taskId, RUNNING_LABEL, false);
886
- const { forgetClaim } = await import("./todoist-claims.js");
887
- forgetClaim(decision.taskId);
888
- log(`todoist-webhook: released ${RUNNING_LABEL} on ${decision.taskId} — dispatch was ${r.outcome}`);
808
+ else {
809
+ audit({
810
+ action: "webhook", actor: "todoist", target: "aibroker",
811
+ outcome: "ignored", reason: result.reason, meta: { event: event.event_name, taskId: parentId },
812
+ });
813
+ log(`todoist-webhook: ${result.reason}`);
889
814
  }
890
- catch { /* the claim is a hint, not a lock; a stuck one is visible */ }
815
+ return;
891
816
  }
892
- audit({
893
- action: "webhook", actor: `todoist:${who}`, target: r.session || decision.project,
894
- outcome: r.outcome, body: decision.body, reason: r.reason,
895
- meta: {
896
- event: event.event_name, taskId: decision.taskId,
897
- rule: decision.rule, nearMiss: decision.nearMiss,
898
- duplicateTitles: twins > 1 ? twins : undefined,
899
- },
900
- });
901
- }
902
- catch (err) {
903
- const reason = err instanceof Error ? err.message : String(err);
904
- audit({
905
- action: "webhook", actor: `todoist:${who}`, target: decision.project,
906
- outcome: "failed", body: decision.body, reason,
907
- meta: { event: event.event_name, taskId: decision.taskId },
908
- });
909
- log(`todoist-webhook: delivery failed — ${reason}`);
910
817
  }
818
+ await processResolvedEvent(event, cfg, deps);
911
819
  })();
912
820
  });
913
821
  }
822
+ /**
823
+ * Everything that happens once an event carries what routing needs — either
824
+ * because it always did (item:added, item:completed, project:deleted), or
825
+ * because `resolveParentWithRetry`/the pending-event sweep just resolved it.
826
+ * The one function both the live request path and the disk-queue retry call,
827
+ * so a fix here covers every event kind that reaches it, not just reminders.
828
+ */
829
+ async function processResolvedEvent(event, cfg, deps) {
830
+ // A comment written on a MIRROR entry is meant for the real task.
831
+ //
832
+ // The mirror is where the reader's attention is, so replying there rather
833
+ // than on the source is the natural mistake — and Todoist has no way to
834
+ // deep-link an individual comment that would make the source easier to
835
+ // reach. Carry it across instead of losing it in a project nothing reads.
836
+ // Checked before routing: the mirror project is deliberately NOT an
837
+ // ingress project, so routing would refuse this and say nothing useful.
838
+ if (event.event_name === "note:added") {
839
+ const { mirrorProjectId, mirrorBack } = await import("./todoist-mirror.js");
840
+ const mirror = mirrorProjectId();
841
+ if (mirror && String(event.event_data?.project_id ?? "") === mirror) {
842
+ const r = await mirrorBack({
843
+ taskId: String(event.event_data?.id ?? ""),
844
+ text: String(event.event_data?.content ?? ""),
845
+ projectId: mirror,
846
+ });
847
+ audit({
848
+ action: "webhook", actor: "todoist", target: "aibroker",
849
+ outcome: r.carried ? "carried-back" : "ignored",
850
+ reason: r.reason ?? "comment on a mirror entry carried to its source task",
851
+ });
852
+ return;
853
+ }
854
+ }
855
+ const isComment = event.event_name === "note:added";
856
+ const parentId = String(event.event_data?.id ?? "");
857
+ const { ownerOf, rememberOwner } = await import("./todoist-owners.js");
858
+ // Grants made since the daemon started take effect now, not at the next
859
+ // restart. A project created and granted while you are using the system
860
+ // has to work immediately, or the grant is indistinguishable from a
861
+ // project that routes nowhere.
862
+ const { applyGrants, expandThroughSubtree } = await import("./todoist-ingress.js");
863
+ let live = applyGrants(cfg);
864
+ // A sub-project is a folder, not a second owner. Before this, organising
865
+ // tasks into "Task Bus / Archive" moved them outside the
866
+ // allowlist and every one was refused — silently, and precisely when
867
+ // someone tidied up. Only ancestors granted WITH a subtree flag apply, so
868
+ // nothing becomes an ingress that nobody granted.
869
+ const eventProject = String(event.event_data?.project_id ?? "");
870
+ if (eventProject && !live.ingressProjectIds.has(eventProject)) {
871
+ try {
872
+ const { projectTree, ancestorsOf } = await import("./todoist-projects.js");
873
+ const tree = await projectTree();
874
+ live = expandThroughSubtree(live, eventProject, ancestorsOf(eventProject, tree), {
875
+ name: tree.get(eventProject)?.name,
876
+ known: await (deps.knownOwners?.() ?? []),
877
+ });
878
+ }
879
+ catch (err) {
880
+ log(`todoist-webhook: subtree lookup failed — ${err instanceof Error ? err.message : String(err)}`);
881
+ }
882
+ }
883
+ const decision = route(event, live, await (deps.knownOwners?.() ?? []), isComment ? ownerOf(parentId) : undefined);
884
+ const who = event.initiator?.email ?? event.initiator?.full_name ?? "todoist";
885
+ if (decision.nearMiss) {
886
+ // Recorded whether or not the task was delivered: the point is that
887
+ // something was asked for and not honoured.
888
+ log(`todoist-webhook: near miss — ${decision.nearMiss}`);
889
+ }
890
+ // A completion has nothing to dispatch, but it is not nothing. The
891
+ // comment thread on a ticked task leaves every list at that moment, so a
892
+ // configured hook gets its chance before this is recorded as "ignored" —
893
+ // and its exit code decides what the record says.
894
+ let hook = { ran: false, ok: true };
895
+ if (event.event_name === "item:completed") {
896
+ const { runCompletedHook } = await import("./todoist-completed-hook.js");
897
+ hook = await runCompletedHook(String(event.event_data?.id ?? ""));
898
+ }
899
+ if (!decision.act) {
900
+ audit({
901
+ action: "webhook", actor: `todoist:${who}`, target: "aibroker",
902
+ outcome: hook.ran ? (hook.ok ? "archived" : "hook-failed") : "ignored",
903
+ reason: hook.ran
904
+ ? (hook.ok ? `${decision.reason} — completion hook ran` : `${decision.reason} — completion hook FAILED: ${hook.detail}`)
905
+ : decision.reason,
906
+ meta: {
907
+ event: event.event_name,
908
+ task: event.event_data?.content ?? undefined,
909
+ nearMiss: decision.nearMiss,
910
+ },
911
+ });
912
+ return;
913
+ }
914
+ // Claim a trigger before dispatching, and release it if the dispatch
915
+ // does not land. Honouring another runner's claim is only half an
916
+ // interlock: a path that dispatches and leaves the task unclaimed lets
917
+ // the next poller see an advanced due date with nothing on it, conclude
918
+ // the box was ticked, and run the same sweep again.
919
+ //
920
+ // A `reminder:fired` dispatch must claim too (incident 6hccprFMww4pX3M9,
921
+ // 24./25.09): the session it reaches finishes with `pai task done`, and that
922
+ // completion arriving on an UNCLAIMED recurring trigger is indistinguishable
923
+ // from a human tick — route() dispatched the same sweep a second time. With
924
+ // the claim set, route() sees pai-running and ignores the run's own
925
+ // completion, exactly as it already does for the poller path.
926
+ const claiming = (event.event_name === "item:completed" || event.event_name === "reminder:fired")
927
+ && isTrigger(event.event_data ?? {});
928
+ if (claiming) {
929
+ try {
930
+ const { setTaskLabel } = await import("./todoist-reply.js");
931
+ await setTaskLabel(decision.taskId, RUNNING_LABEL, true);
932
+ audit({
933
+ action: "todoist-claim", actor: "aibroker", target: `todoist:task:${decision.taskId}`,
934
+ outcome: "claimed",
935
+ });
936
+ // Remember when, so a claim nobody comes back for can be released.
937
+ const { recordClaim } = await import("./todoist-claims.js");
938
+ recordClaim(decision.taskId, undefined,
939
+ // Todoist has already advanced the due date by the time a completion
940
+ // reaches us, so this is the NEXT occurrence — the point past which
941
+ // a surviving claim would block the very trigger it guards. A
942
+ // reminder's due is the occurrence being run NOW; claimDeadline's
943
+ // two-hour floor is what keeps that from expiring the claim early.
944
+ event.event_data?.due?.date);
945
+ }
946
+ catch (err) {
947
+ // Audited, not just logged. A successful claim and a failed one used
948
+ // to be indistinguishable from outside — the label was simply absent,
949
+ // and the only record was a line in a log file nobody reads until
950
+ // something has already gone wrong downstream. The dispatch still
951
+ // proceeds: the claim is a hint, and refusing to run the sweep
952
+ // because a label write failed would be the worse trade.
953
+ const reason = err instanceof Error ? err.message : String(err);
954
+ audit({
955
+ action: "todoist-claim", actor: "aibroker", target: `todoist:task:${decision.taskId}`,
956
+ outcome: "failed",
957
+ reason: `${reason} — dispatching UNCLAIMED, a poller may read this tick as a fresh request`,
958
+ });
959
+ log(`todoist-webhook: could not claim ${decision.taskId} — ${reason}`);
960
+ }
961
+ }
962
+ try {
963
+ // The id rides along so the session can answer on the task it came
964
+ // from. Without it the reply has nowhere to go but a terminal the
965
+ // asker is not looking at.
966
+ // Warn when the title is not unique in its project. Two tasks with the
967
+ // same name are indistinguishable in a list, so an answer posted on one
968
+ // looks — to whoever is watching the other — exactly like being ignored.
969
+ // The session is told, so it can say which id it answered on.
970
+ let twins = 0;
971
+ if (!isComment && !claiming) {
972
+ try {
973
+ const { countTasksWithTitle } = await import("./todoist-reply.js");
974
+ twins = await countTasksWithTitle(String(event.event_data?.project_id ?? ""), String(event.event_data?.content ?? ""));
975
+ }
976
+ catch { /* a lookup failure must not block a delivery */ }
977
+ }
978
+ const twinWarning = twins > 1
979
+ ? `\n\n[note: ${twins} open tasks in this project share this title — say which id you answered on]`
980
+ : "";
981
+ // The project rides along with the task id. A session asked to file a
982
+ // follow-up otherwise has to guess which project is "its own", and a
983
+ // guess from its alias creates a second project the user never sees.
984
+ const fromProject = String(event.event_data?.project_id ?? "");
985
+ const delivered = `${decision.body}${twinWarning}\n\n[todoist:${decision.taskId}${fromProject ? ` in:${fromProject}` : ""}]`;
986
+ const r = await deps.deliver(decision.project, delivered, isComment ? { prefix: "[Task:comment]" } : undefined);
987
+ // Remember who took it, so a later comment reaches the same session.
988
+ // Recorded on the way out and only on a real delivery: a task nobody
989
+ // accepted has no owner to inherit.
990
+ if (!isComment && r.outcome === "delivered")
991
+ rememberOwner(decision.taskId, decision.project);
992
+ // Release a claim the dispatch did not earn. "queued" keeps it — the
993
+ // work IS in flight, the session is simply mid-turn — but a trigger
994
+ // that never reached anyone must go back to being tickable, or the
995
+ // button is dead until someone removes the label by hand.
996
+ if (claiming && r.outcome !== "delivered" && r.outcome !== "queued" && r.outcome !== "spawned") {
997
+ try {
998
+ const { setTaskLabel } = await import("./todoist-reply.js");
999
+ await setTaskLabel(decision.taskId, RUNNING_LABEL, false);
1000
+ const { forgetClaim } = await import("./todoist-claims.js");
1001
+ forgetClaim(decision.taskId);
1002
+ log(`todoist-webhook: released ${RUNNING_LABEL} on ${decision.taskId} — dispatch was ${r.outcome}`);
1003
+ }
1004
+ catch { /* the claim is a hint, not a lock; a stuck one is visible */ }
1005
+ }
1006
+ audit({
1007
+ action: "webhook", actor: `todoist:${who}`, target: r.session || decision.project,
1008
+ outcome: r.outcome, body: decision.body, reason: r.reason,
1009
+ meta: {
1010
+ event: event.event_name, taskId: decision.taskId,
1011
+ rule: decision.rule, nearMiss: decision.nearMiss,
1012
+ duplicateTitles: twins > 1 ? twins : undefined,
1013
+ },
1014
+ });
1015
+ }
1016
+ catch (err) {
1017
+ const reason = err instanceof Error ? err.message : String(err);
1018
+ audit({
1019
+ action: "webhook", actor: `todoist:${who}`, target: decision.project,
1020
+ outcome: "failed", body: decision.body, reason,
1021
+ meta: { event: event.event_name, taskId: decision.taskId },
1022
+ });
1023
+ log(`todoist-webhook: delivery failed — ${reason}`);
1024
+ }
1025
+ }
914
1026
  /** Read config from the environment. Returns null when not configured. */
915
1027
  export function webhookConfigFromEnv() {
916
1028
  const secret = process.env.TODOIST_CLIENT_SECRET;
@@ -987,6 +1099,17 @@ export function startTodoistWebhook(deps) {
987
1099
  const port = Number(process.env.AIBROKER_A2A_PORT ?? process.env.TODOIST_WEBHOOK_PORT) || 8766;
988
1100
  const bind = process.env.TODOIST_WEBHOOK_BIND ?? "127.0.0.1";
989
1101
  const server = createWebhookServer(cfg, deps);
1102
+ // Retries what a live request could not: shortly after start (a daemon
1103
+ // restart must not strand what was already queued) and on every tick after
1104
+ // that (an outage longer than the inline backoff in resolveParentWithRetry).
1105
+ if (cfg) {
1106
+ const liveCfg = cfg;
1107
+ const sweep = () => {
1108
+ void sweepPendingEvents(liveCfg, deps).catch((e) => log(`todoist-webhook: pending-event sweep failed — ${e instanceof Error ? e.message : String(e)}`));
1109
+ };
1110
+ setTimeout(sweep, 20_000).unref();
1111
+ setInterval(sweep, 60_000).unref();
1112
+ }
990
1113
  if (cfg && bind !== "127.0.0.1" && bind !== "localhost") {
991
1114
  log(`todoist-webhook: WARNING binding ${bind}, not loopback — this puts an execution ingress ` +
992
1115
  `directly on the network. Prefer loopback with a TLS proxy (Tailscale Funnel, Cloudflare Tunnel, Caddy) in front.`);