@pnds/pond 1.8.0 → 1.9.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 (2) hide show
  1. package/package.json +3 -3
  2. package/src/gateway.ts +80 -32
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@pnds/pond",
3
- "version": "1.8.0",
3
+ "version": "1.9.0",
4
4
  "description": "OpenClaw channel plugin for Pond IM",
5
5
  "license": "MIT",
6
6
  "repository": {
@@ -15,8 +15,8 @@
15
15
  "openclaw.plugin.json"
16
16
  ],
17
17
  "dependencies": {
18
- "@pnds/sdk": "1.8.0",
19
- "@pnds/cli": "1.8.0"
18
+ "@pnds/cli": "1.9.0",
19
+ "@pnds/sdk": "1.9.0"
20
20
  },
21
21
  "devDependencies": {
22
22
  "@types/node": "^22.0.0",
package/src/gateway.ts CHANGED
@@ -402,7 +402,7 @@ export const pondGateway: ChannelGatewayAdapter<ResolvedPondAccount> = {
402
402
  }
403
403
  }
404
404
  // Resolve remaining items as not-dispatched (fork stopped after error).
405
- // These events are NOT acked — inbox retains them for catch-up replay.
405
+ // These events are NOT acked — dispatch retains them for catch-up replay.
406
406
  for (const remaining of fork.queue.splice(0)) {
407
407
  remaining.resolve(false);
408
408
  }
@@ -535,10 +535,10 @@ export const pondGateway: ChannelGatewayAdapter<ResolvedPondAccount> = {
535
535
 
536
536
  dispatchState.mainCurrentTargetId = event.targetId;
537
537
  await runDispatch(event, orchestratorKey, bodyForAgent);
538
- // Ack inbox after successful drain dispatch — these events were buffered
538
+ // Ack dispatch after successful drain — these events were buffered
539
539
  // without ack, so this is the first time they're confirmed processed.
540
540
  const sourceType = event.type === "task" ? "task_activity" : "message";
541
- client.ackInbox(config.org_id, { source_type: sourceType, source_id: event.messageId }).catch(() => {});
541
+ client.ackDispatch(config.org_id, { source_type: sourceType, source_id: event.messageId }).catch(() => {});
542
542
  }
543
543
  } finally {
544
544
  draining = false;
@@ -550,8 +550,8 @@ export const pondGateway: ChannelGatewayAdapter<ResolvedPondAccount> = {
550
550
  /**
551
551
  * Route and dispatch an inbound event.
552
552
  * Returns true if the event was dispatched (main or fork), false if only buffered.
553
- * Callers should only ack inbox when this returns true — buffered events may be
554
- * lost on crash and need to be replayed from inbox on the next catch-up.
553
+ * Callers should only ack dispatch when this returns true — buffered events may be
554
+ * lost on crash and need to be replayed from dispatch on the next catch-up.
555
555
  */
556
556
  async function handleInboundEvent(event: PondEvent): Promise<boolean> {
557
557
  const disposition = routeTrigger(event, dispatchState, defaultRoutingStrategy);
@@ -683,13 +683,13 @@ export const pondGateway: ChannelGatewayAdapter<ResolvedPondAccount> = {
683
683
  });
684
684
  }, intervalSec * 1000);
685
685
 
686
- // Unified inbox catch-up (replaces separate mention/DM/task catch-up).
686
+ // Dispatch catch-up: fetch pending dispatch events (FIFO).
687
687
  // On cold start, skip items older than COLD_START_WINDOW_MS to avoid
688
688
  // replaying historical backlog from a previous process.
689
689
  const isFirstHello = !hadSuccessfulHello;
690
690
  hadSuccessfulHello = true;
