@commonlyai/cli 0.1.167 → 0.1.169

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@commonlyai/cli",
3
- "version": "0.1.167",
3
+ "version": "0.1.169",
4
4
  "license": "Apache-2.0",
5
5
  "description": "The Commonly CLI — connect agents, manage pods, iterate fast",
6
6
  "type": "module",
@@ -2035,15 +2035,32 @@ export const performRun = ({
2035
2035
  } else {
2036
2036
  // ADR-018 length gate: the tone contract is enforced here, where the
2037
2037
  // wrapper is the one posting. Split, attach — never truncate.
2038
- const delivery = await deliverChatReply({
2039
- client,
2040
- podId: eventPodId,
2041
- text: replyText,
2042
- limit: chatCharLimit,
2043
- maxChunks: maxChatChunks,
2044
- uploadName: `${agentName}-reply-${event._id}.md`,
2045
- log: (line) => log(`[${event.type}] ${line}`),
2046
- });
2038
+ let delivery;
2039
+ try {
2040
+ delivery = await deliverChatReply({
2041
+ client,
2042
+ podId: eventPodId,
2043
+ text: replyText,
2044
+ limit: chatCharLimit,
2045
+ maxChunks: maxChatChunks,
2046
+ uploadName: `${agentName}-reply-${event._id}.md`,
2047
+ log: (line) => log(`[${event.type}] ${line}`),
2048
+ });
2049
+ } catch (deliveryError) {
2050
+ // TASK-535: every rung threw, so the reply reached nobody. The throw
2051
+ // still leaves the events unacked for redelivery (unchanged), but the
2052
+ // retry report is about the BATCH; this line is about the reply, and
2053
+ // it names where it was going so a lost summary is never silent.
2054
+ const eventCount = Array.isArray(event.payload?.batchEventIds)
2055
+ ? event.payload.batchEventIds.length
2056
+ : 1;
2057
+ log(
2058
+ `[${event.type}] ERROR reply NOT delivered to pod ${eventPodId} — `
2059
+ + `${Buffer.byteLength(replyText)} bytes lost, ${eventCount} event${eventCount === 1 ? '' : 's'} `
2060
+ + `left unacked for retry: ${deliveryError.message}`,
2061
+ );
2062
+ throw deliveryError;
2063
+ }
2047
2064
  if (delivery.skipped) {
2048
2065
  // The server answered 200 and created nothing: a duplicate inside its
2049
2066
  // dedupe window, a silent body, a heartbeat it keeps. Nothing reached
@@ -18,6 +18,8 @@
18
18
  * stands the agent down, and both are logged with the reason.
19
19
  */
20
20
 
21
+ import { isUnseenMessagesHold } from './pod-catch-up.js';
22
+
21
23
  // Event types whose payload.messageId identifies a claimable trigger message.
22
24
  // first_contact is deliberately absent: the welcome wake targets exactly one
23
25
  // agent, so there is nothing to contend for. Heartbeats have no message at
@@ -673,7 +675,49 @@ export const deliverChatReply = async ({
673
675
  // request, but no message was created. Keep that distinction at the client
674
676
  // boundary so every delivery mode shares it rather than treating a resolved
675
677
  // promise as proof of a post.
676
- const postMessage = (body) => client.post(messagesPath, body);
678
+ //
679
+ // TASK-535 — the send-time freshness hold. The backend answers 409
680
+ // `unseen_messages` when other members posted after this seat's last
681
+ // recorded tail read. A turn that ran ~43 minutes (rhea, 2026-10-25 10:01Z)
682
+ // hit it on the thread headline, the attach card and the split fallback in
683
+ // turn, so the reply was lost and its 10 events re-ran. A retry without an
684
+ // override cannot succeed — every rung posts to the same guarded route.
685
+ //
686
+ // CTO ruling: on the FIRST hold of a delivery, re-read the pod tail once,
687
+ // then post with `continueAnyway: true`, and keep the override for every
688
+ // later post in THIS delivery (remaining chunks, later rungs). The re-read
689
+ // is GET /messages at the route's 50-row cap with no `before`/`threadRootId`
690
+ // — the read that records a seen cursor (AgentSendFreshness.recordRead). It
691
+ // advances the cursor only when the page holds EVERY message since the last
692
+ // recorded read. The wrapper's own post-turn snapshot is that same read at
693
+ // limit 10, which is why a 12-message gap stayed unseen. So a re-read alone
694
+ // passes a normal post only while the gap is ≤ 50 and nothing lands between
695
+ // read and post; the override is what delivers, and the read records what
696
+ // the wrapper saw, exactly as its snapshots already do. One re-read and one
697
+ // override per delivery; a post that still fails throws to the next rung as
698
+ // before. Nothing here loops.
699
+ let freshnessOverride = false;
700
+ const postMessage = async (body) => {
701
+ if (freshnessOverride) return client.post(messagesPath, { ...body, continueAnyway: true });
702
+ try {
703
+ return await client.post(messagesPath, body);
704
+ } catch (error) {
705
+ if (!isUnseenMessagesHold(error)) throw error;
706
+ freshnessOverride = true;
707
+ const unseen = error.body?.unseenCount;
708
+ log(
709
+ `reply held: ${Number.isFinite(unseen) ? unseen : 'some'} unseen message(s) in pod ${podId} `
710
+ + 'arrived during the turn — re-reading the pod once, then posting with continueAnyway (TASK-535)',
711
+ );
712
+ try {
713
+ await client.get(messagesPath, { limit: 50 });
714
+ } catch (readError) {
715
+ // The re-read is bookkeeping, not permission: the override delivers.
716
+ log(`pod re-read before the continueAnyway post failed (${readError.message}) — posting anyway`);
717
+ }
718
+ return client.post(messagesPath, { ...body, continueAnyway: true });
719
+ }
720
+ };
677
721
  const refused = (response, messages, attemptedMessages) => ({
678
722
  mode: 'refused',
679
723
  messages,
@@ -1,3 +1,13 @@
1
+ /**
2
+ * The backend's send-time freshness hold (agentSendFreshness.unseenMessages):
3
+ * HTTP 409 with `code: 'unseen_messages'`, surfaced by api.js as
4
+ * `error.status` + `error.body`. Shared by the turn-start probe below and the
5
+ * end-of-turn delivery in enforcement.js, so both read the hold the same way.
6
+ */
7
+ export const isUnseenMessagesHold = (error) => (
8
+ error?.status === 409 && error?.body?.code === 'unseen_messages'
9
+ );
10
+
1
11
  /** Explicit turn-start recovery for history that cannot fit in a tail read. */
2
12
  export const catchUpBeforeTurn = async (client, podId) => {
3
13
  const base = `/api/agents/runtime/pods/${encodeURIComponent(podId)}`;
@@ -9,13 +19,19 @@ export const catchUpBeforeTurn = async (client, podId) => {
9
19
  // Unknown freshness preserves existing turn behavior; the send-time auth
10
20
  // and freshness checks still decide whether output may be posted.
11
21
  if (error?.status !== 409) return '';
12
- if (error?.body?.code !== 'unseen_messages') throw error;
22
+ if (!isUnseenMessagesHold(error)) throw error;
13
23
  unseenCount = error.body.unseenCount;
14
24
  }
15
25
  // Routine wakes retain the seat's existing read behavior and prompt size.
16
26
  if (!(unseenCount > 50)) return '';
17
- // Never retry an already generated reply with continueAnyway. A catch-up
18
- // supplies fresh conversation context BEFORE the model gets a turn.
27
+ // Before the turn, the answer to a hold is context, not an override: a
28
+ // catch-up supplies fresh conversation BEFORE the model gets a turn, so
29
+ // nothing here sends with continueAnyway. The one place an already
30
+ // generated reply IS retried with continueAnyway is end-of-turn delivery
31
+ // (deliverChatReply, TASK-535): a turn long enough for the pod to move on
32
+ // otherwise lost its whole reply to this same hold and left its events to
33
+ // re-run. That is a CTO ruling scoped to delivery; it does not license
34
+ // continueAnyway anywhere a fresh read can still reach the model.
19
35
  let body;
20
36
  try {
21
37
  body = await client.post(`${base}/catch-up`, { confirmSkipHistory: true });