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.
- package/dist/connection.d.ts +10 -0
- package/dist/connection.js +16 -0
- package/dist/sellerDaemon.js +92 -1
- package/package.json +1 -1
package/dist/connection.d.ts
CHANGED
|
@@ -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>>;
|
package/dist/connection.js
CHANGED
|
@@ -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
|
+
}
|
package/dist/sellerDaemon.js
CHANGED
|
@@ -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)}`);
|