@ziggs-ai/agent-sdk 0.10.0 → 0.10.2

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 (58) hide show
  1. package/CHANGELOG.md +23 -0
  2. package/README.md +9 -1
  3. package/dist/AgentHost.d.ts +50 -14
  4. package/dist/AgentHost.js +129 -7
  5. package/dist/agent/Agent.js +2 -2
  6. package/dist/agent/ClaudeSdkAgent.d.ts +23 -1
  7. package/dist/agent/ClaudeSdkAgent.js +156 -84
  8. package/dist/agent/claudeCognition.d.ts +25 -3
  9. package/dist/agent/claudeCognition.js +33 -1
  10. package/dist/agent/claudeSdkTools.d.ts +2 -2
  11. package/dist/agent/claudeSdkTools.js +1 -1
  12. package/dist/agent/toolManagerToSdkMcp.js +1 -1
  13. package/dist/context/applyEffects.js +4 -3
  14. package/dist/index.d.ts +6 -4
  15. package/dist/index.js +7 -3
  16. package/dist/ingress/normalizeIncoming.d.ts +2 -2
  17. package/dist/ingress/normalizeIncoming.js +1 -1
  18. package/dist/runtime/AgentMachine.d.ts +4 -4
  19. package/dist/runtime/AgentMachine.js +6 -6
  20. package/dist/runtime/bootstrapCtx.js +6 -8
  21. package/dist/runtime/buildOutcome.d.ts +9 -1
  22. package/dist/runtime/buildOutcome.js +29 -6
  23. package/dist/runtime/{wakeWriteKeys.d.ts → deliveryWriteKeys.d.ts} +2 -2
  24. package/dist/runtime/{wakeWriteKeys.js → deliveryWriteKeys.js} +15 -22
  25. package/dist/runtime/runMechanical.d.ts +2 -2
  26. package/dist/runtime/runMechanical.js +2 -2
  27. package/dist/runtime/runTurn.d.ts +2 -2
  28. package/dist/runtime/runTurn.js +5 -5
  29. package/dist/runtime/taskIntake.js +6 -4
  30. package/dist/server/ConnectionPool.d.ts +11 -53
  31. package/dist/server/ConnectionPool.js +29 -60
  32. package/dist/server/EventQueue.d.ts +6 -0
  33. package/dist/server/EventQueue.js +21 -6
  34. package/dist/server/SeenMessages.d.ts +7 -0
  35. package/dist/server/SeenMessages.js +15 -0
  36. package/dist/server/ZiggsEffectHandler.d.ts +1 -0
  37. package/dist/server/ZiggsEffectHandler.js +18 -11
  38. package/dist/server/createHealthServer.js +1 -1
  39. package/dist/server/inboxLoop.d.ts +17 -2
  40. package/dist/server/inboxLoop.js +92 -16
  41. package/dist/server/polls/PollKind.d.ts +72 -0
  42. package/dist/server/polls/PollKind.js +53 -0
  43. package/dist/server/polls/PollRunner.d.ts +62 -0
  44. package/dist/server/polls/PollRunner.js +138 -0
  45. package/dist/server/questMatch.d.ts +5 -0
  46. package/dist/server/questMatch.js +14 -0
  47. package/dist/server/runLauncher.d.ts +0 -6
  48. package/dist/server/runLauncher.js +54 -27
  49. package/dist/server/tools/tier1/protocolRunner.d.ts +4 -2
  50. package/dist/server/tools/tier1/protocolRunner.js +30 -34
  51. package/dist/server/tools/tier1/protocolTools.js +23 -13
  52. package/dist/server/tools/tier2/connectionTools.js +12 -1
  53. package/dist/tasks/protocolRegistry.d.ts +27 -0
  54. package/dist/tasks/protocolRegistry.js +36 -0
  55. package/dist/types.d.ts +4 -13
  56. package/package.json +2 -2
  57. package/dist/server/staleBacklogGate.d.ts +0 -57
  58. package/dist/server/staleBacklogGate.js +0 -100
@@ -4,6 +4,12 @@ import { runtimeLog } from '../shared/runtimeLog.js';
4
4
  * batch dispatch. The runtime sees at most one in-flight processEvent call
5
5
  * per laneKey at a time; multiple events arriving while busy are merged
6
6
  * into a single { type: 'batch', events: [...] } frame on the next pass.
7
+ *
8
+ * ZIG-1191: `enqueue` REJECTS when the dispatch failed. It used to log the error
9
+ * and resolve, which made every caller — including the inbox loop, whose next
10
+ * statement is the ack — unable to tell a handled event from a lost one. A
11
+ * caller that has nothing to do with a failure should say so with `.catch()`;
12
+ * the one holding a delivery must not have that decision made for it.
7
13
  */
