zyndo 0.3.0 → 0.3.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.
@@ -84,3 +84,13 @@ export declare function deliverTask(session: AgentSession, taskId: string, conte
84
84
  export declare function sendTaskMessage(session: AgentSession, taskId: string, type: 'question' | 'answer' | 'info', content: string): Promise<void>;
85
85
  export declare function getTaskMessages(session: AgentSession, taskId: string): Promise<ReadonlyArray<TaskMessage>>;
86
86
  export declare function getTaskDetail(session: AgentSession, taskId: string): Promise<TaskDetail | undefined>;
87
+ /**
88
+ * List every task the broker has on record for this agent (both as buyer and
89
+ * seller). Used by the seller daemon to reconcile against its event cursor:
90
+ * when an event is missed (broker restart, network partition, lost SSE), the
91
+ * reconciliation pass picks up any `submitted` task assigned to us that the
92
+ * event stream failed to deliver and drives it to `accepted`. Without this the
93
+ * seller can appear "online" (heartbeat OK, polling OK) while silently leaving
94
+ * a buyer's task stuck forever.
95
+ */
96
+ export declare function listAgentTasks(session: AgentSession): Promise<ReadonlyArray<TaskDetail>>;
@@ -154,3 +154,19 @@ export async function getTaskDetail(session, taskId) {
154
154
  return undefined;
155
155
  return (await res.json());
156
156
  }
157
+ /**
158
+ * List every task the broker has on record for this agent (both as buyer and
159
+ * seller). Used by the seller daemon to reconcile against its event cursor:
160
+ * when an event is missed (broker restart, network partition, lost SSE), the
161
+ * reconciliation pass picks up any `submitted` task assigned to us that the
162
+ * event stream failed to deliver and drives it to `accepted`. Without this the
163
+ * seller can appear "online" (heartbeat OK, polling OK) while silently leaving
164
+ * a buyer's task stuck forever.
165
+ */
166
+ export async function listAgentTasks(session) {
167
+ const res = await jsonGet(`${session.bridgeUrl}/agent/tasks`, session.token);
168
+ if (!res.ok)
169
+ return [];
170
+ const data = (await res.json());
171
+ return data.tasks ?? [];
172
+ }
@@ -2,7 +2,7 @@
2
2
  // Seller daemon — poll for tasks, run agent loop, deliver results
3
3
  // ---------------------------------------------------------------------------
4
4
  import { resolve } from 'node:path';
5
- import { connect, reconnect, heartbeat, pollEvents, acceptTask, deliverTask, sendTaskMessage, getTaskMessages, getTaskDetail, registerIdentity } from './connection.js';
5
+ import { connect, reconnect, heartbeat, pollEvents, acceptTask, deliverTask, sendTaskMessage, getTaskMessages, getTaskDetail, listAgentTasks, registerIdentity } from './connection.js';
6
6
  import { ensureIdentityKeypair, signDelivery } from './identity.js';
7
7
  import { composeSystemPrompt, truncateToContract } from './scopeContract.js';
8
8
  import { runAgentLoop } from './agentLoop.js';
@@ -19,6 +19,11 @@ import { createAskBuyerTool } from './tools/askBuyer.js';
19
19
  import { loadState, saveState, deleteState, loadSession, saveSession, loadLastEventId, saveLastEventId } from './state.js';
20
20
  const POLL_INTERVAL_MS = 25_000;
21
21
  const HEARTBEAT_INTERVAL_MS = 45_000;
22
+ // Every N poll cycles, reconcile the local active-task set against the
23
+ // broker's authoritative task list. This catches missed `task.assigned`
24
+ // events caused by broker restarts (in-memory event queue reset), network
25
+ // partitions, or dropped SSE frames. 8 cycles at 25s = ~200s safety net.
26
+ const RECONCILE_EVERY_N_POLLS = 8;
22
27
  // ---------------------------------------------------------------------------
23
28
  // Reconnect error classification (incident 2026-04-09 follow-up)
24
29
  //
@@ -164,6 +169,72 @@ export async function startSellerDaemon(config, opts) {
164
169
  let lastEventId = loadLastEventId();
165
170
  let lastHeartbeat = Date.now();
166
171
  const activeTasks = new Set();
172
+ let pollsSinceReconcile = 0;
173
+ /**
174
+ * Reconcile the local active-task set against the broker's authoritative
175
+ * task list. Picks up any `submitted` task assigned to this seller that we
176
+ * do not already have in flight, regardless of whether we ever saw the
177
+ * matching `task.assigned` event. This is the recovery path for missed
178
+ * events (broker restart wipes the in-memory event queue, network drop
179
+ * loses an event frame, etc.).
180
+ *
181
+ * Also handles `input-required` tasks in `question` input-type (buyer
182
+ * answered a prior question we never got the event for) and
183
+ * `working` tasks we have no record of (we crashed mid-task and need to
184
+ * resume — but without saved state the safest action is to notify the
185
+ * buyer; we just re-add to activeTasks so we do not re-accept).
186
+ */
187
+ async function reconcileTasks(reason) {
188
+ const activeSession = session;
189
+ if (activeSession === undefined)
190
+ return;
191
+ try {
192
+ const tasks = await listAgentTasks(activeSession);
193
+ let picked = 0;
194
+ for (const task of tasks) {
195
+ if (task.sellerAgentId !== activeSession.agentId)
196
+ continue;
197
+ if (activeTasks.has(task.taskId))
198
+ continue;
199
+ if (task.state === 'submitted') {
200
+ if (activeTasks.size >= config.maxConcurrentTasks) {
201
+ logger.info(`Reconcile: task ${task.taskId} submitted but seller at capacity, will retry.`);
202
+ continue;
203
+ }
204
+ activeTasks.add(task.taskId);
205
+ picked += 1;
206
+ logger.info(`Reconcile (${reason}): picking up stuck submitted task ${task.taskId}`);
207
+ handleTask(activeSession, task.taskId, config, logger, identityPrivateKey)
208
+ .catch((err) => {
209
+ logger.error(`Reconciled task ${task.taskId} failed: ${err instanceof Error ? err.message : String(err)}`);
210
+ })
211
+ .finally(() => activeTasks.delete(task.taskId));
212
+ continue;
213
+ }
214
+ if (task.state === 'input-required' && task.inputType === 'question') {
215
+ activeTasks.add(task.taskId);
216
+ picked += 1;
217
+ logger.info(`Reconcile (${reason}): picking up input-required task ${task.taskId}`);
218
+ handleBuyerMessage(activeSession, task.taskId, config, logger, identityPrivateKey)
219
+ .catch((err) => {
220
+ logger.error(`Reconciled message for ${task.taskId} failed: ${err instanceof Error ? err.message : String(err)}`);
221
+ })
222
+ .finally(() => activeTasks.delete(task.taskId));
223
+ continue;
224
+ }
225
+ }
226
+ if (picked > 0) {
227
+ logger.info(`Reconcile (${reason}): recovered ${picked} task(s).`);
228
+ }
229
+ }
230
+ catch (err) {
231
+ logger.error(`Reconcile (${reason}) failed: ${err instanceof Error ? err.message : String(err)}`);
232
+ }
233
+ }
234
+ // Startup reconcile — if the broker restarted while this daemon was down
235
+ // (or this daemon is resuming a long-running session across a broker
236
+ // deploy), pick up any submitted task assigned to us on the very first tick.
237
+ await reconcileTasks('startup');
167
238
  while (signal === undefined || !signal.aborted) {
168
239
  try {
169
240
  // Heartbeat
@@ -197,6 +268,14 @@ export async function startSellerDaemon(config, opts) {
197
268
  logger.info(`Reconnected successfully (attempt ${attempt + 1}).`);
198
269
  lastHeartbeat = Date.now();
199
270
  reconnected = true;
271
+ // Force a reconcile on the next tick: the broker may have
272
+ // restarted and wiped its in-memory event queue, so our
273
+ // lastEventId cursor is now stale relative to the fresh
274
+ // nextEventId counter. Without this the seller polls forever
275
+ // and never sees task.assigned events that happened while we
276
+ // were disconnected or that were issued after the reset.
277
+ pollsSinceReconcile = RECONCILE_EVERY_N_POLLS;
278
+ await reconcileTasks('reconnect');
200
279
  break;
201
280
  }
202
281
  catch (reconnectError) {
@@ -232,6 +311,8 @@ export async function startSellerDaemon(config, opts) {
232
311
  logger.info(`Re-registered: agentId=${session.agentId}`);
233
312
  lastHeartbeat = Date.now();
234
313
  reconnected = true;
314
+ pollsSinceReconcile = RECONCILE_EVERY_N_POLLS;
315
+ await reconcileTasks('re-register');
235
316
  }
236
317
  catch (freshErr) {
237
318
  const msg = freshErr instanceof Error ? freshErr.message : String(freshErr);
@@ -330,6 +411,16 @@ export async function startSellerDaemon(config, opts) {
330
411
  }
331
412
  // Persist cursor so restarts resume from here
332
413
  saveLastEventId(lastEventId);
414
+ // Periodic reconcile: even without a reconnect, poll the task list as a
415
+ // safety net. If a single `task.assigned` event was dropped (network
416
+ // partition, broker partial outage, client-side parse error) the seller
417
+ // would otherwise leave a buyer stuck indefinitely. Every N polls we
418
+ // re-check the authoritative task list and pick up anything missed.
419
+ pollsSinceReconcile += 1;
420
+ if (pollsSinceReconcile >= RECONCILE_EVERY_N_POLLS) {
421
+ pollsSinceReconcile = 0;
422
+ await reconcileTasks('periodic');
423
+ }
333
424
  }
334
425
  catch (error) {
335
426
  logger.error(`Poll error: ${error instanceof Error ? error.message : String(error)}`);
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "zyndo",
3
- "version": "0.3.0",
3
+ "version": "0.3.1",
4
4
  "description": "The agent-to-agent CLI tool for sellers in the Zyndo Marketplace",
5
5
  "type": "module",
6
6
  "license": "MIT",