@agentpulselabs/cli-plugin-pulse 1.1.3 → 1.1.5
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/package.json +3 -2
- package/src/hub/core/db.mjs +21 -5
- package/src/hub/workers/manager.mjs +44 -6
- package/src/lib/contract.sha256 +1 -1
- package/src/lib/daemonRuntime.js +25 -7
- package/src/lib/hubDbReader.js +6 -0
- package/src/lib/hubScan.js +1 -0
- package/src/lib/ledger.js +21 -12
- package/src/lib/lifecycle.js +3 -1
- package/src/lib/runnerStore.js +61 -49
- package/src/runner/bridge.js +8 -3
- package/src/runner/capture.js +83 -20
- package/src/runner/channelDiscipline.js +48 -14
- package/src/runner/channelWalk.js +42 -0
- package/src/runner/deliveryStates.js +22 -9
- package/src/runner/directoryCache.js +34 -0
- package/src/runner/facade.js +71 -14
- package/src/runner/hubCapture.js +105 -56
- package/src/runner/loopMonitor.js +89 -0
- package/src/runner/runnerHub.js +3 -1
- package/src/runner/runnerProcess.js +58 -16
- package/src/runner/runnerService.js +18 -2
- package/src/runner/serial.js +5 -0
- package/src/runner/spool.js +46 -11
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "@agentpulselabs/cli-plugin-pulse",
|
|
3
|
-
"version": "1.1.
|
|
3
|
+
"version": "1.1.5",
|
|
4
4
|
"description": "AgentPulse CLI plugin for installing and operating the Pulse local connector (replaces the deprecated build-discipline Pulse plugin).",
|
|
5
5
|
"type": "module",
|
|
6
6
|
"license": "MIT",
|
|
@@ -21,8 +21,9 @@
|
|
|
21
21
|
"scripts": {
|
|
22
22
|
"test": "node --import ./test/support/register-loader.mjs --test \"test/*.test.js\" \"test/hub/*.test.js\"",
|
|
23
23
|
"contract:check": "node scripts/sync-contract.mjs --local",
|
|
24
|
-
"check": "node --check src/commands/pulse/install.js && node --check src/commands/pulse/pair.js && node --check src/commands/pulse/login.js && node --check src/commands/pulse/start.js && node --check src/commands/pulse/status.js && node --check src/commands/pulse/doctor.js && node --check src/commands/pulse/stop.js && node --check src/commands/pulse/restart.js && node --check src/lib/selfRestart.js && node --check src/lib/subprocess.js && node --check src/lib/daemonProcess.js && node --check src/commands/pulse/mirror.js && node --check src/commands/pulse/hub-write.js && node --check src/lib/localHubWriter.js && node --check src/lib/hubActions.js && node --check src/lib/localHub.js && node --check src/lib/pathRedaction.js && node --check src/lib/rulePacks.js && node --check src/lib/mirrorContract.js && node --check src/lib/graphWindows.js && node --check src/lib/mirrorSource.js && node --check src/lib/mirrorSync.js && node --check src/lib/mirrorService.js && node --check src/lib/mirrorDaemon.js && node --check src/lib/daemonComposition.js && node --check src/lib/daemonRuntime.js && node --check src/lib/daemonLoop.js && node --check src/daemon/main.js && node --check src/lib/sqlite.js && node --check src/lib/nodeFloor.js && node --check src/lib/runnerStore.js && node --check src/lib/hubDbReader.js && node --check src/lib/channelScanner.js && node --check src/lib/uploadRedactor.js && node --check src/lib/hubScan.js && node --check src/lib/outboxFlusher.js && node --check src/lib/hubSnapshot.js && node --check src/lib/reconcileView.js && node --check src/lib/reconcile.js && node --check src/lib/shadowService.js && node --check src/lib/cutover.js && node --check src/lib/cutoverWiring.js && node --check src/lib/cutoverRequest.js && node --check src/commands/pulse/cutover.js && node --check src/lib/cloudFirstCommands.js && node --check src/commands/pulse/import.js && node --check src/commands/pulse/shadow.js && node --check src/commands/pulse/parity.js && node --check src/runner/main.js && node --check src/runner/runnerProcess.js && node --check src/runner/facade.js && node --check src/runner/bridge.js && node --check src/runner/runnerChild.js && node --check src/runner/applier.js && node --check src/runner/capture.js && node --check src/runner/hubCapture.js && node --check src/clients/hub.mjs && node --check src/clients/hub-bridge.mjs && node --check src/runner/busIdentity.js && node --check src/runner/deliveryStates.js && node --check src/lib/doctorWarnings.js && node --check src/hub/lib/closure.mjs && node --check src/runner/channelDiscipline.js",
|
|
24
|
+
"check": "node --check src/commands/pulse/install.js && node --check src/commands/pulse/pair.js && node --check src/commands/pulse/login.js && node --check src/commands/pulse/start.js && node --check src/commands/pulse/status.js && node --check src/commands/pulse/doctor.js && node --check src/commands/pulse/stop.js && node --check src/commands/pulse/restart.js && node --check src/lib/selfRestart.js && node --check src/lib/subprocess.js && node --check src/lib/daemonProcess.js && node --check src/commands/pulse/mirror.js && node --check src/commands/pulse/hub-write.js && node --check src/lib/localHubWriter.js && node --check src/lib/hubActions.js && node --check src/lib/localHub.js && node --check src/lib/pathRedaction.js && node --check src/lib/rulePacks.js && node --check src/lib/mirrorContract.js && node --check src/lib/graphWindows.js && node --check src/lib/mirrorSource.js && node --check src/lib/mirrorSync.js && node --check src/lib/mirrorService.js && node --check src/lib/mirrorDaemon.js && node --check src/lib/daemonComposition.js && node --check src/lib/daemonRuntime.js && node --check src/lib/daemonLoop.js && node --check src/daemon/main.js && node --check src/lib/sqlite.js && node --check src/lib/nodeFloor.js && node --check src/lib/runnerStore.js && node --check src/lib/hubDbReader.js && node --check src/lib/channelScanner.js && node --check src/lib/uploadRedactor.js && node --check src/lib/hubScan.js && node --check src/lib/outboxFlusher.js && node --check src/lib/hubSnapshot.js && node --check src/lib/reconcileView.js && node --check src/lib/reconcile.js && node --check src/lib/shadowService.js && node --check src/lib/cutover.js && node --check src/lib/cutoverWiring.js && node --check src/lib/cutoverRequest.js && node --check src/commands/pulse/cutover.js && node --check src/lib/cloudFirstCommands.js && node --check src/commands/pulse/import.js && node --check src/commands/pulse/shadow.js && node --check src/commands/pulse/parity.js && node --check src/runner/main.js && node --check src/runner/runnerProcess.js && node --check src/runner/facade.js && node --check src/runner/bridge.js && node --check src/runner/runnerChild.js && node --check src/runner/applier.js && node --check src/runner/capture.js && node --check src/runner/loopMonitor.js && node --check src/runner/channelWalk.js && node --check src/runner/serial.js && node --check src/runner/directoryCache.js && node --check src/runner/hubCapture.js && node --check src/clients/hub.mjs && node --check src/clients/hub-bridge.mjs && node --check src/runner/busIdentity.js && node --check src/runner/deliveryStates.js && node --check src/lib/doctorWarnings.js && node --check src/hub/lib/closure.mjs && node --check src/runner/channelDiscipline.js",
|
|
25
25
|
"test:t2": "node --import ./test/support/register-loader.mjs --test --test-timeout=120000 \"test/t2/*.test.js\"",
|
|
26
|
+
"test:perf": "node --import ./test/support/register-loader.mjs --test --test-concurrency=1 --test-timeout=900000 \"test/perf/*.test.js\"",
|
|
26
27
|
"test:t3": "node --import ./test/support/register-loader.mjs --test --test-concurrency=1 --test-timeout=240000 \"test/t3/*.test.js\""
|
|
27
28
|
},
|
|
28
29
|
"engines": {
|
package/src/hub/core/db.mjs
CHANGED
|
@@ -3,6 +3,7 @@
|
|
|
3
3
|
import fs from 'node:fs';
|
|
4
4
|
import path from 'node:path';
|
|
5
5
|
import { DatabaseSync } from 'node:sqlite';
|
|
6
|
+
import { isBusy } from './ticks.mjs';
|
|
6
7
|
|
|
7
8
|
export const SCHEMA_VERSION = 1;
|
|
8
9
|
|
|
@@ -231,22 +232,37 @@ export function withTx(db, fn, label = null) {
|
|
|
231
232
|
}
|
|
232
233
|
|
|
233
234
|
const pause = (ms) => { if (ms > 0) Atomics.wait(new Int32Array(new SharedArrayBuffer(4)), 0, 0, ms); };
|
|
235
|
+
/** How long a busy chunk of writeInChunks waits before each new try; the wait blocks its thread, the collect worker's. */
|
|
236
|
+
const BUSY_PAUSES_MS = [250, 500, 1000, 2000];
|
|
234
237
|
|
|
235
238
|
/**
|
|
236
239
|
* Runs `steps` (functions) in as many short write transactions as it takes for none to hold the lock past `budgetMs`,
|
|
237
240
|
* pausing `pauseMs` between them so writers on other connections get in. Inside an open transaction it simply joins it.
|
|
238
|
-
*
|
|
241
|
+
* A chunk that finds the database busy past busy_timeout is rolled back and run again from its first step, after
|
|
242
|
+
* `busyPausesMs`, so another writer's long transaction delays a pass instead of failing it. Returns how many
|
|
243
|
+
* transactions it used.
|
|
239
244
|
*/
|
|
240
|
-
export function writeInChunks(db, steps, { budgetMs = 50, pauseMs = 15, label = 'chunked write', onPause = null } = {}) {
|
|
245
|
+
export function writeInChunks(db, steps, { budgetMs = 50, pauseMs = 15, label = 'chunked write', onPause = null, busyPausesMs = BUSY_PAUSES_MS } = {}) {
|
|
241
246
|
if (TX.get(db)) { for (const step of steps) step(); return 0; }
|
|
242
247
|
let i = 0;
|
|
243
248
|
let chunks = 0;
|
|
249
|
+
let busy = 0;
|
|
244
250
|
while (i < steps.length) {
|
|
245
251
|
if (chunks) { pause(pauseMs); if (onPause) onPause(); }
|
|
246
252
|
const started = performance.now();
|
|
247
|
-
|
|
248
|
-
|
|
249
|
-
|
|
253
|
+
const first = i;
|
|
254
|
+
try {
|
|
255
|
+
withTx(db, () => {
|
|
256
|
+
do steps[i++](); while (i < steps.length && performance.now() - started < budgetMs);
|
|
257
|
+
}, `${label} (chunk ${chunks + 1})`);
|
|
258
|
+
} catch (e) {
|
|
259
|
+
if (!isBusy(e) || busy >= busyPausesMs.length) throw e;
|
|
260
|
+
txTiming.log(`${label}: database busy, chunk ${chunks + 1} again in ${busyPausesMs[busy]} ms`);
|
|
261
|
+
pause(busyPausesMs[busy++]);
|
|
262
|
+
i = first;
|
|
263
|
+
continue;
|
|
264
|
+
}
|
|
265
|
+
busy = 0;
|
|
250
266
|
chunks++;
|
|
251
267
|
}
|
|
252
268
|
return chunks;
|
|
@@ -20,6 +20,10 @@ const MAX_OUTPUT = 2 * 1024 * 1024;
|
|
|
20
20
|
/** Answers took 0.7-9.8 min in one chat resumed for two days; a fresh, small chat answers well inside this. */
|
|
21
21
|
export const RUN_TIMEOUT_MS = 5 * 60e3;
|
|
22
22
|
const MAX_ATTEMPTS = 3;
|
|
23
|
+
/** Claims also count a run's own lapsed claim taken back after a stall; past this many, a message no run finishes is given up. */
|
|
24
|
+
const MAX_CLAIMS = 10;
|
|
25
|
+
/** A run that ends while the lease is away (a stalled runner lapses it for a moment) waits this long for it to come back. */
|
|
26
|
+
export const HELD_WAIT_MS = 60e3;
|
|
23
27
|
/** Runs cut off by restarts do not use up attempts; past this many restarts a message is given up, not looped on. */
|
|
24
28
|
export const MAX_RESTARTS = 3;
|
|
25
29
|
/** Runs at once, one chat each. One, as before: a second question waits one run at most, FYIs none. */
|
|
@@ -129,10 +133,19 @@ ${voice || ''}`.trim();
|
|
|
129
133
|
const topicOf = (sender) => (sender === 'user' ? 'user' : `agent/${sender}`);
|
|
130
134
|
const errorText = (error) => String(error && error.message ? error.message : error);
|
|
131
135
|
|
|
136
|
+
/** Why a cursor-agent run failed (exit, signal, stderr), never its command line: that holds the whole prompt. */
|
|
137
|
+
function runError(error) {
|
|
138
|
+
if (!error || !String(error.message || '').startsWith('Command failed:')) return errorText(error);
|
|
139
|
+
const how = error.killed ? `timed out (${error.signal})` : error.code != null ? `exit ${error.code}` : `signal ${error.signal}`;
|
|
140
|
+
const said = String(error.stderr || error.stdout || '').trim().split('\n').slice(-3).join(' ').slice(0, 300);
|
|
141
|
+
return `cursor-agent ${how}${said ? `: ${said}` : ''}`;
|
|
142
|
+
}
|
|
143
|
+
|
|
132
144
|
export function createManager({
|
|
133
145
|
hub, state, run = runFile, onChange = () => {}, parallel = PARALLEL, now = Date.now, log = console.error, ackRetryMs = RETRY_MS,
|
|
134
146
|
held = () => true,
|
|
135
147
|
fyi = null,
|
|
148
|
+
heldWaitMs = HELD_WAIT_MS,
|
|
136
149
|
}) {
|
|
137
150
|
const configFile = path.join(state, 'manager.json');
|
|
138
151
|
const statusFile = path.join(state, 'manager-status.json');
|
|
@@ -143,6 +156,8 @@ export function createManager({
|
|
|
143
156
|
const { messaging, registry, leases } = hub;
|
|
144
157
|
/** message id → { message, leaseToken, slot, startedAt, abort } */
|
|
145
158
|
const inflight = new Map();
|
|
159
|
+
/** message id → { count, error } of its runs that failed; claims alone are no attempts. */
|
|
160
|
+
const failures = new Map();
|
|
146
161
|
const runs = new Set();
|
|
147
162
|
let lastHeartbeat = 0;
|
|
148
163
|
let timer = null;
|
|
@@ -222,6 +237,15 @@ export function createManager({
|
|
|
222
237
|
}, 'manager answer'), { delaysMs: ackRetryMs, label: `manager answer to ${m.id}`, log });
|
|
223
238
|
}
|
|
224
239
|
|
|
240
|
+
/** Only the lease holder settles; a stall that lapsed the lease here gets it back moments later. */
|
|
241
|
+
async function heldAgain(signal) {
|
|
242
|
+
for (let waited = 0; !held(); waited += 250) {
|
|
243
|
+
if (signal.aborted || waited >= heldWaitMs) return false;
|
|
244
|
+
await new Promise((resolve) => setTimeout(resolve, 250));
|
|
245
|
+
}
|
|
246
|
+
return !signal.aborted;
|
|
247
|
+
}
|
|
248
|
+
|
|
225
249
|
async function answer(f) {
|
|
226
250
|
const { message: m, slot, abort } = f;
|
|
227
251
|
const chat = await chatFor(slot, abort.signal);
|
|
@@ -236,6 +260,10 @@ export function createManager({
|
|
|
236
260
|
if (envelope && envelope.is_error) throw new Error(`cursor-agent failed: ${String(textOf(envelope) || 'error').slice(0, 300)}`);
|
|
237
261
|
const actions = parseManagerActions(stdout);
|
|
238
262
|
if (!actions.reply) throw new Error('manager returned no reply');
|
|
263
|
+
if (!(await heldAgain(abort.signal))) {
|
|
264
|
+
if (!abort.signal.aborted) log(`manager answer to ${m.id} held back: the manager lease is not held here`);
|
|
265
|
+
return null;
|
|
266
|
+
}
|
|
239
267
|
await settle(m, actions);
|
|
240
268
|
recordTurn(slot, chat.chatId, m, prompt, actions.reply);
|
|
241
269
|
if (registry.fact('manager-restart', m.id)) registry.dropFact('manager-restart', m.id);
|
|
@@ -252,11 +280,16 @@ export function createManager({
|
|
|
252
280
|
const done = answer(f).then((replied) => ({ replied }), (error) => ({ error })).then(({ replied, error }) => {
|
|
253
281
|
inflight.delete(m.id);
|
|
254
282
|
runs.delete(done);
|
|
255
|
-
if (f.abort.signal.aborted) return;
|
|
256
|
-
|
|
283
|
+
if (f.abort.signal.aborted) { log(`manager run for ${m.id} was stopped before it finished; it is not counted as a try`); return; }
|
|
284
|
+
const why = error ? runError(error).slice(0, 500) : null;
|
|
285
|
+
if (error) {
|
|
286
|
+
const failed = { count: ((failures.get(m.id) || {}).count || 0) + 1, error: why };
|
|
287
|
+
failures.set(m.id, failed);
|
|
288
|
+
log(`manager failed for ${m.id} (try ${failed.count} of ${MAX_ATTEMPTS}): ${why}`);
|
|
289
|
+
} else if (replied) failures.delete(m.id);
|
|
257
290
|
status({
|
|
258
291
|
...(replied || {}), state: error ? 'error' : inflight.size ? 'working' : 'online', messageId: error ? m.id : null,
|
|
259
|
-
error: error ?
|
|
292
|
+
error: why, inflight: inflightView(), ...(error ? { lastFailure: { id: m.id, from: m.from, error: why, at: now() } } : {}),
|
|
260
293
|
});
|
|
261
294
|
}).catch((e) => log(`manager status after ${m.id} failed: ${errorText(e)}`));
|
|
262
295
|
runs.add(done);
|
|
@@ -315,9 +348,14 @@ export function createManager({
|
|
|
315
348
|
const own = inflight.get(message.id);
|
|
316
349
|
if (own) { own.leaseToken = message.leaseToken; continue; }
|
|
317
350
|
if (isFyi(message)) { if (!(fyi && fyi(message))) messaging.ack('manager', message.id, {}, registry.directory()); continue; }
|
|
318
|
-
|
|
319
|
-
|
|
320
|
-
|
|
351
|
+
const failed = failures.get(message.id);
|
|
352
|
+
if ((failed && failed.count >= MAX_ATTEMPTS) || message.attempts > MAX_CLAIMS) {
|
|
353
|
+
const tries = failed ? failed.count : message.attempts - 1;
|
|
354
|
+
const last = failed ? failed.error : `none of its ${tries} runs finished (each was cut off by a restart or a lost lease)`;
|
|
355
|
+
messaging.ack('manager', message.id, { reply: `I could not answer this after ${tries} tries, so I stopped retrying. Last error: ${last}`, fromName: 'Manager' }, registry.directory());
|
|
356
|
+
failures.delete(message.id);
|
|
357
|
+
log(`manager gave up on ${message.id} after ${tries} tries: ${last}`);
|
|
358
|
+
status({ gaveUp: { id: message.id, from: message.from, tries, error: last, at: t } });
|
|
321
359
|
continue;
|
|
322
360
|
}
|
|
323
361
|
dispatch(message);
|
package/src/lib/contract.sha256
CHANGED
|
@@ -1,2 +1,2 @@
|
|
|
1
|
-
|
|
1
|
+
c9db92b38d0c978d4fa18d8a731fadbf3013a19659b524d615583ad099a8fafc connector-contract.json
|
|
2
2
|
7b7a59c08797c100b46436123dfcb09b517730357fa90cd0890da9b80308faf3 mirror-contract.json
|
package/src/lib/daemonRuntime.js
CHANGED
|
@@ -152,6 +152,7 @@ export class DaemonRuntime {
|
|
|
152
152
|
attemptNo: attempt.attemptNo,
|
|
153
153
|
fenceToken: attempt.fenceToken,
|
|
154
154
|
events,
|
|
155
|
+
itemRefusals: true,
|
|
155
156
|
});
|
|
156
157
|
} catch (error) {
|
|
157
158
|
if (machineWideError(error)) throw error;
|
|
@@ -164,15 +165,32 @@ export class DaemonRuntime {
|
|
|
164
165
|
this.log('events_discarded', { ...trace, discarded });
|
|
165
166
|
continue;
|
|
166
167
|
}
|
|
167
|
-
|
|
168
|
-
|
|
169
|
-
|
|
170
|
-
|
|
171
|
-
|
|
172
|
-
|
|
173
|
-
|
|
168
|
+
await this.#settleUpload(attempt, events, response);
|
|
169
|
+
}
|
|
170
|
+
}
|
|
171
|
+
|
|
172
|
+
/**
|
|
173
|
+
* Apply the cloud's answer to one uploaded batch. A stored event comes back with its `cursor` (`cloudCursor` is read
|
|
174
|
+
* when that is the only one) and leaves the outbox. A refused one ({ refused }) is dropped on its own, so the rest of
|
|
175
|
+
* the batch is kept. An event the answer leaves out stays pending and is sent again.
|
|
176
|
+
*/
|
|
177
|
+
async #settleUpload(attempt, events, response) {
|
|
178
|
+
if (!Array.isArray(response?.events)) return;
|
|
179
|
+
const sent = new Set(events.map((event) => event.sourceEventId));
|
|
180
|
+
const accepted = [];
|
|
181
|
+
const refused = new Map();
|
|
182
|
+
for (const item of response.events) {
|
|
183
|
+
if (!sent.has(item?.sourceEventId)) continue;
|
|
184
|
+
if (item.refused) refused.set(item.sourceEventId, item.refused.code || 'HUB_EVENT_REFUSED');
|
|
185
|
+
else if (Number.isSafeInteger(item.cursor ?? item.cloudCursor)) {
|
|
186
|
+
accepted.push({ sourceEventId: item.sourceEventId, cursor: item.cursor ?? item.cloudCursor });
|
|
174
187
|
}
|
|
175
188
|
}
|
|
189
|
+
if (accepted.length) await this.ledger.acknowledgeEvents(accepted);
|
|
190
|
+
if (!refused.size) return;
|
|
191
|
+
const codes = [...new Set(refused.values())].sort().join(',');
|
|
192
|
+
const { discarded } = await this.ledger.discardEvents(attempt.jobId, codes, { sourceEventIds: [...refused.keys()] });
|
|
193
|
+
this.log('events_refused', { jobId: attempt.jobId, type: attempt.jobType, attemptNo: attempt.attemptNo, code: codes, discarded });
|
|
176
194
|
}
|
|
177
195
|
|
|
178
196
|
#compose(grant) {
|
package/src/lib/hubDbReader.js
CHANGED
|
@@ -129,4 +129,10 @@ export class HubSnapshot {
|
|
|
129
129
|
const statement = this.db.prepare('SELECT kind, key, payload, updated_at FROM facts WHERE kind = ? ORDER BY key');
|
|
130
130
|
return kinds.flatMap((kind) => statement.all(kind).map(plain));
|
|
131
131
|
}
|
|
132
|
+
|
|
133
|
+
/** The `{ kind, key }` of the same facts in the same order, without their payloads. */
|
|
134
|
+
factKeys(kinds) {
|
|
135
|
+
const statement = this.db.prepare('SELECT kind, key FROM facts WHERE kind = ? ORDER BY key');
|
|
136
|
+
return kinds.flatMap((kind) => statement.all(kind).map(plain));
|
|
137
|
+
}
|
|
132
138
|
}
|
package/src/lib/hubScan.js
CHANGED
|
@@ -17,6 +17,7 @@ export const SKIPPED_FACT_KINDS = Object.freeze({
|
|
|
17
17
|
run: 'machine_runtime_state',
|
|
18
18
|
wake: 'machine_runtime_state',
|
|
19
19
|
manager: 'machine_runtime_state',
|
|
20
|
+
'manager-restart': 'machine_runtime_state',
|
|
20
21
|
totals: 'phase1_deviation_totals',
|
|
21
22
|
});
|
|
22
23
|
export const REMOVABLE_KINDS = Object.freeze(['delivery', 'stream', 'agent-row', 'request', 'status_override']);
|
package/src/lib/ledger.js
CHANGED
|
@@ -226,6 +226,7 @@ export class DurableLedger {
|
|
|
226
226
|
return clone(this.data.outbox.slice(0, Math.max(0, Math.min(100, limit))));
|
|
227
227
|
}
|
|
228
228
|
|
|
229
|
+
/** Events the cloud stored, as { sourceEventId, cursor } with the cloud cursor it gave each, the wire's names. */
|
|
229
230
|
async acknowledgeEvents(acknowledgements) {
|
|
230
231
|
return this.#mutate((state) => {
|
|
231
232
|
if (!Array.isArray(acknowledgements) || acknowledgements.length > 100) {
|
|
@@ -237,15 +238,15 @@ export class DurableLedger {
|
|
|
237
238
|
if (
|
|
238
239
|
!pendingIds.has(item.sourceEventId) ||
|
|
239
240
|
seen.has(item.sourceEventId) ||
|
|
240
|
-
!Number.isSafeInteger(item.
|
|
241
|
-
item.
|
|
241
|
+
!Number.isSafeInteger(item.cursor) ||
|
|
242
|
+
item.cursor < 1
|
|
242
243
|
) {
|
|
243
244
|
throw new HubError('HUB_EVENT_ACK_INVALID', 'Event acknowledgement does not match the sent batch');
|
|
244
245
|
}
|
|
245
246
|
seen.add(item.sourceEventId);
|
|
246
247
|
}
|
|
247
248
|
const accepted = new Map(
|
|
248
|
-
acknowledgements.map((item) => [item.sourceEventId, Number(item.
|
|
249
|
+
acknowledgements.map((item) => [item.sourceEventId, Number(item.cursor)])
|
|
249
250
|
);
|
|
250
251
|
for (const event of state.outbox) {
|
|
251
252
|
if (!accepted.has(event.sourceEventId) || !TERMINAL_STATES.has(event.terminalState)) continue;
|
|
@@ -465,19 +466,27 @@ export class DurableLedger {
|
|
|
465
466
|
}
|
|
466
467
|
|
|
467
468
|
/**
|
|
468
|
-
* Drop a job's pending events the cloud refused for good (its fence is gone or the batch is invalid)
|
|
469
|
-
*
|
|
469
|
+
* Drop a job's pending events the cloud refused for good: all of them (its fence is gone or the batch is invalid),
|
|
470
|
+
* or only sourceEventIds (the cloud refused those items and stored the rest). They never count toward
|
|
471
|
+
* acceptedLocalCursor. A terminal attempt has nothing left to upload once its terminal event is gone.
|
|
470
472
|
*/
|
|
471
|
-
async discardEvents(jobId, code) {
|
|
473
|
+
async discardEvents(jobId, code, { sourceEventIds = null } = {}) {
|
|
474
|
+
const only = sourceEventIds ? new Set(sourceEventIds) : null;
|
|
472
475
|
return this.#mutate((state) => {
|
|
473
|
-
const
|
|
474
|
-
|
|
476
|
+
const dropped = (event) => event.jobId === jobId && (!only || only.has(event.sourceEventId));
|
|
477
|
+
const gone = state.outbox.filter(dropped);
|
|
478
|
+
state.outbox = state.outbox.filter((event) => !dropped(event));
|
|
475
479
|
const attempt = state.attempts[jobId];
|
|
476
|
-
if (attempt &&
|
|
477
|
-
attempt.eventsDiscarded = {
|
|
478
|
-
|
|
480
|
+
if (attempt && gone.length) {
|
|
481
|
+
attempt.eventsDiscarded = {
|
|
482
|
+
count: (only ? attempt.eventsDiscarded?.count || 0 : 0) + gone.length,
|
|
483
|
+
code,
|
|
484
|
+
at: new Date().toISOString(),
|
|
485
|
+
};
|
|
486
|
+
const terminalGone = !only || gone.some((event) => event.terminalState === attempt.state);
|
|
487
|
+
if (TERMINAL_STATES.has(attempt.state) && terminalGone) attempt.terminalUploaded = true;
|
|
479
488
|
}
|
|
480
|
-
return { discarded };
|
|
489
|
+
return { discarded: gone.length };
|
|
481
490
|
});
|
|
482
491
|
}
|
|
483
492
|
|
package/src/lib/lifecycle.js
CHANGED
|
@@ -3,10 +3,12 @@ import { HubError } from './errors.js';
|
|
|
3
3
|
import { validateJobResult } from './intents.js';
|
|
4
4
|
import { redactText } from './redaction.js';
|
|
5
5
|
|
|
6
|
+
// Hex, so the id always starts with a letter or digit: Pulse clouds before 1.1.3 refuse a sourceEventId starting with
|
|
7
|
+
// '-' or '_', which a base64url digest does about 3% of the time.
|
|
6
8
|
function eventId(tuple, type) {
|
|
7
9
|
return createHash('sha256')
|
|
8
10
|
.update(`${tuple.jobId}\0${tuple.attemptNo}\0${tuple.fenceToken}\0${type}`)
|
|
9
|
-
.digest('
|
|
11
|
+
.digest('hex');
|
|
10
12
|
}
|
|
11
13
|
|
|
12
14
|
export class AttemptCoordinator {
|