8
14
  export class EventQueue {
9
15
  _processEvent;
@@ -28,9 +34,12 @@ export class EventQueue {
28
34
  state = { events: [], processing: false };
29
35
  this._state.set(laneKey, state);
30
36
  }
31
- return new Promise((resolve) => {
37
+ return new Promise((resolve, reject) => {
32
38
  this._metrics.enqueued += 1;
33
- state.events.push({ event, resolve });
39
+ state.events.push({
40
+ event,
41
+ settle: (err) => (err === undefined ? resolve() : reject(err)),
42
+ });
34
43
  this._trimQueueIfNeeded(state, laneKey);
35
44
  this._processIfNeeded(laneKey);
36
45
  });
@@ -49,21 +58,25 @@ export class EventQueue {
49
58
  while (state.events.length > 0) {
50
59
  const batch = state.events.splice(0, state.events.length);
51
60
  const events = batch.map(e => e.event);
52
- const resolvers = batch.map(e => e.resolve);
61
+ const settlers = batch.map(e => e.settle);
53
62
  const coalesced = events.length === 1
54
63
  ? events[0]
55
64
  : { type: 'batch', events };
65
+ // Coalescing makes the batch the unit of success: the lane ran one tick
66
+ // for all of it, so a failure belongs to every event that was merged in.
67
+ let failure;
56
68
  try {
57
69
  await this._processEvent(coalesced, laneKey);
58
70
  }
59
71
  catch (error) {
72
+ failure = error ?? new Error('processEvent failed');
60
73
  this._metrics.errors += 1;
61
74
  runtimeLog.error('EventQueue', `processEvent error for laneKey=${laneKey}: ${error?.message || error}`);
62
75
  }
63
76
  this._metrics.processedBatches += 1;
64
77
  this._metrics.processedEvents += events.length;
65
- for (const r of resolvers)
66
- r();
78
+ for (const settle of settlers)
79
+ settle(failure);
67
80
  }
68
81
  state.processing = false;
69
82
  }
@@ -71,7 +84,9 @@ export class EventQueue {
71
84
  while (state.events.length > this._maxQueueLengthPerLane) {
72
85
  const dropped = state.events.shift();
73
86
  this._metrics.dropped += 1;
74
- dropped?.resolve();
87
+ // A dropped event was never handled, so its caller hears about it — the
88
+ // inbox loop holds the ack instead of acking work that fell off the queue.
89
+ dropped?.settle(new Error(`EventQueue dropped an event on laneKey=${laneKey} (queue over ${this._maxQueueLengthPerLane})`));
75
90
  runtimeLog.warn('EventQueue', `dropped oldest event laneKey=${laneKey} max=${this._maxQueueLengthPerLane}`);
76
91
  }
77
92
  }
@@ -10,4 +10,11 @@ export declare class SeenMessages {
10
10
  constructor(maxSize?: number);
11
11
  has(messageId: string | null | undefined): boolean;
12
12
  mark(messageId: string | null | undefined): void;
13
+ /**
14
+ * Forget a mark, so the next delivery of the same id is processed rather than
15
+ * skipped. ZIG-1191: marking happens BEFORE the work, to hold the door against
16
+ * a concurrent duplicate — which means a failure has to take the mark back, or
17
+ * the redelivery that a held ack buys is silently dropped as a duplicate.
18
+ */
19
+ unmark(messageId: string | null | undefined): void;
13
20
  }
@@ -28,4 +28,19 @@ export class SeenMessages {
28
28
  this.seen.delete(oldest);
29
29
  }
30
30
  }
31
+ /**
32
+ * Forget a mark, so the next delivery of the same id is processed rather than
33
+ * skipped. ZIG-1191: marking happens BEFORE the work, to hold the door against
34
+ * a concurrent duplicate — which means a failure has to take the mark back, or
35
+ * the redelivery that a held ack buys is silently dropped as a duplicate.
36
+ */
37
+ unmark(messageId) {
38
+ if (!messageId || typeof messageId !== 'string')
39
+ return;
40
+ if (!this.seen.delete(messageId))
41
+ return;
42
+ const at = this.order.indexOf(messageId);
43
+ if (at !== -1)
44
+ this.order.splice(at, 1);
45
+ }
31
46
  }
@@ -67,6 +67,7 @@ export declare function executeToolWithSideEffects(deps: Pick<ZiggsEffectDeps, '
67
67
  }>, roster: {
68
68
  agents: unknown[];
69
69
  users: unknown[];
70
+ agreements?: unknown[];
70
71
  }): Promise<ToolCallResult>;
