zyndo 0.2.1 → 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/commands/referee.d.ts +8 -0
- package/dist/commands/referee.js +86 -0
- package/dist/config.d.ts +28 -0
- package/dist/config.js +106 -2
- package/dist/connection.d.ts +37 -1
- package/dist/connection.js +44 -10
- package/dist/identity.d.ts +23 -0
- package/dist/identity.js +51 -0
- package/dist/index.js +6 -0
- package/dist/init.js +346 -10
- package/dist/scopeContract.d.ts +44 -0
- package/dist/scopeContract.js +148 -0
- package/dist/sellerDaemon.js +214 -23
- package/dist/state.d.ts +2 -0
- package/dist/state.js +23 -0
- package/package.json +1 -1
package/dist/sellerDaemon.js
CHANGED
|
@@ -1,7 +1,10 @@
|
|
|
1
1
|
// ---------------------------------------------------------------------------
|
|
2
2
|
// Seller daemon — poll for tasks, run agent loop, deliver results
|
|
3
3
|
// ---------------------------------------------------------------------------
|
|
4
|
-
import {
|
|
4
|
+
import { resolve } from 'node:path';
|
|
5
|
+
import { connect, reconnect, heartbeat, pollEvents, acceptTask, deliverTask, sendTaskMessage, getTaskMessages, getTaskDetail, listAgentTasks, registerIdentity } from './connection.js';
|
|
6
|
+
import { ensureIdentityKeypair, signDelivery } from './identity.js';
|
|
7
|
+
import { composeSystemPrompt, truncateToContract } from './scopeContract.js';
|
|
5
8
|
import { runAgentLoop } from './agentLoop.js';
|
|
6
9
|
import { createAnthropicProvider } from './providers/anthropic.js';
|
|
7
10
|
import { createOpenAIProvider } from './providers/openai.js';
|
|
@@ -13,9 +16,14 @@ import { createBashTool } from './tools/bash.js';
|
|
|
13
16
|
import { createGlobTool } from './tools/glob.js';
|
|
14
17
|
import { createGrepTool } from './tools/grep.js';
|
|
15
18
|
import { createAskBuyerTool } from './tools/askBuyer.js';
|
|
16
|
-
import { loadState, saveState, deleteState, loadSession, saveSession } from './state.js';
|
|
19
|
+
import { loadState, saveState, deleteState, loadSession, saveSession, loadLastEventId, saveLastEventId } from './state.js';
|
|
17
20
|
const POLL_INTERVAL_MS = 25_000;
|
|
18
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;
|
|
19
27
|
// ---------------------------------------------------------------------------
|
|
20
28
|
// Reconnect error classification (incident 2026-04-09 follow-up)
|
|
21
29
|
//
|
|
@@ -91,6 +99,22 @@ function loadTools(allowedTools, workingDirectory) {
|
|
|
91
99
|
export async function startSellerDaemon(config, opts) {
|
|
92
100
|
const logger = opts?.logger ?? defaultLogger;
|
|
93
101
|
const signal = opts?.signal;
|
|
102
|
+
// Slice 3b — load or generate the Ed25519 identity key once. Private key
|
|
103
|
+
// stays in memory for the lifetime of the daemon; the public key is
|
|
104
|
+
// registered with the broker after every fresh connect / re-connect so
|
|
105
|
+
// rotations are a single env change + restart.
|
|
106
|
+
let identityPrivateKey;
|
|
107
|
+
let identityPublicKeyB64;
|
|
108
|
+
try {
|
|
109
|
+
const keyPath = config.identityKeyPath ?? resolve(process.env.HOME ?? '.', '.zyndo', 'keys', `${config.name}.pem`);
|
|
110
|
+
const keypair = ensureIdentityKeypair(keyPath);
|
|
111
|
+
identityPrivateKey = keypair.privateKey;
|
|
112
|
+
identityPublicKeyB64 = keypair.publicKeyB64;
|
|
113
|
+
logger.info(`Identity key loaded: ${keyPath}`);
|
|
114
|
+
}
|
|
115
|
+
catch (err) {
|
|
116
|
+
logger.error(`Identity key setup failed: ${err instanceof Error ? err.message : String(err)}. Continuing unsigned.`);
|
|
117
|
+
}
|
|
94
118
|
// Try to reconnect as the same agent from a previous session
|
|
95
119
|
let session;
|
|
96
120
|
const previousSession = loadSession();
|
|
@@ -123,15 +147,94 @@ export async function startSellerDaemon(config, opts) {
|
|
|
123
147
|
description: config.description,
|
|
124
148
|
skills: [...config.skills],
|
|
125
149
|
categories: [...config.categories],
|
|
126
|
-
maxConcurrentTasks: config.maxConcurrentTasks
|
|
150
|
+
maxConcurrentTasks: config.maxConcurrentTasks,
|
|
151
|
+
sellerSlug: config.id
|
|
127
152
|
});
|
|
128
153
|
logger.info(`Connected: agentId=${session.agentId}`);
|
|
129
154
|
}
|
|
130
155
|
// Persist session for future restarts
|
|
131
156
|
saveSession(session.agentId, session.reconnectToken);
|
|
132
|
-
|
|
157
|
+
// Slice 3b — register the Ed25519 public key with the broker. Failures
|
|
158
|
+
// are logged but non-fatal because signing is soft-enforced until
|
|
159
|
+
// rollout > 80%.
|
|
160
|
+
if (identityPublicKeyB64 !== undefined) {
|
|
161
|
+
try {
|
|
162
|
+
await registerIdentity(session, identityPublicKeyB64);
|
|
163
|
+
logger.info('Identity public key registered with broker.');
|
|
164
|
+
}
|
|
165
|
+
catch (err) {
|
|
166
|
+
logger.error(`Identity registration failed: ${err instanceof Error ? err.message : String(err)}. Deliveries will be unsigned.`);
|
|
167
|
+
}
|
|
168
|
+
}
|
|
169
|
+
let lastEventId = loadLastEventId();
|
|
133
170
|
let lastHeartbeat = Date.now();
|
|
134
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');
|
|
135
238
|
while (signal === undefined || !signal.aborted) {
|
|
136
239
|
try {
|
|
137
240
|
// Heartbeat
|
|
@@ -165,6 +268,14 @@ export async function startSellerDaemon(config, opts) {
|
|
|
165
268
|
logger.info(`Reconnected successfully (attempt ${attempt + 1}).`);
|
|
166
269
|
lastHeartbeat = Date.now();
|
|
167
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');
|
|
168
279
|
break;
|
|
169
280
|
}
|
|
170
281
|
catch (reconnectError) {
|
|
@@ -193,12 +304,15 @@ export async function startSellerDaemon(config, opts) {
|
|
|
193
304
|
description: config.description,
|
|
194
305
|
skills: [...config.skills],
|
|
195
306
|
categories: [...config.categories],
|
|
196
|
-
maxConcurrentTasks: config.maxConcurrentTasks
|
|
307
|
+
maxConcurrentTasks: config.maxConcurrentTasks,
|
|
308
|
+
sellerSlug: config.id
|
|
197
309
|
});
|
|
198
310
|
saveSession(session.agentId, session.reconnectToken);
|
|
199
311
|
logger.info(`Re-registered: agentId=${session.agentId}`);
|
|
200
312
|
lastHeartbeat = Date.now();
|
|
201
313
|
reconnected = true;
|
|
314
|
+
pollsSinceReconcile = RECONCILE_EVERY_N_POLLS;
|
|
315
|
+
await reconcileTasks('re-register');
|
|
202
316
|
}
|
|
203
317
|
catch (freshErr) {
|
|
204
318
|
const msg = freshErr instanceof Error ? freshErr.message : String(freshErr);
|
|
@@ -235,7 +349,7 @@ export async function startSellerDaemon(config, opts) {
|
|
|
235
349
|
if (event.eventId > lastEventId)
|
|
236
350
|
lastEventId = event.eventId;
|
|
237
351
|
logger.info(`Task assigned: ${taskId} (active: ${activeTasks.size}/${config.maxConcurrentTasks})`);
|
|
238
|
-
handleTask(session, taskId, config, logger)
|
|
352
|
+
handleTask(session, taskId, config, logger, identityPrivateKey)
|
|
239
353
|
.catch((err) => {
|
|
240
354
|
logger.error(`Task ${taskId} failed: ${err instanceof Error ? err.message : String(err)}`);
|
|
241
355
|
})
|
|
@@ -252,7 +366,7 @@ export async function startSellerDaemon(config, opts) {
|
|
|
252
366
|
continue; // Task handler is already running
|
|
253
367
|
activeTasks.add(taskId);
|
|
254
368
|
logger.info(`Message received for task: ${taskId}`);
|
|
255
|
-
handleBuyerMessage(session, taskId, config, logger)
|
|
369
|
+
handleBuyerMessage(session, taskId, config, logger, identityPrivateKey)
|
|
256
370
|
.catch((err) => {
|
|
257
371
|
logger.error(`Message handling for ${taskId} failed: ${err instanceof Error ? err.message : String(err)}`);
|
|
258
372
|
})
|
|
@@ -265,12 +379,25 @@ export async function startSellerDaemon(config, opts) {
|
|
|
265
379
|
continue;
|
|
266
380
|
activeTasks.add(taskId);
|
|
267
381
|
logger.info(`Revision requested for task: ${taskId}`);
|
|
268
|
-
handleRevision(session, taskId, feedback, config, logger)
|
|
382
|
+
handleRevision(session, taskId, feedback, config, logger, identityPrivateKey)
|
|
269
383
|
.catch((err) => {
|
|
270
384
|
logger.error(`Revision for ${taskId} failed: ${err instanceof Error ? err.message : String(err)}`);
|
|
271
385
|
})
|
|
272
386
|
.finally(() => activeTasks.delete(taskId));
|
|
273
387
|
}
|
|
388
|
+
// Dispute lifecycle events — log so the seller operator is aware
|
|
389
|
+
if (event.type === 'task.dispute.opened') {
|
|
390
|
+
const taskId = event.payload.taskId;
|
|
391
|
+
const reason = event.payload.reason ?? '';
|
|
392
|
+
const disputeId = event.payload.disputeId ?? '';
|
|
393
|
+
logger.info(`DISPUTE OPENED on task ${taskId} (${disputeId}): ${reason}`);
|
|
394
|
+
}
|
|
395
|
+
if (event.type === 'task.dispute.resolved') {
|
|
396
|
+
const taskId = event.payload.taskId;
|
|
397
|
+
const resolution = event.payload.resolution ?? '';
|
|
398
|
+
const disputeId = event.payload.disputeId ?? '';
|
|
399
|
+
logger.info(`DISPUTE RESOLVED on task ${taskId} (${disputeId}): ${resolution}`);
|
|
400
|
+
}
|
|
274
401
|
// Clean up state on terminal events
|
|
275
402
|
if (event.type === 'task.completed' || event.type === 'task.rejected' || event.type === 'task.failed' || event.type === 'task.canceled') {
|
|
276
403
|
const taskId = event.payload.taskId;
|
|
@@ -282,6 +409,18 @@ export async function startSellerDaemon(config, opts) {
|
|
|
282
409
|
if (minDeferredEventId !== undefined && lastEventId >= minDeferredEventId) {
|
|
283
410
|
lastEventId = minDeferredEventId - 1;
|
|
284
411
|
}
|
|
412
|
+
// Persist cursor so restarts resume from here
|
|
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
|
+
}
|
|
285
424
|
}
|
|
286
425
|
catch (error) {
|
|
287
426
|
logger.error(`Poll error: ${error instanceof Error ? error.message : String(error)}`);
|
|
@@ -293,8 +432,18 @@ export async function startSellerDaemon(config, opts) {
|
|
|
293
432
|
// ---------------------------------------------------------------------------
|
|
294
433
|
// Task handler
|
|
295
434
|
// ---------------------------------------------------------------------------
|
|
296
|
-
async function handleTask(session, taskId, config, logger) {
|
|
297
|
-
|
|
435
|
+
async function handleTask(session, taskId, config, logger, identityPrivateKey) {
|
|
436
|
+
try {
|
|
437
|
+
await acceptTask(session, taskId);
|
|
438
|
+
}
|
|
439
|
+
catch (err) {
|
|
440
|
+
const msg = err instanceof Error ? err.message : String(err);
|
|
441
|
+
if (msg.includes('409')) {
|
|
442
|
+
logger.info(`Task ${taskId}: already in progress or completed, skipping.`);
|
|
443
|
+
return;
|
|
444
|
+
}
|
|
445
|
+
throw err;
|
|
446
|
+
}
|
|
298
447
|
logger.info(`Task ${taskId}: accepted, starting work...`);
|
|
299
448
|
// Get task context from task detail first, then messages as supplement
|
|
300
449
|
const detail = await getTaskDetail(session, taskId);
|
|
@@ -302,9 +451,20 @@ async function handleTask(session, taskId, config, logger) {
|
|
|
302
451
|
const messages = await getTaskMessages(session, taskId);
|
|
303
452
|
const messageContext = messages.map((m) => m.content).join('\n');
|
|
304
453
|
const context = taskContext || messageContext || 'Task assigned. No additional context provided.';
|
|
454
|
+
// Compose the system prompt with the BOUND CONTRACT + REFUSAL PROTOCOL
|
|
455
|
+
// derived from the hire's frozen deliverables snapshot. Seller persona is
|
|
456
|
+
// subordinate; the contract and refusal blocks cannot be overridden by the
|
|
457
|
+
// seller or by anything the buyer says mid-task.
|
|
458
|
+
const scopedSystemPrompt = composeSystemPrompt(config.systemPrompt, detail?.deliverablesSnapshot);
|
|
459
|
+
if (detail?.deliverablesSnapshot === undefined) {
|
|
460
|
+
logger.info(`Task ${taskId}: no deliverables snapshot on task (legacy hire or scope guard disabled), running with seller persona only.`);
|
|
461
|
+
}
|
|
462
|
+
else {
|
|
463
|
+
logger.info(`Task ${taskId}: bound contract applied — ${detail.deliverablesSnapshot.items.length} item(s), ${detail.deliverablesSnapshot.inScope.length} in-scope, ${detail.deliverablesSnapshot.outOfScope.length} out-of-scope.`);
|
|
464
|
+
}
|
|
305
465
|
const result = config.provider === 'claude-code'
|
|
306
|
-
? await runClaudeCodeTask(context, config, logger)
|
|
307
|
-
: await runAgentLoop(createProvider(config.provider, config.model, config.providerApiKey), loadTools(config.allowedTools, config.workingDirectory), context, { systemPrompt:
|
|
466
|
+
? await runClaudeCodeTask(context, { ...config, systemPrompt: scopedSystemPrompt }, logger)
|
|
467
|
+
: await runAgentLoop(createProvider(config.provider, config.model, config.providerApiKey), loadTools(config.allowedTools, config.workingDirectory), context, { systemPrompt: scopedSystemPrompt, taskId });
|
|
308
468
|
if (result.timedOut === true) {
|
|
309
469
|
logger.error(`Task ${taskId}: agent timed out or errored, notifying buyer.`);
|
|
310
470
|
await sendTaskMessage(session, taskId, 'info', 'The seller agent encountered a timeout processing this task. Please allow more time or simplify the request. The task remains assigned.');
|
|
@@ -317,15 +477,29 @@ async function handleTask(session, taskId, config, logger) {
|
|
|
317
477
|
await sendTaskMessage(session, taskId, 'question', result.pendingQuestion ?? 'Could you clarify?');
|
|
318
478
|
return;
|
|
319
479
|
}
|
|
320
|
-
|
|
321
|
-
|
|
480
|
+
// Output-side truncation: count produced units, clamp to contract, log a
|
|
481
|
+
// scope.truncated info message back to the buyer so the seller is not
|
|
482
|
+
// silently abused via prompt injection mid-conversation.
|
|
483
|
+
const truncated = truncateToContract(result.output, detail?.deliverablesSnapshot);
|
|
484
|
+
const finalOutput = truncated.output;
|
|
485
|
+
if (truncated.events.length > 0) {
|
|
486
|
+
for (const ev of truncated.events) {
|
|
487
|
+
logger.info(`Task ${taskId}: scope truncation — item="${ev.itemName}" produced=${ev.producedQuantity} allowed=${ev.allowedQuantity}`);
|
|
488
|
+
await sendTaskMessage(session, taskId, 'info', `[scope-guard] Your brief requested more "${ev.itemName}" than the listing delivers. The seller agent produced ${ev.producedQuantity} but the contract allows only ${ev.allowedQuantity}. The delivery has been trimmed to the contracted quantity. To get more, hire again with a listing that covers the extra volume.`);
|
|
489
|
+
}
|
|
490
|
+
}
|
|
491
|
+
logger.info(`Task ${taskId}: delivering result (${finalOutput.length} chars)`);
|
|
492
|
+
const signature = identityPrivateKey !== undefined
|
|
493
|
+
? signDelivery({ taskId, content: finalOutput, privateKey: identityPrivateKey })
|
|
494
|
+
: undefined;
|
|
495
|
+
await deliverTask(session, taskId, finalOutput, signature);
|
|
322
496
|
// Preserve state for potential revision — only deleted on terminal events
|
|
323
|
-
saveState({ taskId, messages: [], claudeCodeContext: context, originalContext: context, lastDelivery:
|
|
497
|
+
saveState({ taskId, messages: [], claudeCodeContext: context, originalContext: context, lastDelivery: finalOutput });
|
|
324
498
|
}
|
|
325
499
|
// ---------------------------------------------------------------------------
|
|
326
500
|
// Revision handler
|
|
327
501
|
// ---------------------------------------------------------------------------
|
|
328
|
-
async function handleRevision(session, taskId, feedback, config, logger) {
|
|
502
|
+
async function handleRevision(session, taskId, feedback, config, logger, identityPrivateKey) {
|
|
329
503
|
const savedState = loadState(taskId);
|
|
330
504
|
// Use original task context (not compounded revision context) to avoid quadratic growth
|
|
331
505
|
const originalContext = savedState?.originalContext ?? savedState?.claudeCodeContext ?? '';
|
|
@@ -344,9 +518,12 @@ async function handleRevision(session, taskId, feedback, config, logger) {
|
|
|
344
518
|
'',
|
|
345
519
|
'Revise your work based on the buyer feedback above. Output the complete updated deliverable.'
|
|
346
520
|
].join('\n');
|
|
521
|
+
// Re-fetch the frozen snapshot so revisions stay bound to the contract.
|
|
522
|
+
const revisionDetail = await getTaskDetail(session, taskId);
|
|
523
|
+
const revisionSystemPrompt = composeSystemPrompt(config.systemPrompt, revisionDetail?.deliverablesSnapshot);
|
|
347
524
|
const result = config.provider === 'claude-code'
|
|
348
|
-
? await runClaudeCodeTask(revisionContext, config, logger)
|
|
349
|
-
: await runAgentLoop(createProvider(config.provider, config.model, config.providerApiKey), loadTools(config.allowedTools, config.workingDirectory), revisionContext, { systemPrompt:
|
|
525
|
+
? await runClaudeCodeTask(revisionContext, { ...config, systemPrompt: revisionSystemPrompt }, logger)
|
|
526
|
+
: await runAgentLoop(createProvider(config.provider, config.model, config.providerApiKey), loadTools(config.allowedTools, config.workingDirectory), revisionContext, { systemPrompt: revisionSystemPrompt, taskId });
|
|
350
527
|
if (result.timedOut === true) {
|
|
351
528
|
logger.error(`Task ${taskId}: revision timed out, notifying buyer.`);
|
|
352
529
|
await sendTaskMessage(session, taskId, 'info', 'The seller agent timed out while working on the revision. Please allow more time or simplify the request.');
|
|
@@ -359,9 +536,20 @@ async function handleRevision(session, taskId, feedback, config, logger) {
|
|
|
359
536
|
await sendTaskMessage(session, taskId, 'question', result.pendingQuestion ?? 'Could you clarify?');
|
|
360
537
|
return;
|
|
361
538
|
}
|
|
362
|
-
|
|
363
|
-
|
|
364
|
-
|
|
539
|
+
const truncatedRevision = truncateToContract(result.output, revisionDetail?.deliverablesSnapshot);
|
|
540
|
+
const finalRevisionOutput = truncatedRevision.output;
|
|
541
|
+
if (truncatedRevision.events.length > 0) {
|
|
542
|
+
for (const ev of truncatedRevision.events) {
|
|
543
|
+
logger.info(`Task ${taskId}: revision scope truncation — item="${ev.itemName}" produced=${ev.producedQuantity} allowed=${ev.allowedQuantity}`);
|
|
544
|
+
await sendTaskMessage(session, taskId, 'info', `[scope-guard] Revision trimmed: "${ev.itemName}" produced ${ev.producedQuantity}, contract allows ${ev.allowedQuantity}.`);
|
|
545
|
+
}
|
|
546
|
+
}
|
|
547
|
+
logger.info(`Task ${taskId}: delivering revision (${finalRevisionOutput.length} chars)`);
|
|
548
|
+
const revisionSignature = identityPrivateKey !== undefined
|
|
549
|
+
? signDelivery({ taskId, content: finalRevisionOutput, privateKey: identityPrivateKey })
|
|
550
|
+
: undefined;
|
|
551
|
+
await deliverTask(session, taskId, finalRevisionOutput, revisionSignature);
|
|
552
|
+
saveState({ taskId, messages: [], claudeCodeContext: revisionContext, originalContext, lastDelivery: finalRevisionOutput });
|
|
365
553
|
}
|
|
366
554
|
// ---------------------------------------------------------------------------
|
|
367
555
|
// Buyer message handler (resume paused loop)
|
|
@@ -376,7 +564,7 @@ function buildResumedContext(priorContext, priorQuestion, buyerAnswer) {
|
|
|
376
564
|
'Continue the task with this new information.'
|
|
377
565
|
].join('\n');
|
|
378
566
|
}
|
|
379
|
-
async function handleBuyerMessage(session, taskId, config, logger) {
|
|
567
|
+
async function handleBuyerMessage(session, taskId, config, logger, identityPrivateKey) {
|
|
380
568
|
const savedState = loadState(taskId);
|
|
381
569
|
if (savedState === undefined) {
|
|
382
570
|
logger.info(`Task ${taskId}: no saved state found, ignoring message.`);
|
|
@@ -421,7 +609,10 @@ async function handleBuyerMessage(session, taskId, config, logger) {
|
|
|
421
609
|
return;
|
|
422
610
|
}
|
|
423
611
|
logger.info(`Task ${taskId}: delivering result (${result.output.length} chars)`);
|
|
424
|
-
|
|
612
|
+
const resumedSignature = identityPrivateKey !== undefined
|
|
613
|
+
? signDelivery({ taskId, content: result.output, privateKey: identityPrivateKey })
|
|
614
|
+
: undefined;
|
|
615
|
+
await deliverTask(session, taskId, result.output, resumedSignature);
|
|
425
616
|
const resumedContext = config.provider === 'claude-code'
|
|
426
617
|
? buildResumedContext(savedState.claudeCodeContext ?? '', savedState.pendingQuestion, lastMessage.content)
|
|
427
618
|
: savedState.claudeCodeContext ?? '';
|
package/dist/state.d.ts
CHANGED
|
@@ -11,6 +11,8 @@ export type ConversationState = Readonly<{
|
|
|
11
11
|
export declare function saveState(state: ConversationState): void;
|
|
12
12
|
export declare function loadState(taskId: string): ConversationState | undefined;
|
|
13
13
|
export declare function deleteState(taskId: string): void;
|
|
14
|
+
export declare function saveLastEventId(lastEventId: number): void;
|
|
15
|
+
export declare function loadLastEventId(): number;
|
|
14
16
|
export type PersistedSession = Readonly<{
|
|
15
17
|
agentId: string;
|
|
16
18
|
reconnectToken: string;
|
package/dist/state.js
CHANGED
|
@@ -36,6 +36,29 @@ export function deleteState(taskId) {
|
|
|
36
36
|
unlinkSync(path);
|
|
37
37
|
}
|
|
38
38
|
}
|
|
39
|
+
// ---------------------------------------------------------------------------
|
|
40
|
+
// Event cursor persistence — resume from last processed event on restart
|
|
41
|
+
// ---------------------------------------------------------------------------
|
|
42
|
+
function getEventCursorFile() {
|
|
43
|
+
return resolve(getBaseDir(), 'event-cursor.json');
|
|
44
|
+
}
|
|
45
|
+
export function saveLastEventId(lastEventId) {
|
|
46
|
+
const baseDir = getBaseDir();
|
|
47
|
+
mkdirSync(baseDir, { recursive: true });
|
|
48
|
+
writeFileSync(getEventCursorFile(), JSON.stringify({ lastEventId, savedAt: new Date().toISOString() }), 'utf-8');
|
|
49
|
+
}
|
|
50
|
+
export function loadLastEventId() {
|
|
51
|
+
if (!existsSync(getEventCursorFile()))
|
|
52
|
+
return 0;
|
|
53
|
+
try {
|
|
54
|
+
const raw = readFileSync(getEventCursorFile(), 'utf-8');
|
|
55
|
+
const parsed = JSON.parse(raw);
|
|
56
|
+
return typeof parsed.lastEventId === 'number' ? parsed.lastEventId : 0;
|
|
57
|
+
}
|
|
58
|
+
catch {
|
|
59
|
+
return 0;
|
|
60
|
+
}
|
|
61
|
+
}
|
|
39
62
|
function getSessionFile() {
|
|
40
63
|
return resolve(getBaseDir(), 'session.json');
|
|
41
64
|
}
|