691
- catchUpFromInbox(isFirstHello).catch((err) => {
692
- log?.warn(`pond[${ctx.accountId}]: inbox catch-up failed: ${String(err)}`);
691
+ catchUpFromDispatch(isFirstHello).catch((err) => {
692
+ log?.warn(`pond[${ctx.accountId}]: dispatch catch-up failed: ${String(err)}`);
693
693
  });
694
694
  } catch (err) {
695
695
  log?.error(`pond[${ctx.accountId}]: failed to fetch chats: ${String(err)}`);
@@ -757,7 +757,7 @@ export const pondGateway: ChannelGatewayAdapter<ResolvedPondAccount> = {
757
757
  (m) => m.user_id === agentUserId || m.user_id === MENTION_ALL_USER_ID,
758
758
  );
759
759
  if (!isMentioned) {
760
- // Release claim so catch-up can process this via agent_route inbox items
760
+ // Release claim so catch-up can process this via dispatch events
761
761
  dispatchedMessages.delete(data.id);
762
762
  return;
763
763
  }
@@ -802,7 +802,7 @@ export const pondGateway: ChannelGatewayAdapter<ResolvedPondAccount> = {
802
802
  try {
803
803
  const dispatched = await handleInboundEvent(event);
804
804
  if (dispatched) {
805
- client.ackInbox(config.org_id, { source_type: "message", source_id: data.id }).catch(() => {});
805
+ client.ackDispatch(config.org_id, { source_type: "message", source_id: data.id }).catch(() => {});
806
806
  } else {
807
807
  // Not dispatched (buffered or fork failed) — release claim so catch-up can retry
808
808
  dispatchedMessages.delete(data.id);
@@ -861,7 +861,7 @@ export const pondGateway: ChannelGatewayAdapter<ResolvedPondAccount> = {
861
861
  // Server event buffer overflowed — events were lost, trigger full catch-up
862
862
  ws.on("recovery.overflow", () => {
863
863
  log?.warn(`pond[${ctx.accountId}]: recovery.overflow — triggering full catch-up`);
864
- catchUpFromInbox().catch((err) =>
864
+ catchUpFromDispatch().catch((err) =>
865
865
  log?.warn(`pond[${ctx.accountId}]: overflow catch-up failed: ${String(err)}`));
866
866
  });
867
867
 
@@ -871,33 +871,43 @@ export const pondGateway: ChannelGatewayAdapter<ResolvedPondAccount> = {
871
871
  try {
872
872
  const dispatched = await handleTaskAssignment(data);
873
873
  if (dispatched) {
874
- client.ackInbox(config.org_id, { source_type: "task_activity", source_id: data.id }).catch(() => {});
874
+ client.ackDispatch(config.org_id, { source_type: "task_activity", source_id: data.id }).catch(() => {});
875
875
  }
876
876
  } catch (err) {
877
877
  log?.error(`pond[${ctx.accountId}]: task dispatch failed for ${data.id}: ${String(err)}`);
878
878
  }
879
879
  });
880
880
 
881
- // ── Unified inbox catch-up ──
882
- // Replays missed mentions, DMs, and task assignments via the inbox API.
883
- // Adapts each item into a PondEvent and routes through handleInboundEvent.
884
- async function catchUpFromInbox(coldStart = false) {
881
+ // ── Dispatch catch-up ──
882
+ // Replays missed events via the dispatch API (agent-only endpoint, FIFO order).
883
+ // Each dispatch event is a source-pointer; we re-fetch the entity to build a PondEvent.
884
+ //
885
+ // Cold-start window: On a fresh process start (not reconnect), events older than
886
+ // COLD_START_WINDOW_MS are acked without processing. This is an intentional operator
887
+ // decision: a newly started agent should not replay unbounded historical backlog from
888
+ // a previous process lifetime. Reconnects (non-cold-start) replay all pending events.
889
+ //
890
+ // NOTE: RouteToAgents (active/smart group routing) inserts dispatch rows asynchronously
891
+ // after the message.new WS event. A race exists where the live ack-by-source fires
892
+ // before the dispatch row is inserted, leaving an orphan pending row. This is benign:
893
+ // catch-up will process it on next cycle (duplicate delivery, not data loss).
894
+ // TODO: resolve by creating dispatch rows before publishing the live event, or by
895
+ // consuming dispatch.new in the live path.
896
+ async function catchUpFromDispatch(coldStart = false) {
885
897
  const minAge = coldStart ? Date.now() - COLD_START_WINDOW_MS : 0;
886
898
  let cursor: string | undefined;
887
899
  let processed = 0;
888
900
  let skippedOld = 0;
889
901
 
890
902
  do {
891
- const page = await client.getInbox(config.org_id, {
892
- type: "mention,agent_dm,task_assign,agent_route",
893
- read: "false",
903
+ const page = await client.getDispatch(config.org_id, {
894
904
  limit: 50,
895
905
  cursor,
896
906
  });
897
907
 
898
908
  for (const item of page.data ?? []) {
899
- if (minAge > 0 && new Date(item.updated_at).getTime() < minAge) {
900
- client.markInboxRead(config.org_id, item.id).catch(() => {});
909
+ if (minAge > 0 && new Date(item.created_at).getTime() < minAge) {
910
+ client.ackDispatchByID(config.org_id, item.id).catch(() => {});
901
911
  skippedOld++;
902
912
  continue;
903
913
  }
@@ -905,21 +915,59 @@ export const pondGateway: ChannelGatewayAdapter<ResolvedPondAccount> = {
905
915
  processed++;
906
916
  try {
907
917
  let dispatched = false;
908
- if (item.type === "task_assign" && item.source_id) {
918
+ if (item.event_type === "task_assign" && item.task_id) {
909
919
  // Task catch-up: fetch full task and dispatch
910
- const task = await client.getTask(config.org_id, item.source_id);
920
+ let task: Awaited<ReturnType<typeof client.getTask>> | null = null;
921
+ let taskFetchFailed = false;
922
+ try {
923
+ task = await client.getTask(config.org_id, item.task_id);
924
+ } catch (err: unknown) {
925
+ // Distinguish 404 (task deleted) from transient failures
926
+ const status = (err as { status?: number })?.status;
927
+ if (status === 404) {
928
+ task = null; // task genuinely deleted
929
+ } else {
930
+ taskFetchFailed = true;
931
+ log?.warn(`pond[${ctx.accountId}]: catch-up task fetch failed for ${item.task_id}, leaving pending: ${String(err)}`);
932
+ }
933
+ }
934
+ if (taskFetchFailed) {
935
+ continue; // leave pending for next catch-up cycle
936
+ }
911
937
  if (task) {
938
+ // Defense-in-depth (D2): verify task is still assigned to this agent
939
+ if (task.assignee_id !== agentUserId) {
940
+ log?.info(`pond[${ctx.accountId}]: skipping stale task dispatch ${item.id} — reassigned to ${task.assignee_id}`);
941
+ client.ackDispatchByID(config.org_id, item.id).catch(() => {});
942
+ continue;
943
+ }
912
944
  dispatched = await handleTaskAssignment(task);
913
945
  } else {
914
- dispatched = true; // task deleted — mark read to prevent infinite retry
946
+ dispatched = true; // task genuinely deleted — ack to prevent infinite retry
915
947
  }
916
- } else if (item.source_id && item.chat_id) {
917
- // Message catch-up (mention or agent_dm): build PondEvent from inbox item
948
+ } else if (item.event_type === "message" && item.source_id && item.chat_id) {
949
+ // Message catch-up: build PondEvent from dispatch source pointer
918
950
  if (!tryClaimMessage(item.source_id)) continue;
919
- const msg = await client.getMessage(item.source_id).catch(() => null);
951
+ let msg: Awaited<ReturnType<typeof client.getMessage>> | null = null;
952
+ let msgFetchFailed = false;
953
+ try {
954
+ msg = await client.getMessage(item.source_id);
955
+ } catch (err: unknown) {
956
+ const status = (err as { status?: number })?.status;
957
+ if (status === 404) {
958
+ msg = null; // message genuinely deleted
959
+ } else {
960
+ msgFetchFailed = true;
961
+ log?.warn(`pond[${ctx.accountId}]: catch-up message fetch failed for ${item.source_id}, leaving pending: ${String(err)}`);
962
+ }
963
+ }
964
+ if (msgFetchFailed) {
965
+ dispatchedMessages.delete(item.source_id);
966
+ continue; // leave pending for next catch-up cycle
967
+ }
920
968
  if (!msg || msg.sender_id === agentUserId) {
921
969
  dispatchedMessages.delete(item.source_id);
922
- client.markInboxRead(config.org_id, item.id).catch(() => {});
970
+ client.ackDispatchByID(config.org_id, item.id).catch(() => {});
923
971
  continue;
924
972
  }
925
973
 
@@ -951,7 +999,7 @@ export const pondGateway: ChannelGatewayAdapter<ResolvedPondAccount> = {
951
999
  }
952
1000
  if (!body) {
953
1001
  dispatchedMessages.delete(item.source_id);
954
- client.markInboxRead(config.org_id, item.id).catch(() => {});
1002
+ client.ackDispatchByID(config.org_id, item.id).catch(() => {});
955
1003
  continue;
956
1004
  }
957
1005
 
@@ -971,17 +1019,17 @@ export const pondGateway: ChannelGatewayAdapter<ResolvedPondAccount> = {
971
1019
  dispatched = await handleInboundEvent(event);
972
1020
  }
973
1021
  if (dispatched) {
974
- client.markInboxRead(config.org_id, item.id).catch(() => {});
1022
+ client.ackDispatchByID(config.org_id, item.id).catch(() => {});
975
1023
  }
976
1024
  } catch (err) {
977
- log?.warn(`pond[${ctx.accountId}]: catch-up item ${item.id} (${item.type}) failed: ${String(err)}`);
1025
+ log?.warn(`pond[${ctx.accountId}]: catch-up dispatch ${item.id} (${item.event_type}) failed: ${String(err)}`);
978
1026
  }
979
1027
  }
980
1028
  cursor = page.has_more ? page.next_cursor : undefined;
981
1029
  } while (cursor);
982
1030
 
983
1031
  if (processed > 0 || skippedOld > 0) {
984
- log?.info(`pond[${ctx.accountId}]: inbox catch-up: processed ${processed}, skipped ${skippedOld} old item(s)`);
1032
+ log?.info(`pond[${ctx.accountId}]: dispatch catch-up: processed ${processed}, skipped ${skippedOld} old item(s)`);
985
1033
  }
986
1034
  }
987
1035