71
72
  /**
72
73
  * Build a single shared EffectHandler. The handler is stateful — it caches
@@ -109,11 +109,14 @@ export async function executeToolWithSideEffects(deps, eff, effect, roster) {
109
109
  const toolT0 = Date.now();
110
110
  const args = effect.args || {};
111
111
  const argsPreview = previewToolArgs(args);
112
- await emitToolOperationArtifact(deps.artifactsClient, effect.sessionId, 'started', {
112
+ // ZIG-1120 #6 — breadcrumbs are best-effort. Awaiting them bracketed every
113
+ // tool call; a slow/wedged artifacts backend stalled the whole wake. Match
114
+ // ClaudeSdkAgent thought writes (fire-and-forget).
115
+ void emitToolOperationArtifact(deps.artifactsClient, effect.sessionId, 'started', {
113
116
  state: 'started',
114
117
  tool: effect.name,
115
118
  ...(argsPreview ? { args: argsPreview } : {}),
116
- }, effect.wakeKeys?.artifactKey({ s: 'started', t: effect.name, a: argsPreview }));
119
+ }, effect.writeKeys?.artifactKey({ s: 'started', t: effect.name, a: argsPreview }));
117
120
  try {
118
121
  const lastUser = roster.users.find((u) => u.userId || u.id);
119
122
  // Accumulate token usage from any nested LLM call the tool makes (llmTool),
@@ -142,22 +145,25 @@ export async function executeToolWithSideEffects(deps, eff, effect, roster) {
142
145
  eff,
143
146
  agents: roster.agents,
144
147
  users: roster.users,
148
+ // ZIG-1120 #8 — session snapshot already has agreements; protocolRunner
149
+ // used to re-fetch them over HTTP on every spawn/subcontract.
150
+ agreements: roster.agreements ?? [],
145
151
  senderId: effect.senderId ?? lastUser?.userId ?? lastUser?.id,
146
152
  senderType: effect.senderType ?? (lastUser ? 'USER' : undefined),
147
153
  onBehalfOfAgreementId: effect.onBehalfOfAgreementId,
148
154
  memory: effect.memory,
149
- wakeKeys: effect.wakeKeys,
155
+ writeKeys: effect.writeKeys,
150
156
  reportUsage,
151
157
  turnScratch: {},
152
158
  });
153
159
  // One clip per tool call: this is a full JSON.stringify of the untruncated
154
160
  // result, and the artifact body and the idempotency key want the same string.
155
161
  const clipped = clipForOperationArtifact(result);
156
- await emitToolOperationArtifact(deps.artifactsClient, effect.sessionId, 'completed', {
162
+ void emitToolOperationArtifact(deps.artifactsClient, effect.sessionId, 'completed', {
157
163
  state: 'completed',
158
164
  tool: effect.name,
159
165
  result: clipped,
160
- }, effect.wakeKeys?.artifactKey({ s: 'completed', t: effect.name, r: clipped }));
166
+ }, effect.writeKeys?.artifactKey({ s: 'completed', t: effect.name, r: clipped }));
161
167
  sendTelemetry(deps.telemetryClient, buildToolIngestPayload({
162
168
  sessionId: effect.sessionId,
163
169
  tool: effect.name,
@@ -171,11 +177,11 @@ export async function executeToolWithSideEffects(deps, eff, effect, roster) {
171
177
  catch (error) {
172
178
  const errMsg = error?.message ?? String(error);
173
179
  runtimeLog.warn('ZiggsEffectHandler', `tool-call "${effect.name}" failed: ${errMsg}`);
174
- await emitToolOperationArtifact(deps.artifactsClient, effect.sessionId, 'error', {
180
+ void emitToolOperationArtifact(deps.artifactsClient, effect.sessionId, 'error', {
175
181
  state: 'failed',
176
182
  tool: effect.name,
177
183
  error: errMsg,
178
- }, effect.wakeKeys?.artifactKey({ s: 'failed', t: effect.name, e: errMsg }));
184
+ }, effect.writeKeys?.artifactKey({ s: 'failed', t: effect.name, e: errMsg }));
179
185
  sendTelemetry(deps.telemetryClient, buildToolIngestPayload({
180
186
  sessionId: effect.sessionId,
181
187
  tool: effect.name,
@@ -277,14 +283,15 @@ export function createZiggsEffectHandler(deps) {
277
283
  return { text, usage };
278
284
  }
279
285
  case 'tool-call': {
280
- // Roster comes from the cached snapshot (populated by read-context). Both
281
- // cognitions land here — the FSM via runTurn's executeTool, the Claude
282
- // Agent SDK via the toolManagerToSdkMcp bridge — so tool side effects are
283
- // emitted exactly once, in one place (ZIG-775).
286
+ // Roster + agreements come from the cached snapshot (populated by
287
+ // read-context). Both cognitions land here — the FSM via runTurn's
288
+ // executeTool, the Claude Agent SDK via the toolManagerToSdkMcp bridge —
289
+ // so tool side effects are emitted exactly once, in one place (ZIG-775).
284
290
  const cached = cacheGet(effect.sessionId);
285
291
  return executeToolWithSideEffects(deps, handler, effect, {
286
292
  agents: cached?.agents || [],
287
293
  users: cached?.users || [],
294
+ agreements: cached?.agreements || [],
288
295
  });
289
296
  }
290
297
  case 'send-message': {
@@ -19,7 +19,7 @@ function readJsonBody(req) {
19
19
  export function createHealthServer({ port = Number(process.env.PORT) || 8080, label = 'agents', pool, } = {}) {
20
20
  const server = createServer(async (req, res) => {
21
21
  const url = req.url ?? '/';
22
- const startMatch = url.match(/^\/fleet-wake\/([^/?]+)/);
22
+ const startMatch = url.match(/^\/fleet-start\/([^/?]+)/);
23
23
  if (req.method === 'POST' && startMatch && pool) {
24
24
  const agentId = decodeURIComponent(startMatch[1]);
25
25
  try {
@@ -1,4 +1,4 @@
1
- import { type InboxTaskRef, type Creds } from '@ziggs-ai/api-client';
1
+ import { type InboxQuestRef, type InboxTaskRef, type Creds } from '@ziggs-ai/api-client';
2
2
  import { SeenMessages } from './SeenMessages.js';
3
3
  /**
4
4
  * Per-agent inbox long-poll loop (delivery law, phase 4). This is the ONLY way
@@ -26,6 +26,12 @@ export interface InboxLoopDeps {
26
26
  * for it (chat-less agreement, self-assigned task).
27
27
  */
