@ziggs-ai/agent-sdk 0.10.0 → 0.10.1

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 (41) hide show
  1. package/CHANGELOG.md +23 -0
  2. package/dist/AgentHost.d.ts +50 -14
  3. package/dist/AgentHost.js +129 -7
  4. package/dist/agent/ClaudeSdkAgent.d.ts +23 -1
  5. package/dist/agent/ClaudeSdkAgent.js +145 -75
  6. package/dist/context/applyEffects.js +4 -3
  7. package/dist/index.d.ts +3 -2
  8. package/dist/index.js +3 -2
  9. package/dist/runtime/bootstrapCtx.js +6 -8
  10. package/dist/runtime/buildOutcome.d.ts +9 -1
  11. package/dist/runtime/buildOutcome.js +29 -6
  12. package/dist/runtime/taskIntake.js +6 -4
  13. package/dist/runtime/wakeWriteKeys.js +14 -21
  14. package/dist/server/ConnectionPool.d.ts +10 -52
  15. package/dist/server/ConnectionPool.js +26 -57
  16. package/dist/server/EventQueue.d.ts +6 -0
  17. package/dist/server/EventQueue.js +21 -6
  18. package/dist/server/SeenMessages.d.ts +7 -0
  19. package/dist/server/SeenMessages.js +15 -0
  20. package/dist/server/ZiggsEffectHandler.d.ts +1 -0
  21. package/dist/server/ZiggsEffectHandler.js +14 -7
  22. package/dist/server/inboxLoop.d.ts +17 -2
  23. package/dist/server/inboxLoop.js +92 -16
  24. package/dist/server/polls/PollKind.d.ts +72 -0
  25. package/dist/server/polls/PollKind.js +53 -0
  26. package/dist/server/polls/PollRunner.d.ts +62 -0
  27. package/dist/server/polls/PollRunner.js +138 -0
  28. package/dist/server/questMatch.d.ts +5 -0
  29. package/dist/server/questMatch.js +14 -0
  30. package/dist/server/runLauncher.d.ts +0 -6
  31. package/dist/server/runLauncher.js +54 -27
  32. package/dist/server/tools/tier1/protocolRunner.d.ts +3 -1
  33. package/dist/server/tools/tier1/protocolRunner.js +24 -28
  34. package/dist/server/tools/tier1/protocolTools.js +23 -13
  35. package/dist/server/tools/tier2/connectionTools.js +12 -1
  36. package/dist/tasks/protocolRegistry.d.ts +27 -0
  37. package/dist/tasks/protocolRegistry.js +36 -0
  38. package/dist/types.d.ts +0 -9
  39. package/package.json +2 -2
  40. package/dist/server/staleBacklogGate.d.ts +0 -57
  41. package/dist/server/staleBacklogGate.js +0 -100
