@the-open-engine/zeroshot 6.34.1 → 6.34.3
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/cli/index.js +34 -8
- package/cli/json-export.js +77 -0
- package/npm-shrinkwrap.json +2 -2
- package/package.json +1 -1
- package/src/agent/agent-lifecycle.js +24 -19
- package/src/agent/agent-task-executor.js +539 -157
- package/src/agent/output-extraction.js +111 -39
- package/src/agent/provider-terminal-failure.js +186 -0
- package/src/ledger.js +364 -31
- package/src/orchestrator.js +33 -1
- package/src/template-resolver.js +5 -0
- package/task-lib/watcher-output-runtime.js +128 -43
package/src/ledger.js
CHANGED
|
@@ -1,7 +1,8 @@
|
|
|
1
1
|
/**
|
|
2
|
-
* Ledger -
|
|
2
|
+
* Ledger - Durable event log for multi-agent coordination
|
|
3
3
|
*
|
|
4
4
|
* Provides:
|
|
5
|
+
* - Immutable control/replayable messages plus a bounded newest tail of raw AGENT_OUTPUT
|
|
5
6
|
* - SQLite-backed message storage with indexes
|
|
6
7
|
* - Query API for message retrieval
|
|
7
8
|
* - In-memory cache for recent queries
|
|
@@ -16,9 +17,14 @@ const {
|
|
|
16
17
|
USER_GUIDANCE_AGENT,
|
|
17
18
|
USER_GUIDANCE_CLUSTER,
|
|
18
19
|
} = require('./guidance-topics');
|
|
20
|
+
const { isReplayableMessage } = require('./agent/context-replay-policy');
|
|
19
21
|
const { messageSequenceFromSql, messageSequenceToSql } = require('./ledger-sequence');
|
|
20
22
|
|
|
21
23
|
const MESSAGE_SEQUENCE_SELECT = 'CAST(rowid AS TEXT) AS sequence';
|
|
24
|
+
const AGENT_OUTPUT_EXPORT_MAX_BYTES = 8 * 1024 * 1024;
|
|
25
|
+
const AGENT_OUTPUT_EXPORT_MAX_MESSAGES = 8192;
|
|
26
|
+
const EMPTY_OMISSION_DIGEST = '0'.repeat(64);
|
|
27
|
+
const AGENT_OUTPUT_RECEIPT_SENDER = 'zeroshot';
|
|
22
28
|
|
|
23
29
|
class Ledger extends EventEmitter {
|
|
24
30
|
constructor(dbPath = ':memory:', options = {}) {
|
|
@@ -87,16 +93,40 @@ class Ledger extends EventEmitter {
|
|
|
87
93
|
CREATE INDEX IF NOT EXISTS idx_cluster_sender ON messages(cluster_id, sender);
|
|
88
94
|
CREATE INDEX IF NOT EXISTS idx_cluster_topic ON messages(cluster_id, topic);
|
|
89
95
|
CREATE INDEX IF NOT EXISTS idx_cluster_timestamp ON messages(cluster_id, timestamp);
|
|
96
|
+
|
|
97
|
+
CREATE TABLE IF NOT EXISTS agent_output_compaction (
|
|
98
|
+
cluster_id TEXT PRIMARY KEY,
|
|
99
|
+
receipt_message_id TEXT,
|
|
100
|
+
first_omitted_timestamp INTEGER,
|
|
101
|
+
omitted_messages INTEGER NOT NULL DEFAULT 0,
|
|
102
|
+
omitted_export_bytes INTEGER NOT NULL DEFAULT 0,
|
|
103
|
+
omission_digest TEXT NOT NULL,
|
|
104
|
+
retained_messages INTEGER NOT NULL DEFAULT 0,
|
|
105
|
+
retained_export_bytes INTEGER NOT NULL DEFAULT 0
|
|
106
|
+
);
|
|
107
|
+
|
|
108
|
+
CREATE TABLE IF NOT EXISTS ledger_sequence (
|
|
109
|
+
singleton INTEGER PRIMARY KEY CHECK (singleton = 1),
|
|
110
|
+
value INTEGER NOT NULL
|
|
111
|
+
);
|
|
112
|
+
INSERT OR IGNORE INTO ledger_sequence (singleton, value) VALUES (1, 0);
|
|
113
|
+
UPDATE ledger_sequence
|
|
114
|
+
SET value = MAX(value, COALESCE((SELECT MAX(rowid) FROM messages), 0))
|
|
115
|
+
WHERE singleton = 1;
|
|
90
116
|
`);
|
|
91
117
|
|
|
92
118
|
this._prepareStatements();
|
|
93
119
|
this._loadLastTimestamp();
|
|
120
|
+
this._reconcileAgentOutput();
|
|
94
121
|
}
|
|
95
122
|
|
|
96
123
|
_prepareStatements() {
|
|
97
124
|
const insert = this.db.prepare(`
|
|
98
|
-
INSERT INTO messages (
|
|
99
|
-
|
|
125
|
+
INSERT INTO messages (
|
|
126
|
+
rowid, id, timestamp, topic, sender, receiver,
|
|
127
|
+
content_text, content_data, metadata, cluster_id
|
|
128
|
+
)
|
|
129
|
+
VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?)
|
|
100
130
|
`);
|
|
101
131
|
insert.safeIntegers(true);
|
|
102
132
|
|
|
@@ -111,9 +141,94 @@ class Ledger extends EventEmitter {
|
|
|
111
141
|
`SELECT ${MESSAGE_SEQUENCE_SELECT}, * FROM messages WHERE cluster_id = ?` +
|
|
112
142
|
` ORDER BY timestamp ASC, rowid ASC`
|
|
113
143
|
),
|
|
144
|
+
|
|
145
|
+
agentOutputRows: this.db.prepare(
|
|
146
|
+
`SELECT ${MESSAGE_SEQUENCE_SELECT}, * FROM messages` +
|
|
147
|
+
` WHERE cluster_id = ? AND topic = 'AGENT_OUTPUT' ORDER BY timestamp ASC, rowid ASC`
|
|
148
|
+
),
|
|
149
|
+
};
|
|
150
|
+
|
|
151
|
+
if (!this.readonly) {
|
|
152
|
+
const nextSequence = this.db.prepare(`
|
|
153
|
+
UPDATE ledger_sequence
|
|
154
|
+
SET value = MAX(value, COALESCE((SELECT MAX(rowid) FROM messages), 0)) + 1
|
|
155
|
+
WHERE singleton = 1
|
|
156
|
+
AND value < 9223372036854775807
|
|
157
|
+
AND COALESCE((SELECT MAX(rowid) FROM messages), 0) < 9223372036854775807
|
|
158
|
+
RETURNING value
|
|
159
|
+
`);
|
|
160
|
+
nextSequence.safeIntegers(true);
|
|
161
|
+
this.stmts.nextSequence = nextSequence;
|
|
162
|
+
this._prepareAgentOutputCompactionStatements();
|
|
163
|
+
}
|
|
164
|
+
}
|
|
165
|
+
|
|
166
|
+
_prepareAgentOutputCompactionStatements() {
|
|
167
|
+
this.compactionStmts = {
|
|
168
|
+
get: this.db.prepare('SELECT * FROM agent_output_compaction WHERE cluster_id = ?'),
|
|
169
|
+
insert: this.db.prepare(`
|
|
170
|
+
INSERT INTO agent_output_compaction (
|
|
171
|
+
cluster_id, receipt_message_id, first_omitted_timestamp,
|
|
172
|
+
omitted_messages, omitted_export_bytes, omission_digest,
|
|
173
|
+
retained_messages, retained_export_bytes
|
|
174
|
+
) VALUES (?, NULL, NULL, 0, 0, ?, ?, ?)
|
|
175
|
+
`),
|
|
176
|
+
update: this.db.prepare(`
|
|
177
|
+
UPDATE agent_output_compaction
|
|
178
|
+
SET receipt_message_id = ?, first_omitted_timestamp = ?, omitted_messages = ?,
|
|
179
|
+
omitted_export_bytes = ?, omission_digest = ?, retained_messages = ?,
|
|
180
|
+
retained_export_bytes = ?
|
|
181
|
+
WHERE cluster_id = ?
|
|
182
|
+
`),
|
|
183
|
+
deleteMessage: this.db.prepare('DELETE FROM messages WHERE id = ?'),
|
|
184
|
+
updateReceipt: this.db.prepare(`
|
|
185
|
+
UPDATE messages SET content_data = ?, metadata = ? WHERE id = ?
|
|
186
|
+
`),
|
|
187
|
+
receiptExists: this.db.prepare('SELECT 1 FROM messages WHERE id = ?'),
|
|
188
|
+
agentOutputClusterIds: this.db.prepare(`
|
|
189
|
+
SELECT cluster_id FROM agent_output_compaction
|
|
190
|
+
UNION
|
|
191
|
+
SELECT cluster_id FROM messages WHERE topic = 'AGENT_OUTPUT'
|
|
192
|
+
`),
|
|
193
|
+
deleteState: this.db.prepare('DELETE FROM agent_output_compaction'),
|
|
114
194
|
};
|
|
115
195
|
}
|
|
116
196
|
|
|
197
|
+
_reconcileAgentOutput() {
|
|
198
|
+
const clusterIds = this.compactionStmts.agentOutputClusterIds
|
|
199
|
+
.all()
|
|
200
|
+
.map((row) => row.cluster_id);
|
|
201
|
+
const reconcileCluster = this.db.transaction((clusterId) => {
|
|
202
|
+
const measured = this._measureCompactableAgentOutput(clusterId);
|
|
203
|
+
let state = this.compactionStmts.get.get(clusterId);
|
|
204
|
+
if (!state) {
|
|
205
|
+
if (measured.retained_messages === 0) return;
|
|
206
|
+
state = this._createCompactionState(clusterId, measured);
|
|
207
|
+
} else if (
|
|
208
|
+
state.retained_messages !== measured.retained_messages ||
|
|
209
|
+
state.retained_export_bytes !== measured.retained_export_bytes
|
|
210
|
+
) {
|
|
211
|
+
state.retained_messages = measured.retained_messages;
|
|
212
|
+
state.retained_export_bytes = measured.retained_export_bytes;
|
|
213
|
+
this._updateCompactionState(state);
|
|
214
|
+
}
|
|
215
|
+
const receiptMissing =
|
|
216
|
+
state.omitted_messages > 0 &&
|
|
217
|
+
(!state.receipt_message_id ||
|
|
218
|
+
!this.compactionStmts.receiptExists.get(state.receipt_message_id));
|
|
219
|
+
if (
|
|
220
|
+
receiptMissing ||
|
|
221
|
+
state.retained_export_bytes > AGENT_OUTPUT_EXPORT_MAX_BYTES ||
|
|
222
|
+
state.retained_messages > AGENT_OUTPUT_EXPORT_MAX_MESSAGES
|
|
223
|
+
) {
|
|
224
|
+
this._compactAgentOutput(clusterId);
|
|
225
|
+
}
|
|
226
|
+
});
|
|
227
|
+
for (const clusterId of clusterIds) {
|
|
228
|
+
reconcileCluster.immediate(clusterId);
|
|
229
|
+
}
|
|
230
|
+
}
|
|
231
|
+
|
|
117
232
|
_loadLastTimestamp() {
|
|
118
233
|
const row = this.db.prepare('SELECT MAX(timestamp) AS max_timestamp FROM messages').get();
|
|
119
234
|
if (row && Number.isFinite(row.max_timestamp)) {
|
|
@@ -121,6 +236,191 @@ class Ledger extends EventEmitter {
|
|
|
121
236
|
}
|
|
122
237
|
}
|
|
123
238
|
|
|
239
|
+
_insertRecord(record) {
|
|
240
|
+
const allocated = this.stmts.nextSequence.get();
|
|
241
|
+
if (!allocated) {
|
|
242
|
+
throw new RangeError('Ledger message sequence exhausted the SQLite rowid range');
|
|
243
|
+
}
|
|
244
|
+
this.stmts.insert.run(
|
|
245
|
+
allocated.value,
|
|
246
|
+
record.id,
|
|
247
|
+
record.timestamp,
|
|
248
|
+
record.topic,
|
|
249
|
+
record.sender,
|
|
250
|
+
record.receiver,
|
|
251
|
+
record.content_text,
|
|
252
|
+
record.content_data,
|
|
253
|
+
record.metadata,
|
|
254
|
+
record.cluster_id
|
|
255
|
+
);
|
|
256
|
+
record.sequence = messageSequenceFromSql(allocated.value);
|
|
257
|
+
return this._deserializeMessage(record);
|
|
258
|
+
}
|
|
259
|
+
|
|
260
|
+
_recordExportBytes(record) {
|
|
261
|
+
return Buffer.byteLength(JSON.stringify(this._deserializeMessage(record)));
|
|
262
|
+
}
|
|
263
|
+
|
|
264
|
+
_isCompactableAgentOutput(record) {
|
|
265
|
+
if (record.topic !== 'AGENT_OUTPUT') return false;
|
|
266
|
+
const message = this._deserializeMessage(record);
|
|
267
|
+
return message.metadata?.compactionReceipt !== true && !isReplayableMessage(message);
|
|
268
|
+
}
|
|
269
|
+
|
|
270
|
+
_measureCompactableAgentOutput(clusterId) {
|
|
271
|
+
let retainedMessages = 0;
|
|
272
|
+
let retainedExportBytes = 0;
|
|
273
|
+
for (const row of this.stmts.agentOutputRows.iterate(clusterId)) {
|
|
274
|
+
if (!this._isCompactableAgentOutput(row)) continue;
|
|
275
|
+
retainedMessages += 1;
|
|
276
|
+
retainedExportBytes += this._recordExportBytes(row);
|
|
277
|
+
}
|
|
278
|
+
return {
|
|
279
|
+
retained_messages: retainedMessages,
|
|
280
|
+
retained_export_bytes: retainedExportBytes,
|
|
281
|
+
};
|
|
282
|
+
}
|
|
283
|
+
|
|
284
|
+
_createCompactionState(clusterId, measured = this._measureCompactableAgentOutput(clusterId)) {
|
|
285
|
+
this.compactionStmts.insert.run(
|
|
286
|
+
clusterId,
|
|
287
|
+
EMPTY_OMISSION_DIGEST,
|
|
288
|
+
measured.retained_messages,
|
|
289
|
+
measured.retained_export_bytes
|
|
290
|
+
);
|
|
291
|
+
return this.compactionStmts.get.get(clusterId);
|
|
292
|
+
}
|
|
293
|
+
|
|
294
|
+
_updateCompactionState(state) {
|
|
295
|
+
this.compactionStmts.update.run(
|
|
296
|
+
state.receipt_message_id,
|
|
297
|
+
state.first_omitted_timestamp,
|
|
298
|
+
state.omitted_messages,
|
|
299
|
+
state.omitted_export_bytes,
|
|
300
|
+
state.omission_digest,
|
|
301
|
+
state.retained_messages,
|
|
302
|
+
state.retained_export_bytes,
|
|
303
|
+
state.cluster_id
|
|
304
|
+
);
|
|
305
|
+
}
|
|
306
|
+
|
|
307
|
+
_oldestCompactableAgentOutput(clusterId) {
|
|
308
|
+
for (const row of this.stmts.agentOutputRows.iterate(clusterId)) {
|
|
309
|
+
if (this._isCompactableAgentOutput(row)) return row;
|
|
310
|
+
}
|
|
311
|
+
return null;
|
|
312
|
+
}
|
|
313
|
+
|
|
314
|
+
_nextOmissionDigest(previousDigest, record) {
|
|
315
|
+
const hash = crypto.createHash('sha256');
|
|
316
|
+
hash.update(Buffer.from(previousDigest, 'hex'));
|
|
317
|
+
const exportedMessage = Buffer.from(JSON.stringify(this._deserializeMessage(record)));
|
|
318
|
+
const length = Buffer.allocUnsafe(8);
|
|
319
|
+
length.writeBigUInt64BE(BigInt(exportedMessage.length));
|
|
320
|
+
hash.update(length);
|
|
321
|
+
hash.update(exportedMessage);
|
|
322
|
+
return hash.digest('hex');
|
|
323
|
+
}
|
|
324
|
+
|
|
325
|
+
_agentOutputReceiptRecord(state) {
|
|
326
|
+
const line =
|
|
327
|
+
`[ZEROSHOT] Earlier raw provider output omitted from the durable control-plane tail ` +
|
|
328
|
+
`(messages=${state.omitted_messages}, export_bytes=${state.omitted_export_bytes}, ` +
|
|
329
|
+
`sha256_chain=${state.omission_digest}). The newest output is retained within ` +
|
|
330
|
+
`${AGENT_OUTPUT_EXPORT_MAX_BYTES} exported bytes and ` +
|
|
331
|
+
`${AGENT_OUTPUT_EXPORT_MAX_MESSAGES} messages. Complete output remains in task logs.`;
|
|
332
|
+
return {
|
|
333
|
+
id:
|
|
334
|
+
state.receipt_message_id ||
|
|
335
|
+
`agent_output_receipt_${crypto.createHash('sha256').update(state.cluster_id).digest('hex').slice(0, 24)}`,
|
|
336
|
+
timestamp: state.first_omitted_timestamp,
|
|
337
|
+
topic: 'AGENT_OUTPUT',
|
|
338
|
+
sender: AGENT_OUTPUT_RECEIPT_SENDER,
|
|
339
|
+
receiver: 'broadcast',
|
|
340
|
+
content_text: null,
|
|
341
|
+
content_data: JSON.stringify({
|
|
342
|
+
type: 'output_omission',
|
|
343
|
+
line,
|
|
344
|
+
omittedMessages: state.omitted_messages,
|
|
345
|
+
omittedExportBytes: state.omitted_export_bytes,
|
|
346
|
+
sha256Chain: state.omission_digest,
|
|
347
|
+
retainedExportByteLimit: AGENT_OUTPUT_EXPORT_MAX_BYTES,
|
|
348
|
+
retainedMessageLimit: AGENT_OUTPUT_EXPORT_MAX_MESSAGES,
|
|
349
|
+
completeOutputAuthority: 'task_logs',
|
|
350
|
+
}),
|
|
351
|
+
metadata: JSON.stringify({
|
|
352
|
+
compactionReceipt: true,
|
|
353
|
+
contextSafe: false,
|
|
354
|
+
replayPolicy: 'raw_log_only',
|
|
355
|
+
}),
|
|
356
|
+
cluster_id: state.cluster_id,
|
|
357
|
+
};
|
|
358
|
+
}
|
|
359
|
+
|
|
360
|
+
_persistAgentOutputReceipt(state) {
|
|
361
|
+
const receipt = this._agentOutputReceiptRecord(state);
|
|
362
|
+
const updated = state.receipt_message_id
|
|
363
|
+
? this.compactionStmts.updateReceipt.run(receipt.content_data, receipt.metadata, receipt.id)
|
|
364
|
+
: { changes: 0 };
|
|
365
|
+
if (updated.changes === 0) {
|
|
366
|
+
this._insertRecord(receipt);
|
|
367
|
+
}
|
|
368
|
+
state.receipt_message_id = receipt.id;
|
|
369
|
+
}
|
|
370
|
+
|
|
371
|
+
_compactAgentOutput(clusterId) {
|
|
372
|
+
const state = this.compactionStmts.get.get(clusterId) || this._createCompactionState(clusterId);
|
|
373
|
+
while (
|
|
374
|
+
state.retained_export_bytes > AGENT_OUTPUT_EXPORT_MAX_BYTES ||
|
|
375
|
+
state.retained_messages > AGENT_OUTPUT_EXPORT_MAX_MESSAGES
|
|
376
|
+
) {
|
|
377
|
+
const oldest = this._oldestCompactableAgentOutput(clusterId);
|
|
378
|
+
if (!oldest) break;
|
|
379
|
+
const exportBytes = this._recordExportBytes(oldest);
|
|
380
|
+
state.first_omitted_timestamp ??= oldest.timestamp;
|
|
381
|
+
state.omitted_messages += 1;
|
|
382
|
+
state.omitted_export_bytes += exportBytes;
|
|
383
|
+
state.omission_digest = this._nextOmissionDigest(state.omission_digest, oldest);
|
|
384
|
+
state.retained_messages -= 1;
|
|
385
|
+
state.retained_export_bytes -= exportBytes;
|
|
386
|
+
if (
|
|
387
|
+
!state.receipt_message_id ||
|
|
388
|
+
!this.compactionStmts.receiptExists.get(state.receipt_message_id)
|
|
389
|
+
) {
|
|
390
|
+
this._persistAgentOutputReceipt(state);
|
|
391
|
+
}
|
|
392
|
+
this.compactionStmts.deleteMessage.run(oldest.id);
|
|
393
|
+
}
|
|
394
|
+
if (state.omitted_messages > 0) {
|
|
395
|
+
this._persistAgentOutputReceipt(state);
|
|
396
|
+
}
|
|
397
|
+
this._updateCompactionState(state);
|
|
398
|
+
}
|
|
399
|
+
|
|
400
|
+
_insertAndCompact(record) {
|
|
401
|
+
const fullMessage = this._insertRecord(record);
|
|
402
|
+
if (this._isCompactableAgentOutput(record)) {
|
|
403
|
+
const state = this.compactionStmts.get.get(record.cluster_id);
|
|
404
|
+
if (state) {
|
|
405
|
+
state.retained_messages += 1;
|
|
406
|
+
state.retained_export_bytes += this._recordExportBytes(record);
|
|
407
|
+
this._updateCompactionState(state);
|
|
408
|
+
}
|
|
409
|
+
this._compactAgentOutput(record.cluster_id);
|
|
410
|
+
}
|
|
411
|
+
return fullMessage;
|
|
412
|
+
}
|
|
413
|
+
|
|
414
|
+
_appendRecord(record) {
|
|
415
|
+
const compactable = this._isCompactableAgentOutput(record);
|
|
416
|
+
if (!this._appendRecordTxn) {
|
|
417
|
+
this._appendRecordTxn = this.db.transaction((item, compact) =>
|
|
418
|
+
compact ? this._insertAndCompact(item) : this._insertRecord(item)
|
|
419
|
+
);
|
|
420
|
+
}
|
|
421
|
+
return this._appendRecordTxn(record, compactable);
|
|
422
|
+
}
|
|
423
|
+
|
|
124
424
|
/**
|
|
125
425
|
* Append a message to the ledger
|
|
126
426
|
* @param {Object} message - Message object
|
|
@@ -154,18 +454,7 @@ class Ledger extends EventEmitter {
|
|
|
154
454
|
};
|
|
155
455
|
|
|
156
456
|
try {
|
|
157
|
-
const
|
|
158
|
-
record.id,
|
|
159
|
-
record.timestamp,
|
|
160
|
-
record.topic,
|
|
161
|
-
record.sender,
|
|
162
|
-
record.receiver,
|
|
163
|
-
record.content_text,
|
|
164
|
-
record.content_data,
|
|
165
|
-
record.metadata,
|
|
166
|
-
record.cluster_id
|
|
167
|
-
);
|
|
168
|
-
record.sequence = messageSequenceFromSql(insertResult.lastInsertRowid);
|
|
457
|
+
const fullMessage = this._appendRecord(record);
|
|
169
458
|
|
|
170
459
|
// Invalidate cache
|
|
171
460
|
this.cache.clear();
|
|
@@ -173,7 +462,6 @@ class Ledger extends EventEmitter {
|
|
|
173
462
|
this._lastTimestamp = Math.max(this._lastTimestamp, timestamp);
|
|
174
463
|
|
|
175
464
|
// Emit event for subscriptions
|
|
176
|
-
const fullMessage = this._deserializeMessage(record);
|
|
177
465
|
this.emit('message', fullMessage);
|
|
178
466
|
this.emit(`topic:${message.topic}`, fullMessage);
|
|
179
467
|
|
|
@@ -229,20 +517,7 @@ class Ledger extends EventEmitter {
|
|
|
229
517
|
cluster_id: message.cluster_id,
|
|
230
518
|
};
|
|
231
519
|
|
|
232
|
-
|
|
233
|
-
record.id,
|
|
234
|
-
record.timestamp,
|
|
235
|
-
record.topic,
|
|
236
|
-
record.sender,
|
|
237
|
-
record.receiver,
|
|
238
|
-
record.content_text,
|
|
239
|
-
record.content_data,
|
|
240
|
-
record.metadata,
|
|
241
|
-
record.cluster_id
|
|
242
|
-
);
|
|
243
|
-
record.sequence = messageSequenceFromSql(insertResult.lastInsertRowid);
|
|
244
|
-
|
|
245
|
-
results.push(this._deserializeMessage(record));
|
|
520
|
+
results.push(this._insertAndCompact(record));
|
|
246
521
|
}
|
|
247
522
|
|
|
248
523
|
return { results, baseTimestamp };
|
|
@@ -546,6 +821,54 @@ class Ledger extends EventEmitter {
|
|
|
546
821
|
return rows.map((row) => this._deserializeMessage(row));
|
|
547
822
|
}
|
|
548
823
|
|
|
824
|
+
/**
|
|
825
|
+
* Iterate over all cluster messages without materializing the complete ledger.
|
|
826
|
+
* @param {String} cluster_id - Cluster ID
|
|
827
|
+
* @yields {Object} One deserialized message at a time
|
|
828
|
+
*/
|
|
829
|
+
*iterateAll(cluster_id) {
|
|
830
|
+
for (const row of this.stmts.getAll.iterate(cluster_id)) {
|
|
831
|
+
yield this._deserializeMessage(row);
|
|
832
|
+
}
|
|
833
|
+
}
|
|
834
|
+
|
|
835
|
+
/**
|
|
836
|
+
* Run synchronous reads against one stable SQLite snapshot.
|
|
837
|
+
* @param {Function} callback - Work to perform inside the snapshot
|
|
838
|
+
* @returns {*} The callback result
|
|
839
|
+
*/
|
|
840
|
+
withReadSnapshot(callback) {
|
|
841
|
+
return this.db.transaction(callback).deferred();
|
|
842
|
+
}
|
|
843
|
+
|
|
844
|
+
/**
|
|
845
|
+
* Return whether a legacy cluster requires one writable compaction pass.
|
|
846
|
+
* This preflight is read-only and does not acquire a writer lock.
|
|
847
|
+
* @param {String} cluster_id - Cluster ID
|
|
848
|
+
* @returns {Boolean} True when actual raw output disagrees with durable budget state
|
|
849
|
+
*/
|
|
850
|
+
needsAgentOutputReconciliation(cluster_id) {
|
|
851
|
+
const hasCompactionTable = this.db
|
|
852
|
+
.prepare(
|
|
853
|
+
"SELECT 1 FROM sqlite_master WHERE type = 'table' AND name = 'agent_output_compaction'"
|
|
854
|
+
)
|
|
855
|
+
.get();
|
|
856
|
+
const state =
|
|
857
|
+
hasCompactionTable &&
|
|
858
|
+
this.db.prepare('SELECT * FROM agent_output_compaction WHERE cluster_id = ?').get(cluster_id);
|
|
859
|
+
const measured = this._measureCompactableAgentOutput(cluster_id);
|
|
860
|
+
if (!state) return measured.retained_messages > 0;
|
|
861
|
+
if (
|
|
862
|
+
state.retained_messages !== measured.retained_messages ||
|
|
863
|
+
state.retained_export_bytes !== measured.retained_export_bytes
|
|
864
|
+
) {
|
|
865
|
+
return true;
|
|
866
|
+
}
|
|
867
|
+
if (state.omitted_messages === 0) return false;
|
|
868
|
+
if (!state.receipt_message_id) return true;
|
|
869
|
+
return !this.db.prepare('SELECT 1 FROM messages WHERE id = ?').get(state.receipt_message_id);
|
|
870
|
+
}
|
|
871
|
+
|
|
549
872
|
/**
|
|
550
873
|
* Get aggregated token usage by agent role
|
|
551
874
|
* Queries TOKEN_USAGE messages and sums tokens per role
|
|
@@ -811,9 +1134,19 @@ class Ledger extends EventEmitter {
|
|
|
811
1134
|
* Clear all messages (for testing)
|
|
812
1135
|
*/
|
|
813
1136
|
clear() {
|
|
814
|
-
this.db.
|
|
1137
|
+
this.db.transaction(() => {
|
|
1138
|
+
this.db.exec('DELETE FROM messages');
|
|
1139
|
+
if (this.compactionStmts) {
|
|
1140
|
+
this.compactionStmts.deleteState.run();
|
|
1141
|
+
}
|
|
1142
|
+
})();
|
|
815
1143
|
this.cache.clear();
|
|
816
1144
|
}
|
|
817
1145
|
}
|
|
818
1146
|
|
|
1147
|
+
Ledger.AGENT_OUTPUT_EXPORT_LIMITS = Object.freeze({
|
|
1148
|
+
maxBytes: AGENT_OUTPUT_EXPORT_MAX_BYTES,
|
|
1149
|
+
maxMessages: AGENT_OUTPUT_EXPORT_MAX_MESSAGES,
|
|
1150
|
+
});
|
|
1151
|
+
|
|
819
1152
|
module.exports = Ledger;
|
package/src/orchestrator.js
CHANGED
|
@@ -275,6 +275,7 @@ class Orchestrator {
|
|
|
275
275
|
// Track if orchestrator is closed (prevents _saveClusters race conditions during cleanup)
|
|
276
276
|
this.closed = false;
|
|
277
277
|
this._conductorWatchdogs = new Set();
|
|
278
|
+
this._clusterRunBoundaries = new Map();
|
|
278
279
|
|
|
279
280
|
// Track if clusters are loaded (for lazy loading pattern)
|
|
280
281
|
this._clustersLoaded = options.skipLoad === true;
|
|
@@ -1575,11 +1576,17 @@ class Orchestrator {
|
|
|
1575
1576
|
}
|
|
1576
1577
|
|
|
1577
1578
|
_registerAgentErrorHandler(messageBus, clusterId) {
|
|
1579
|
+
this._recordClusterRunBoundary(messageBus, clusterId);
|
|
1578
1580
|
this._subscribeToClusterTopic(messageBus, clusterId, 'AGENT_ERROR', async (message) => {
|
|
1579
1581
|
const agentRole = message.content?.data?.role;
|
|
1580
1582
|
const attempts = message.content?.data?.attempts || 1;
|
|
1581
1583
|
const hookFailure = message.content?.data?.hookFailure === true;
|
|
1582
1584
|
const restartExhausted = message.content?.data?.restartExhausted === true;
|
|
1585
|
+
const durableClusterFailure = this._findCurrentRunClusterFailure(
|
|
1586
|
+
messageBus,
|
|
1587
|
+
clusterId,
|
|
1588
|
+
message.sequence
|
|
1589
|
+
);
|
|
1583
1590
|
|
|
1584
1591
|
await this._saveClusters();
|
|
1585
1592
|
|
|
@@ -1587,7 +1594,10 @@ class Orchestrator {
|
|
|
1587
1594
|
agentRole === 'implementation' ||
|
|
1588
1595
|
agentRole === 'coordinator' ||
|
|
1589
1596
|
message.sender === 'consensus-coordinator';
|
|
1590
|
-
const shouldStop =
|
|
1597
|
+
const shouldStop =
|
|
1598
|
+
!durableClusterFailure &&
|
|
1599
|
+
shouldStopForRole &&
|
|
1600
|
+
(hookFailure || restartExhausted || attempts >= 3);
|
|
1591
1601
|
|
|
1592
1602
|
if (shouldStop) {
|
|
1593
1603
|
this._log(`\n${'='.repeat(80)}`);
|
|
@@ -1610,6 +1620,25 @@ class Orchestrator {
|
|
|
1610
1620
|
});
|
|
1611
1621
|
}
|
|
1612
1622
|
|
|
1623
|
+
_recordClusterRunBoundary(messageBus, clusterId) {
|
|
1624
|
+
const latest = messageBus.findLast({ cluster_id: clusterId, orderBySequence: true });
|
|
1625
|
+
this._clusterRunBoundaries.set(clusterId, latest?.sequence ?? null);
|
|
1626
|
+
}
|
|
1627
|
+
|
|
1628
|
+
_findCurrentRunClusterFailure(messageBus, clusterId, throughId) {
|
|
1629
|
+
const boundary = this._clusterRunBoundaries.get(clusterId);
|
|
1630
|
+
return (
|
|
1631
|
+
messageBus.query({
|
|
1632
|
+
cluster_id: clusterId,
|
|
1633
|
+
topic: 'CLUSTER_FAILED',
|
|
1634
|
+
order: 'desc',
|
|
1635
|
+
limit: 1,
|
|
1636
|
+
...(boundary === null ? {} : { afterId: boundary }),
|
|
1637
|
+
...(throughId === undefined ? {} : { throughId }),
|
|
1638
|
+
})[0] || null
|
|
1639
|
+
);
|
|
1640
|
+
}
|
|
1641
|
+
|
|
1613
1642
|
_registerPushBlockedHandler(messageBus, clusterId) {
|
|
1614
1643
|
this._subscribeToClusterTopic(messageBus, clusterId, 'PUSH_BLOCKED', async (message) => {
|
|
1615
1644
|
const reason = message.content?.data?.blocked_reason || 'unknown';
|
|
@@ -2425,6 +2454,7 @@ class Orchestrator {
|
|
|
2425
2454
|
|
|
2426
2455
|
// Now remove from memory after persisting
|
|
2427
2456
|
this.clusters.delete(clusterId);
|
|
2457
|
+
this._clusterRunBoundaries.delete(clusterId);
|
|
2428
2458
|
}
|
|
2429
2459
|
|
|
2430
2460
|
/**
|
|
@@ -2462,6 +2492,7 @@ class Orchestrator {
|
|
|
2462
2492
|
watchdog.dispose();
|
|
2463
2493
|
}
|
|
2464
2494
|
this._conductorWatchdogs.clear();
|
|
2495
|
+
this._clusterRunBoundaries.clear();
|
|
2465
2496
|
|
|
2466
2497
|
for (const cluster of this.clusters.values()) {
|
|
2467
2498
|
if (typeof cluster.snapshotter?.stop === 'function') {
|
|
@@ -2902,6 +2933,7 @@ class Orchestrator {
|
|
|
2902
2933
|
}
|
|
2903
2934
|
|
|
2904
2935
|
async _restartClusterAgents(cluster) {
|
|
2936
|
+
this._recordClusterRunBoundary(cluster.messageBus, cluster.id);
|
|
2905
2937
|
cluster.state = 'running';
|
|
2906
2938
|
cluster.pid = process.pid;
|
|
2907
2939
|
for (const agent of cluster.agents) {
|
package/src/template-resolver.js
CHANGED
|
@@ -327,6 +327,11 @@ class TemplateResolver {
|
|
|
327
327
|
* @returns {any}
|
|
328
328
|
*/
|
|
329
329
|
_resolveString(str, params) {
|
|
330
|
+
const exactPlaceholder = str.match(/^\{\{(\w+)\}\}$/);
|
|
331
|
+
if (exactPlaceholder && params[exactPlaceholder[1]] !== undefined) {
|
|
332
|
+
return JSON.parse(JSON.stringify(params[exactPlaceholder[1]]));
|
|
333
|
+
}
|
|
334
|
+
|
|
330
335
|
// Handle simple {{param}} substitutions
|
|
331
336
|
let result = str.replace(
|
|
332
337
|
/\{\{(\w+)\}\}/g,
|