28
28
  onTask?: (task: InboxTaskRef) => Promise<void>;
29
+ /**
30
+ * A marketplace quest doorbell (ZIG-1185). The host MUST triage with a plain
31
+ * string compare and return without an LLM wake on miss — otherwise every
32
+ * public quest burns a full Claude turn per published supplier.
33
+ */
34
+ onQuest?: (quest: InboxQuestRef) => Promise<void>;
29
35
  label?: string;
30
36
  }
31
37
  export interface InboxPassResult {
@@ -35,10 +41,17 @@ export interface InboxPassResult {
35
41
  resourceEvents: number;
36
42
  /** Assigned open tasks delivered this pass (ZIG-973). */
37
43
  tasksDelivered: number;
44
+ /** Quest refs triaged this pass (ZIG-1185) — includes misses that only ack. */
45
+ questsTriaged: number;
38
46
  /** True when the watermark advanced this pass. */
39
47
  acked: boolean;
40
48
  /** True when there was more mail than one envelope carries. */
41
49
  truncated: boolean;
50
+ /**
51
+ * Handovers into the host that failed this pass (ZIG-1191). Non-zero means the
52
+ * watermark was deliberately held, so the same window arrives again.
53
+ */
54
+ failures: number;
42
55
  }
43
56
  /**
44
57
  * One long-poll pass: hold up to `waitSeconds` for new inbox content, then read
@@ -46,7 +59,9 @@ export interface InboxPassResult {
46
59
  * Reused each iteration of {@link runInboxLoop}; also usable as a single catch-up
47
60
  * read (`waitSeconds: 0`) at bootstrap.
48
61
  */
49
- export declare function pollInboxOnce(deps: InboxLoopDeps, waitSeconds?: number): Promise<InboxPassResult>;
62
+ export declare function pollInboxOnce(deps: InboxLoopDeps, waitSeconds?: number, opts?: {
63
+ forceAck?: boolean;
64
+ }): Promise<InboxPassResult>;
50
65
  export interface InboxLoopHandle {
51
66
  /** Stop the loop after the current pass; resolves when it has fully exited. */
52
67
  stop(): Promise<void>;
@@ -15,7 +15,7 @@ function warn(label, msg) {
15
15
  * reads through `/context/read`, where the agent's own grants fence what it is
16
16
  * allowed to see. A delivery reference is an address, never an authorisation.
17
17
  */
18
- async function deliverMessagesForChat(deps, news, earliestTs, reader) {
18
+ async function deliverMessagesForChat(deps, news, earliestTs, reader, attempt) {
19
19
  let delivered = 0;
20
20
  let remaining = news.count;
21
21
  // `after` is exclusive, so step back a millisecond to include the oldest
@@ -55,8 +55,11 @@ async function deliverMessagesForChat(deps, news, earliestTs, reader) {
55
55
  const text = typeof row.text === 'string' ? row.text : '';
56
56
  if (!text.trim())
57
57
  continue;
58
- await deps.onMessage(text, { ...row, chatId: news.chatId });
59
- delivered++;
58
+ // The host un-marks its own `seen` entry when a message fails, so there is
59
+ // nothing to forget here — but the failure still holds the watermark.
60
+ const handled = await attempt(`chat ${news.chatId} message ${messageId}`, () => deps.onMessage(text, { ...row, chatId: news.chatId }));
61
+ if (handled)
62
+ delivered++;
60
63
  remaining--;
61
64
  }
62
65
  if (!page.hasMore || !page.latestSequence)
@@ -72,7 +75,7 @@ async function deliverMessagesForChat(deps, news, earliestTs, reader) {
72
75
  * Reused each iteration of {@link runInboxLoop}; also usable as a single catch-up
73
76
  * read (`waitSeconds: 0`) at bootstrap.
74
77
  */
75
- export async function pollInboxOnce(deps, waitSeconds = 0) {
78
+ export async function pollInboxOnce(deps, waitSeconds = 0, opts = {}) {
76
79
  const label = deps.label ?? 'inboxLoop';
77
80
  const inbox = new InboxClient(deps.creds.operatorKey, deps.creds.agentId, deps.baseUrl);
78
81
  const reader = new ContextReadClient(deps.creds.operatorKey, deps.creds.agentId, deps.baseUrl);
@@ -80,6 +83,24 @@ export async function pollInboxOnce(deps, waitSeconds = 0) {
80
83
  let messagesDelivered = 0;
81
84
  let resourceEvents = 0;
82
85
  let tasksDelivered = 0;
86
+ let questsTriaged = 0;
87
+ let failures = 0;
88
+ // ZIG-1191: a handover that failed must not be acked, and must not be
89
+ // remembered as seen — otherwise the redelivery the held watermark buys is
90
+ // dropped as a duplicate. One failure does not abandon the rest of the
91
+ // envelope: the others are still delivered, and the watermark waits for all.
92
+ const attempt = async (what, handover, forget) => {
93
+ try {
94
+ await handover();
95
+ return true;
96
+ }
97
+ catch (err) {
98
+ failures++;
99
+ forget?.();
100
+ warn(label, `${what} was not handled: ${err?.message ?? err} — holding the ack`);
101
+ return false;
102
+ }
103
+ };
83
104
  if (envelope.deliveriesCapped) {
84
105
  // Informational only — does NOT block acking what WAS handed over. The
85
106
  // tail stays unacked and arrives on the next pass; never acking here would
@@ -97,7 +118,7 @@ export async function pollInboxOnce(deps, waitSeconds = 0) {
97
118
  }
98
119
  for (const news of envelope.chats) {
99
120
  const earliest = earliestByChat.get(news.chatId) ?? news.latestAt;
100
- const n = await deliverMessagesForChat(deps, news, earliest, reader);
121
+ const n = await deliverMessagesForChat(deps, news, earliest, reader, attempt);
101
122
  messagesDelivered += n;
102
123
  if (n > 0)
103
124
  log(label, `chat ${news.chatId} delivered ${n} message(s)`);
@@ -105,14 +126,16 @@ export async function pollInboxOnce(deps, waitSeconds = 0) {
105
126
  // Everything that is not chat text is handed over as a resource event, from
106
127
  // the delivery itself — the reference already carries the ids the host needs,
107
128
  // so nothing has to be re-derived to name what changed.
129
+ // Quests must never take this path (ZIG-1185) — even if a pre-cut server
130
+ // left them in `deliveries`, skip so they cannot wake the LLM before triage.
108
131
  for (const d of envelope.deliveries) {
109
- if (d.kind === 'message')
132
+ if (d.kind === 'message' || d.kind === 'quest')
110
133
  continue;
111
134
  const key = `delivery:${d.kind}:${d.resourceId}:${d.ts}`;
112
135
  if (deps.seen.has(key))
113
136
  continue;
114
137
  deps.seen.mark(key);
115
- await deps.onResourceEvent({
138
+ const handled = await attempt(`${d.kind} ${d.resourceId}`, () => deps.onResourceEvent({
116
139
  kind: d.kind,
117
140
  ts: d.ts,
118
141
  resourceId: d.resourceId,
@@ -120,8 +143,9 @@ export async function pollInboxOnce(deps, waitSeconds = 0) {
120
143
  ...(d.agreementId ? { agreementId: d.agreementId } : {}),
121
144
  ...(d.taskId ? { taskId: d.taskId } : {}),
122
145
  change: 'created',
123
- });
124
- resourceEvents++;
146
+ }), () => deps.seen.unmark(key));
147
+ if (handled)
148
+ resourceEvents++;
125
149
  }
126
150
  // ZIG-973: the work channel. An open assigned task is redelivered on every
127
151
  // pass until it closes (that IS the durable-wait contract), so dedup on
@@ -133,13 +157,35 @@ export async function pollInboxOnce(deps, waitSeconds = 0) {
133
157
  continue;
134
158
  deps.seen.mark(key);
135
159
  if (deps.onTask) {
136
- await deps.onTask(task);
137
- tasksDelivered++;
160
+ const handled = await attempt(`task ${task.taskId}`, () => deps.onTask(task), () => deps.seen.unmark(key));
161
+ if (handled)
162
+ tasksDelivered++;
138
163
  }
139
164
  }
140
165
  if (envelope.truncatedTasks) {
141
166
  warn(label, `inbox truncatedTasks=${envelope.truncatedTasks} (more assigned tasks than one pass surfaces — they appear as earlier ones close)`);
142
167
  }
168
+ // ZIG-1185: quest channel. Triage is the host's job (exact string compare);
169
+ // a miss must still count as handled so the watermark can advance with 0 tokens.
170
+ for (const quest of envelope.questsAwaitingMe ?? []) {
171
+ const key = `quest:${quest.agreementId}:${quest.ts}`;
172
+ if (deps.seen.has(key))
173
+ continue;
174
+ deps.seen.mark(key);
175
+ if (deps.onQuest) {
176
+ const handled = await attempt(`quest ${quest.agreementId}`, () => deps.onQuest(quest), () => deps.seen.unmark(key));
177
+ if (handled)
178
+ questsTriaged++;
179
+ }
180
+ else {
181
+ // No handler → still count as handled (ack) so an old host cannot wedge
182
+ // forever on quest mail it does not understand.
183
+ questsTriaged++;
184
+ }
185
+ }
186
+ if (envelope.truncatedQuests) {
187
+ warn(label, `inbox truncatedQuests=${envelope.truncatedQuests} (more quests than one pass surfaces — the tail follows on the next pass)`);
188
+ }
143
189
  // Pending decisions are standing state, not deliveries: they keep appearing
144
190
  // until answered, so dedup them the same way tasks are deduped.
145
191
  for (const proposal of envelope.proposalsAwaitingMe) {
@@ -147,36 +193,60 @@ export async function pollInboxOnce(deps, waitSeconds = 0) {
147
193
  if (deps.seen.has(key))
148
194
  continue;
149
195
  deps.seen.mark(key);
150
- await deps.onResourceEvent({
196
+ const handled = await attempt(`pending proposal ${proposal.agreementId}`, () => deps.onResourceEvent({
151
197
  kind: 'agreement',
152
198
  ts: proposal.proposedAt ?? new Date().toISOString(),
153
199
  resourceId: proposal.agreementId,
154
200
  agreementId: proposal.agreementId,
155
201
  change: 'created',
156
202
  reason: 'proposal_pending',
157
- });
158
- resourceEvents++;
203
+ }), () => deps.seen.unmark(key));
204
+ if (handled)
205
+ resourceEvents++;
159
206
  }
160
207
  // Act, then ack: everything above has been handed to the host, so the
161
208
  // watermark may now move past it. A crash before this point redelivers.
209
+ //
210
+ // ZIG-1191: "act" now means acted, not attempted. A pass with failures holds
211
+ // the watermark so the server redelivers the window — unless `forceAck` says
212
+ // this window has failed too many times in a row and is now poison, in which
213
+ // case it is dropped loudly rather than wedging the agent forever.
162
214
  let acked = false;
163
- if (envelope.ackTo) {
215
+ if (envelope.ackTo && (failures === 0 || opts.forceAck)) {
216
+ if (failures > 0) {
217
+ runtimeLog.error(label, `acking a window that failed ${failures} handover(s) — giving up on redelivery to keep the ` +
218
+ `agent alive; this mail is lost (ackTo=${envelope.ackTo})`);
219
+ }
164
220
  await inbox.ack(envelope.ackTo);
165
221
  acked = true;
166
222
  log(label, `acked up to ${envelope.ackTo}`);
167
223
  }
224
+ else if (envelope.ackTo) {
225
+ warn(label, `holding the watermark at ${envelope.ackTo} — ${failures} handover(s) failed this pass, so the ` +
226
+ 'window will be redelivered');
227
+ }
168
228
  return {
169
229
  deliveries: envelope.deliveries.length,
170
230
  messagesDelivered,
171
231
  resourceEvents,
172
232
  tasksDelivered,
233
+ questsTriaged,
173
234
  acked,
174
235
  truncated: envelope.deliveriesCapped,
236
+ failures,
175
237
  };
176
238
  }
177
239
  const DEFAULT_WAIT_SECONDS = 25;
178
240
  const ERROR_BACKOFF_BASE_MS = 1_000;
179
241
  const ERROR_BACKOFF_MAX_MS = 30_000;
242
+ /**
243
+ * How many passes in a row may hold the watermark before the window is treated
244
+ * as poison and acked away (ZIG-1191). Three is enough for the failures we have
245
+ * actually seen — a redeployed container, a wedged backend, a transient throw —
246
+ * and short enough that a frame the host can never handle costs three wakes
247
+ * rather than an unbounded loop.
248
+ */
249
+ const MAX_HELD_PASSES = 3;
180
250
  function sleep(ms) {
181
251
  return new Promise((resolve) => setTimeout(resolve, ms));
182
252
  }
@@ -197,13 +267,19 @@ export function runInboxLoop(deps, opts = {}) {
197
267
  const waitSeconds = opts.waitSeconds ?? DEFAULT_WAIT_SECONDS;
198
268
  let running = true;
199
269
  let consecutiveErrors = 0;
270
+ let heldPasses = 0;
200
271
  let first = true;
201
272
  const done = (async () => {
202
273
  while (running) {
203
274
  try {
204
- await pollInboxOnce(deps, first ? 0 : waitSeconds);
275
+ const pass = await pollInboxOnce(deps, first ? 0 : waitSeconds, {
276
+ forceAck: heldPasses >= MAX_HELD_PASSES,
277
+ });
205
278
  first = false;
206
279
  consecutiveErrors = 0;
280
+ // A held pass means the same window comes back; count how many times we
281
+ // have refused it so a permanently unhandleable frame cannot spin here.
282
+ heldPasses = pass.failures > 0 && !pass.acked ? heldPasses + 1 : 0;
207
283
  }
208
284
  catch (err) {
209
285
  if (!running)
@@ -0,0 +1,72 @@
1
+ /**
2
+ * PollKind — polling as a declared, pluggable thing, the way tools are.
3
+ *
4
+ * An agent lists the polls it depends on the same way it lists tools:
5
+ *
6
+ * polls: [{ kind: 'sente-email', identityId: 'idt_...' }]
7
+ *
8
+ * It declares; it does not execute. Execution belongs to PollRunner, which the
9
+ * launcher runs once for the whole fleet — polling is a fleet concern the way
10
+ * the health server is, not a per-host behaviour: one loop per (agent, binding)
11
+ * with its own interval, backoff and backpressure, instead of every host
12
+ * hand-rolling a timer inside its message loop. Declaration travels with the
13
+ * agent; execution does not.
14
+ *
15
+ * A poll answers ONE question: is there work for this agent right now. It never
16
+ * returns the work itself. The agent reads that for itself once woken, through
17
+ * its own tools, so it cannot end up with a second, differently-shaped copy of
18
+ * its input and no way to tell which is current.
19
+ *
20
+ * WHERE THE CURSOR LIVES: in the source, whenever the source has one. Sente
21
+ * has `archived` on a message, so "already handled" is a fact about the
22
+ * mailbox, not something we mirror. A kind whose source has no such notion has
23
+ * to keep a cursor, and then it is the kind's business — never the agent's.
24
+ */
25
+ /**
26
+ * Kind-specific config, verbatim from the agent's manifest.
27
+ *
28
+ * `chatId` is not kind-specific and is not optional in practice: a wake has to
29
+ * resolve to a lane or AgentHost's session resolver returns null and drops it
30
+ * silently. A poll has no engagement behind it to derive one from, so the
31
+ * binding names the chat the agent thinks in — which also puts its triage
32
+ * somewhere a human can watch. PollRunner refuses to register a binding without
33
+ * one rather than let the wake evaporate at runtime.
34
+ */
35
+ export interface PollBinding {
36
+ kind: string;
37
+ /** Chat the poll wake is delivered into. Required; see above. */
38
+ chatId?: string;
39
+ [key: string]: unknown;
40
+ }
41
+ export interface PollContext {
42
+ agentId: string;
43
+ operatorKey?: string;
44
+ }
45
+ export interface PollResult {
46
+ /** Whether the agent should be woken now. */
47
+ actionable: boolean;
48
+ /**
49
+ * One line telling the agent why it woke. Carries no payload — see above.
50
+ * Omit and the runner sends the kind's default.
51
+ */
52
+ hint?: string;
53
+ }
54
+ export interface PollKind {
55
+ id: string;
56
+ /** How often to run this kind. The runner's default applies when omitted. */
57
+ intervalMs?: number;
58
+ poll(binding: PollBinding, ctx: PollContext): Promise<PollResult>;
59
+ }
60
+ /**
61
+ * Kinds register here rather than being imported by the runner, so a
62
+ * source-specific kind ships with the agent that needs it and the SDK stays
63
+ * free of third-party dependencies.
64
+ */
65
+ export declare class PollRegistry {
66
+ private readonly kinds;
67
+ register(kind: PollKind): this;
68
+ get(id: string): PollKind | undefined;
69
+ get ids(): string[];
70
+ }
71
+ /** Read an agent's declared polls off its config. Absent or malformed → none. */
72
+ export declare function declaredPolls(config: Record<string, unknown>): PollBinding[];
@@ -0,0 +1,53 @@
1
+ /**
2
+ * PollKind — polling as a declared, pluggable thing, the way tools are.
3
+ *
4
+ * An agent lists the polls it depends on the same way it lists tools:
5
+ *
6
+ * polls: [{ kind: 'sente-email', identityId: 'idt_...' }]
7
+ *
8
+ * It declares; it does not execute. Execution belongs to PollRunner, which the
9
+ * launcher runs once for the whole fleet — polling is a fleet concern the way
10
+ * the health server is, not a per-host behaviour: one loop per (agent, binding)
11
+ * with its own interval, backoff and backpressure, instead of every host
12
+ * hand-rolling a timer inside its message loop. Declaration travels with the
13
+ * agent; execution does not.
14
+ *
15
+ * A poll answers ONE question: is there work for this agent right now. It never
16
+ * returns the work itself. The agent reads that for itself once woken, through
17
+ * its own tools, so it cannot end up with a second, differently-shaped copy of
18
+ * its input and no way to tell which is current.
19
+ *
20
+ * WHERE THE CURSOR LIVES: in the source, whenever the source has one. Sente
21
+ * has `archived` on a message, so "already handled" is a fact about the
22
+ * mailbox, not something we mirror. A kind whose source has no such notion has
23
+ * to keep a cursor, and then it is the kind's business — never the agent's.
24
+ */
25
+ /**
26
+ * Kinds register here rather than being imported by the runner, so a
27
+ * source-specific kind ships with the agent that needs it and the SDK stays
28
+ * free of third-party dependencies.
29
+ */
30
+ export class PollRegistry {
31
+ kinds = new Map();
32
+ register(kind) {
33
+ if (!kind?.id)
34
+ throw new Error('PollRegistry: a poll kind needs an id');
35
+ if (this.kinds.has(kind.id))
36
+ throw new Error(`PollRegistry: duplicate poll kind "${kind.id}"`);
37
+ this.kinds.set(kind.id, kind);
38
+ return this;
39
+ }
40
+ get(id) {
41
+ return this.kinds.get(id);
42
+ }
43
+ get ids() {
44
+ return [...this.kinds.keys()];
45
+ }
46
+ }
47
+ /** Read an agent's declared polls off its config. Absent or malformed → none. */
48
+ export function declaredPolls(config) {
49
+ const raw = config['polls'];
50
+ if (!Array.isArray(raw))
51
+ return [];
52
+ return raw.filter((b) => !!b && typeof b === 'object' && typeof b.kind === 'string');
53
+ }
@@ -0,0 +1,62 @@
1
+ /**
2
+ * PollRunner — executes declared polls for a whole fleet, outside any host.
3
+ *
4
+ * Every host already long-polls its own Ziggs inbox; this applies the same wake
5
+ * discipline to the sources Ziggs cannot see — mailboxes, feeds, whatever a
6
+ * PollKind can ask. It runs in the launcher rather than inside any AgentHost
7
+ * because none of it belongs to a host's message loop: a poll is per
8
+ * (agent, binding) and carries its own interval, backoff and backpressure.
9
+ *
10
+ * One loop per (agent, binding), and each loop AWAITS its own wake before
11
+ * scheduling the next tick. That is the whole backpressure story: while an
12
+ * agent is still dealing with what it was woken for, its next poll has not
13
+ * happened yet, so it cannot be woken twice for the same thing. No in-flight
14
+ * bookkeeping, no dedup table — the source is authoritative and a poll that
15
+ * has not run yet cannot report anything stale.
16
+ *
17
+ * A failing source backs off exponentially and recovers on the first success.
18
+ * Without it a source that is down is hit at full rate indefinitely, which is
19
+ * how you get rate-limited by the thing you were trying to read.
20
+ */
21
+ import { type PollRegistry } from './PollKind.js';
22
+ /** The slice of ConnectionPool this needs. Kept narrow to avoid a cycle. */
23
+ export interface WakeTarget {
24
+ sendTo(agentId: string, text: string, metadata?: Record<string, unknown>, opts?: {
25
+ timeoutMs?: number;
26
+ }): Promise<unknown>;
27
+ }
28
+ export interface PollRunnerOptions {
29
+ pool: WakeTarget;
30
+ registry: PollRegistry;
31
+ /** Registered agent configs — anything carrying `agentId` and maybe `polls`. */
32
+ agents: Record<string, unknown>[];
33
+ operatorKey?: string;
34
+ defaultIntervalMs?: number;
35
+ /** Ceiling on the failure backoff. Default 15 minutes. */
36
+ maxBackoffMs?: number;
37
+ /**
38
+ * How long a woken agent may take before the wake is treated as hung.
39
+ * Defaults to 5 minutes, matching the Claude-brain tick timeout — the pool's
40
+ * own `sendTo` default is 30s, which a legitimate multi-turn wake blows
41
+ * through every single time.
42
+ */
43
+ wakeTimeoutMs?: number;
44
+ label?: string;
45
+ }
46
+ export declare class PollRunner {
47
+ private readonly pool;
48
+ private readonly operatorKey?;
49
+ private readonly label;
50
+ private readonly maxBackoffMs;
51
+ private readonly wakeTimeoutMs;
52
+ private readonly loops;
53
+ private running;
54
+ constructor({ pool, registry, agents, operatorKey, defaultIntervalMs, maxBackoffMs, wakeTimeoutMs, label, }: PollRunnerOptions);
55
+ get size(): number;
56
+ start(): void;
57
+ stop(): void;
58
+ private schedule;
59
+ /** Interval while healthy; doubling up to the ceiling while failing. */
60
+ private nextDelay;
61
+ private tick;
62
+ }