@@ -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
+ }
@@ -0,0 +1,138 @@
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 { runtimeLog } from '../../shared/runtimeLog.js';
22
+ import { declaredPolls } from './PollKind.js';
23
+ export class PollRunner {
24
+ pool;
25
+ operatorKey;
26
+ label;
27
+ maxBackoffMs;
28
+ wakeTimeoutMs;
29
+ loops = [];
30
+ running = false;
31
+ constructor({ pool, registry, agents, operatorKey, defaultIntervalMs = 60_000, maxBackoffMs = 15 * 60_000, wakeTimeoutMs = Number(process.env.CLAUDE_SDK_TICK_TIMEOUT_MS ?? 300_000), label = 'PollRunner', }) {
32
+ this.pool = pool;
33
+ this.operatorKey = operatorKey;
34
+ this.label = label;
35
+ this.maxBackoffMs = maxBackoffMs;
36
+ this.wakeTimeoutMs = wakeTimeoutMs;
37
+ for (const config of agents) {
38
+ const agentId = config['agentId'];
39
+ if (typeof agentId !== 'string' || !agentId)
40
+ continue;
41
+ for (const binding of declaredPolls(config)) {
42
+ const kind = registry.get(binding.kind);
43
+ if (!kind) {
44
+ // Loud, and does not stop the fleet: an agent declaring a kind nobody
45
+ // registered would otherwise sit there looking healthy and never wake.
46
+ runtimeLog.warn(label, `${agentId} declares unknown poll kind "${binding.kind}" — it will never be woken by it (registered: ${registry.ids.join(', ') || 'none'})`);
47
+ continue;
48
+ }
49
+ if (!binding.chatId) {
50
+ // Refused at startup rather than dropped per-wake: without a lane the
51
+ // host's session resolver returns null and the wake disappears with
52
+ // only a debug line, which looks exactly like "the source is quiet".
53
+ runtimeLog.warn(label, `${agentId} declares poll "${binding.kind}" without a chatId — refusing to register it, because every wake would be silently dropped`);
54
+ continue;
55
+ }
56
+ this.loops.push({
57
+ agentId,
58
+ binding,
59
+ kind,
60
+ intervalMs: kind.intervalMs ?? defaultIntervalMs,
61
+ chatId: binding.chatId,
62
+ timer: null,
63
+ failures: 0,
64
+ });
65
+ }
66
+ }
67
+ }
68
+ get size() {
69
+ return this.loops.length;
70
+ }
71
+ start() {
72
+ if (this.running)
73
+ return;
74
+ this.running = true;
75
+ if (!this.loops.length)
76
+ return;
77
+ for (const loop of this.loops)
78
+ this.schedule(loop, 0);
79
+ runtimeLog.info(this.label, `started — ${this.loops.length} poll(s): ${this.loops.map((l) => `${l.agentId}:${l.kind.id}`).join(', ')}`);
80
+ }
81
+ stop() {
82
+ this.running = false;
83
+ for (const loop of this.loops) {
84
+ if (loop.timer)
85
+ clearTimeout(loop.timer);
86
+ loop.timer = null;
87
+ }
88
+ runtimeLog.info(this.label, 'stopped');
89
+ }
90
+ schedule(loop, delayMs = this.nextDelay(loop)) {
91
+ if (!this.running)
92
+ return;
93
+ loop.timer = setTimeout(() => void this.tick(loop), delayMs);
94
+ }
95
+ /** Interval while healthy; doubling up to the ceiling while failing. */
96
+ nextDelay(loop) {
97
+ if (loop.failures === 0)
98
+ return loop.intervalMs;
99
+ return Math.min(loop.intervalMs * 2 ** loop.failures, this.maxBackoffMs);
100
+ }
101
+ async tick(loop) {
102
+ if (!this.running)
103
+ return;
104
+ let result;
105
+ try {
106
+ result = await loop.kind.poll(loop.binding, {
107
+ agentId: loop.agentId,
108
+ ...(this.operatorKey ? { operatorKey: this.operatorKey } : {}),
109
+ });
110
+ loop.failures = 0;
111
+ }
112
+ catch (err) {
113
+ loop.failures += 1;
114
+ runtimeLog.error(this.label, `${loop.agentId}:${loop.kind.id} source failed (${loop.failures}x, next in ${this.nextDelay(loop)}ms): ${err.message}`);
115
+ this.schedule(loop);
116
+ return;
117
+ }
118
+ if (result?.actionable) {
119
+ const hint = result.hint ?? `New work from ${loop.kind.id}. Check it.`;
120
+ try {
121
+ // Awaited on purpose — see the header. The next tick waits for the
122
+ // agent to finish, which is what stops a double wake. chatId is what
123
+ // makes the wake resolve to a lane at all, and wakeTimeoutMs has to
124
+ // outlast a real multi-turn wake or the await stops meaning anything.
125
+ await this.pool.sendTo(loop.agentId, hint, { chatId: loop.chatId, pollKind: loop.kind.id }, { timeoutMs: this.wakeTimeoutMs });
126
+ runtimeLog.info(this.label, `woke ${loop.agentId} for ${loop.kind.id}`);
127
+ }
128
+ catch (err) {
129
+ // Reported separately from a source failure: the source is fine, the
130
+ // agent is not. Still backs off, because hammering a wedged agent helps
131
+ // nobody.
132
+ loop.failures += 1;
133
+ runtimeLog.error(this.label, `${loop.agentId} wake failed (${loop.failures}x, next in ${this.nextDelay(loop)}ms): ${err.message}`);
134
+ }
135
+ }
136
+ this.schedule(loop);
137
+ }
138
+ }
@@ -0,0 +1,5 @@
1
+ /**
2
+ * ZIG-1185 — exact-match triage for marketplace quests.
3
+ * Empty match never hits (token-safe default). Comparison is exact after trim.
4
+ */
5
+ export declare function questExactMatch(match: string, tags: ReadonlyArray<string | null | undefined>): boolean;
@@ -0,0 +1,14 @@
1
+ /**
2
+ * ZIG-1185 — exact-match triage for marketplace quests.
3
+ * Empty match never hits (token-safe default). Comparison is exact after trim.
4
+ */
5
+ export function questExactMatch(match, tags) {
6
+ const needle = typeof match === 'string' ? match.trim() : '';
7
+ if (!needle)
8
+ return false;
9
+ for (const t of tags) {
10
+ if (typeof t === 'string' && t.trim() === needle)
11
+ return true;
12
+ }
13
+ return false;
14
+ }
@@ -15,14 +15,8 @@ interface SingleOptions {
15
15
  onShutdown?: () => Promise<void>;
16
16
  }
17
17
  interface FleetOptions extends SingleOptions {
18
- wsUrl?: string;
19
18
  agentMeta?: Record<string, unknown>[];
20
19
  primary?: AgentConfig | ((pool: ConnectionPool) => AgentConfig);
21
- preStart?: string[];
22
- poolOptions?: {
23
- maxActive?: number;
24
- idleTimeoutMs?: number;
25
- };
26
20
  }
27
21
  export declare function runLauncher(configOrConfigs: AgentConfig | AgentConfig[], opts?: FleetOptions): Promise<unknown>;
28
22
  export {};