@agentpulselabs/cli-plugin-pulse 1.1.3 → 1.1.6

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 CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@agentpulselabs/cli-plugin-pulse",
3
- "version": "1.1.3",
3
+ "version": "1.1.6",
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/channelFiles.js && node --check src/runner/fileReads.js && node --check src/runner/fileReadsWorker.js && node --check src/runner/transcriptFeed.js && node --check src/runner/collectWorker.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": {
@@ -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,27 +232,61 @@ 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
- * Returns how many transactions it used.
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
- withTx(db, () => {
248
- do steps[i++](); while (i < steps.length && performance.now() - started < budgetMs);
249
- }, `${label} (chunk ${chunks + 1})`);
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;
253
269
  }
254
270
 
271
+ /**
272
+ * Pulse runner-stalls round 2: when discovery last saved a pass. A pass rewrites only the rows that moved, so a row's
273
+ * updated_at no longer says when a pass last confirmed it; this does. Kept out of the facts, where capture and the
274
+ * change feed would see it on every pass.
275
+ */
276
+ export function recordDiscoveryPass(db, at = Date.now()) {
277
+ runSql(db, 'CREATE TABLE IF NOT EXISTS discovery_pass (id INTEGER PRIMARY KEY CHECK (id = 1), at INTEGER NOT NULL)');
278
+ db.prepare('INSERT INTO discovery_pass (id, at) VALUES (1, ?) ON CONFLICT (id) DO UPDATE SET at = excluded.at').run(at);
279
+ }
280
+
281
+ /** When discovery last saved a pass (recordDiscoveryPass), or 0 before the first. */
282
+ export function lastDiscoveryPass(db) {
283
+ try {
284
+ return Number(db.prepare('SELECT at FROM discovery_pass WHERE id = 1').get()?.at) || 0;
285
+ } catch {
286
+ return 0;
287
+ }
288
+ }
289
+
255
290
  export function jsonCol(v) {
256
291
  if (v == null) return null;
257
292
  return typeof v === 'string' ? v : JSON.stringify(v);
@@ -4,6 +4,7 @@
4
4
  // and again after WAKE_AGAIN_MS if your message is still waiting.
5
5
  import { ADDRESS, managerChats } from './ids.mjs';
6
6
  import { edgeId } from './edgeIds.mjs';
7
+ import { lastDiscoveryPass } from './db.mjs';
7
8
 
8
9
  export const WAKE_AGAIN_MS = 15 * 60e3;
9
10
  /** No hook event for this long: the subagent is not running (long commands can be silent for minutes). */
@@ -59,8 +60,9 @@ const nudgeText = (every) => `Scheduled check-in from the hub (every ${every} mi
59
60
  /**
60
61
  * Agents finished by what the Board shows: a stream whose final review passed, or a done agent row or delivery. Rows
61
62
  * and deliveries are only as fresh as the last collect, so one older than the agent's last sign of life does not count.
63
+ * A row is as fresh as the last saved pass (`passAt`), which rewrites only the rows that moved.
62
64
  */
63
- function doneIds(registry, actors, seen) {
65
+ function doneIds(registry, actors, seen, passAt = 0) {
64
66
  const rows = new Map(registry.facts('agent-row').map((r) => [r.key, r]));
65
67
  const deliveries = new Map(registry.facts('delivery').map((d) => [d.key, d]));
66
68
  const done = new Set();
@@ -69,7 +71,7 @@ function doneIds(registry, actors, seen) {
69
71
  const key = a.stream_key || (row && row.streamKey);
70
72
  const stream = key && registry.fact('stream', key);
71
73
  const d = a.node_type === 'orchestrator' && deliveries.get(a.id);
72
- const current = (f) => f && f.status === 'done' && f.updated_at >= (seen.get(a.id) || 0);
74
+ const current = (f) => f && f.status === 'done' && Math.max(f.updated_at, passAt) >= (seen.get(a.id) || 0);
73
75
  if ((stream && stream.done) || current(row) || current(d)) done.add(a.id);
74
76
  }
75
77
  return done;
@@ -91,7 +93,7 @@ export function scheduledNudge(hub, { everyMin, now = Date.now(), skip = null, r
91
93
  for (const chat of managerChats(registry.fact('manager', 'config'))) exclude.add(chat);
92
94
  if (skip) for (const id of skip(last)) exclude.add(id);
93
95
  if (released) for (const id of released()) pending.delete(id);
94
- const targets = nudgeTargets({ actors, seen, done: doneIds(registry, actors, seen), pending, exclude, now });
96
+ const targets = nudgeTargets({ actors, seen, done: doneIds(registry, actors, seen, lastDiscoveryPass(db)), pending, exclude, now });
95
97
  const dir = targets.length ? registry.directory(now) : [];
96
98
  const sent = [];
97
99
  for (const t of targets) {
@@ -2,7 +2,7 @@
2
2
  // Discovery worker: scans transcripts and channels, writes inferred actors and facts. Does not serve GET.
3
3
  import crypto from 'node:crypto';
4
4
  import { DERIVED_REQUEST_PREFIX, collect } from '../lib/collect.mjs';
5
- import { writeInChunks } from '../core/db.mjs';
5
+ import { recordDiscoveryPass, writeInChunks } from '../core/db.mjs';
6
6
  import { nodeTypeOf } from '../core/ids.mjs';
7
7
  import { projectGraph } from '../projections/graph.mjs';
8
8
 
@@ -16,6 +16,15 @@ export const DISCOVERY_REUSE_MS = 30e3;
16
16
  export const CANONICAL_HOURS = 168;
17
17
 
18
18
  const lastWritten = new WeakMap();
19
+ /**
20
+ * Pulse runner-stalls: per db, every row the last saved pass wrote, as hashes of what it wrote and of the row it left.
21
+ * A row whose input and current row both still match is not written again, so a pass where little moved takes the
22
+ * write lock, and gives capture journal rows, only for what did.
23
+ */
24
+ const lastRows = new WeakMap();
25
+ const rowHash = (text) => crypto.createHash('sha1').update(String(text)).digest('base64');
26
+ /** The message ids conversations.importTranscriptTurn and forgetOpening use for a session's opening turn. */
27
+ const openingIds = (id) => { const base = `t-${`${id}-open`.slice(0, 40)}`; return [`${base}-p`, `${base}-r`]; };
19
28
 
20
29
  /** What a pass would write, minus what moves on every pass by itself (generatedAt, the manager node's window). */
21
30
  function discoverySignature(registry, graph) {
@@ -26,7 +35,7 @@ function discoverySignature(registry, graph) {
26
35
  }
27
36
 
28
37
  /**
29
- * Saves a discovery pass. Returns false, writing nothing, when it matches the last pass saved through this db. A fact
38
+ * Saves a discovery pass. Returns false, writing no rows (only the pass time), when it matches the last pass saved through this db. A fact
30
39
  * kind written here must be listed in DISCOVERY_FACTS (core/changes.mjs) or each pass wakes the change feed.
31
40
  *
32
41
  * A full pass is ~1.5k actor and ~1.1k fact writes (0.3-0.4 s on the live hub.db), so it goes in short transactions
@@ -36,18 +45,39 @@ function discoverySignature(registry, graph) {
36
45
  export function persistDiscovery(hub, graph, chunks = {}) {
37
46
  const { registry, conversations, db } = hub;
38
47
  const signature = discoverySignature(registry, graph);
39
- if (lastWritten.get(db) === signature) return false;
48
+ if (lastWritten.get(db) === signature) {
49
+ // Nothing moved, but this pass still confirmed every row (core/wake.mjs doneIds reads when).
50
+ writeInChunks(db, [() => recordDiscoveryPass(db)], { label: 'discovery pass', ...chunks });
51
+ return false;
52
+ }
40
53
  const now = graph.generatedAt || Date.now();
54
+ const rows = lastRows.get(db) || new Map();
55
+ const kept = new Map();
41
56
  const steps = [];
42
- const step = (fn) => steps.push(fn);
43
- for (const n of graph.nodes || []) step(() => {
57
+ /** A keyed step is skipped while `input` and the row `read` returns are what the last saved pass left. */
58
+ const step = (fn, key = null, input = null, read = null) => {
59
+ if (key === null) { steps.push(fn); return; }
60
+ const want = rowHash(JSON.stringify(input));
61
+ const was = rows.get(key);
62
+ if (was && was.input === want && was.row === rowHash(read())) { kept.set(key, was); return; }
63
+ steps.push(() => { fn(); kept.set(key, { input: want, row: rowHash(read()) }); });
64
+ };
65
+ const actorRow = db.prepare('SELECT * FROM actors WHERE id = ?');
66
+ const factRow = db.prepare('SELECT payload FROM facts WHERE kind = ? AND key = ?');
67
+ const factKeys = db.prepare("SELECT kind, key FROM facts WHERE kind IN ('delivery', 'stream', 'agent-row') ORDER BY kind, key");
68
+ const restRows = db.prepare("SELECT kind, key, payload FROM facts WHERE kind IN ('request', 'totals') ORDER BY kind, key");
69
+ const openingRows = db.prepare('SELECT m.id, d.recipient FROM messages m LEFT JOIN deliveries d ON d.message_id = m.id WHERE m.id IN (?, ?) ORDER BY m.id');
70
+ const readActor = (id) => () => JSON.stringify(actorRow.get(id) ?? null);
71
+ const readFact = (kind, key) => () => factRow.get(kind, key)?.payload ?? '';
72
+ const readOpening = (id) => () => JSON.stringify(openingRows.all(...openingIds(id)));
73
+ for (const n of graph.nodes || []) {
44
74
  const parentId = n.parent || n.launchedBy || null;
45
75
  const synthetic = !!n.synthetic;
46
76
  const nodeType = n.node_type || nodeTypeOf({
47
77
  id: n.id, parentId, synthetic, role: n.role,
48
78
  });
49
79
  const persona = n.persona || {};
50
- registry.upsertInferred({
80
+ const patch = {
51
81
  id: n.id,
52
82
  node_type: nodeType,
53
83
  role: n.role || (nodeType === 'orchestrator' ? 'ARCHITECT' : null),
@@ -65,22 +95,29 @@ export function persistDiscovery(hub, graph, chunks = {}) {
65
95
  started_at: n.startedAt, last_at: n.lastAt, tokens_est: n.tokensEst,
66
96
  msgs_in: n.msgsIn, msgs_out: n.msgsOut, repo: n.repo || n.project, channel: n.channel,
67
97
  synthetic, extra: { streams: n.streams, status: n.status, kind: n.kind },
68
- });
98
+ };
99
+ step(() => registry.upsertInferred(patch), `actor:${n.id}`, patch, readActor(n.id));
69
100
  // Presence is owned by the hook heartbeat. Discovery only records last_at on the actor.
70
- });
101
+ }
71
102
  const deliveries = graph.deliveries || [];
72
103
  const accepted = new Map(registry.facts('accepted').map((a) => [a.key, a.at || a.updated_at]));
73
- for (const d of deliveries) step(() => registry.setFact('delivery', d.id, accepted.has(d.id) ? { ...d, acceptedAt: accepted.get(d.id) } : d));
74
- for (const d of deliveries) for (const s of d.streams || []) step(() => registry.setFact('stream', s.key, { ...s, deliveryId: d.id }));
75
- for (const a of graph.agents || []) step(() => registry.setFact('agent-row', a.id, a));
104
+ const setFact = (kind, key, payload) => step(() => registry.setFact(kind, key, payload), `fact:${kind}:${key}`, payload, readFact(kind, key));
105
+ for (const d of deliveries) setFact('delivery', d.id, accepted.has(d.id) ? { ...d, acceptedAt: accepted.get(d.id) } : d);
106
+ for (const d of deliveries) for (const s of d.streams || []) setFact('stream', s.key, { ...s, deliveryId: d.id });
107
+ for (const a of graph.agents || []) setFact('agent-row', a.id, a);
108
+ const keys = {
109
+ delivery: deliveries.map((d) => d.id),
110
+ stream: deliveries.flatMap((d) => (d.streams || []).map((s) => s.key)),
111
+ 'agent-row': (graph.agents || []).map((a) => a.id),
112
+ };
76
113
  step(() => {
77
- registry.keepFacts('delivery', deliveries.map((d) => d.id));
78
- registry.keepFacts('stream', deliveries.flatMap((d) => (d.streams || []).map((s) => s.key)));
79
- registry.keepFacts('agent-row', (graph.agents || []).map((a) => a.id));
80
- });
81
- for (const n of graph.nodes || []) if (n.kind === 'agent') step(() => conversations.forgetOpening(n.id));
114
+ registry.keepFacts('delivery', keys.delivery);
115
+ registry.keepFacts('stream', keys.stream);
116
+ registry.keepFacts('agent-row', keys['agent-row']);
117
+ }, 'keep', keys, () => JSON.stringify(factKeys.all()));
118
+ for (const n of graph.nodes || []) if (n.kind === 'agent') step(() => conversations.forgetOpening(n.id), `forget:${n.id}`, null, readOpening(n.id));
82
119
  const requests = (graph.requests || []).map((r) => [r.id || `r-${r.at}`, r]);
83
- for (const [key, r] of requests) step(() => registry.setFact('request', key, r));
120
+ for (const [key, r] of requests) setFact('request', key, r);
84
121
  step(() => {
85
122
  // Only post-derived requests are discovery's to drop; filed ones (hub.mjs request) live as facts alone.
86
123
  const current = new Set(requests.map(([key]) => key));
@@ -90,18 +127,21 @@ export function persistDiscovery(hub, graph, chunks = {}) {
90
127
  // The projection counts each window's totals from this one basis; per-window facts older passes wrote are dropped.
91
128
  if (graph.totalsBasis) registry.setFact('totals', 'basis', graph.totalsBasis);
92
129
  registry.keepFacts('totals', graph.totalsBasis ? ['basis'] : []);
93
- });
130
+ }, 'requests', [[...requests.map(([key]) => key)].sort(), graph.totalsBasis ?? null], () => JSON.stringify(restRows.all()));
94
131
  for (const n of graph.nodes || []) {
95
132
  if (n.kind !== 'session' || n.parent || n.launchedBy) continue;
96
133
  if (n.promptTitle || n.title) {
134
+ // A transcript turn is inserted once and never updated, so a pass's own clock is no part of its input.
97
135
  step(() => conversations.importTranscriptTurn({
98
136
  sessionId: n.id, turnId: `${n.id}-open`, at: n.startedAt || now,
99
137
  prompt: n.promptTitle || n.title, reply: null,
100
- }));
138
+ }), `opening:${n.id}`, [n.startedAt || null, n.promptTitle || n.title], readOpening(n.id));
101
139
  }
102
140
  }
141
+ step(() => recordDiscoveryPass(db));
103
142
  writeInChunks(db, steps, { label: 'discovery', ...chunks });
104
143
  lastWritten.set(db, signature);
144
+ lastRows.set(db, kept);
105
145
  return true;
106
146
  }
107
147
 
@@ -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
- if (error) log(`manager failed for ${m.id}: ${errorText(error)}`);
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 ? errorText(error).slice(0, 500) : null, inflight: inflightView(),
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
- if (message.attempts > MAX_ATTEMPTS) {
319
- const last = readJson(statusFile, {}).error || 'unknown error';
320
- messaging.ack('manager', message.id, { reply: `I could not answer this after ${MAX_ATTEMPTS} tries, so I stopped retrying. Last error: ${last}`, fromName: 'Manager' }, registry.directory());
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);
@@ -1,2 +1,2 @@
1
- ce1142e1e133c8aff45a999d0d4b67faa6177f420ddcd1b449af81f8e806d8d0 connector-contract.json
1
+ c9db92b38d0c978d4fa18d8a731fadbf3013a19659b524d615583ad099a8fafc connector-contract.json
2
2
  7b7a59c08797c100b46436123dfcb09b517730357fa90cd0890da9b80308faf3 mirror-contract.json
@@ -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
- if (Array.isArray(response.events)) {
168
- await this.ledger.acknowledgeEvents(
169
- response.events.map((event) => ({
170
- sourceEventId: event.sourceEventId,
171
- cloudCursor: event.cloudCursor,
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) {
@@ -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
  }
@@ -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.cloudCursor) ||
241
- item.cloudCursor < 1
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.cloudCursor)])
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). They never
469
- * count toward acceptedLocalCursor, and a terminal attempt has nothing left to upload.
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 discarded = state.outbox.filter((event) => event.jobId === jobId).length;
474
- state.outbox = state.outbox.filter((event) => event.jobId !== jobId);
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 && discarded) {
477
- attempt.eventsDiscarded = { count: discarded, code, at: new Date().toISOString() };
478
- if (TERMINAL_STATES.has(attempt.state)) attempt.terminalUploaded = true;
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
 
@@ -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('base64url');
11
+ .digest('hex');
10
12
  }
11
13
 
12
14
  export class AttemptCoordinator {
@@ -150,6 +150,7 @@ export class MirrorSource {
150
150
  this.redaction = new UploadRedactor({ workspace, home, approvedRoots });
151
151
  this.windowCache = new Map();
152
152
  this.rowMemo = new RowMemo();
153
+ this.itemMemo = new RowMemo();
153
154
  this.briefs = new Map();
154
155
  this.taskKeys = null;
155
156
  }
@@ -388,12 +389,18 @@ export class MirrorSource {
388
389
  checkpoint();
389
390
  const secrets = [await this.reader.tokenValue().catch(() => null), ...this.heldSecrets()].filter(Boolean);
390
391
  const redactor = this.redaction.redactor(secrets);
392
+ const basis = this.redaction.basis(secrets);
391
393
  const items = new Map();
394
+ // An item's redacted, checked form depends on its kind and data and on the redaction basis, so one that did not
395
+ // move since the last pass is not redacted and checked again.
396
+ this.itemMemo.begin(basis);
392
397
  for (const entry of raw) {
393
398
  if (typeof entry.externalId !== 'string' || !entry.externalId) continue;
394
399
  const externalId = externalIdFor(entry.externalId);
395
- const redacted = redactor.redactValue(entry.data, { keep: entry.kind === 'rule_pack' ? ['file'] : [] });
396
- const result = sanitizeItem(entry.kind, entry.kind === 'brief' ? markTruncated(redacted) : redacted, { secrets });
400
+ const result = this.itemMemo.take(`${entry.kind}\0${JSON.stringify(entry.data)}`, () => {
401
+ const redacted = redactor.redactValue(entry.data, { keep: entry.kind === 'rule_pack' ? ['file'] : [] });
402
+ return sanitizeItem(entry.kind, entry.kind === 'brief' ? markTruncated(redacted) : redacted, { secrets });
403
+ });
397
404
  if (result.error) {
398
405
  withheld.push({ kind: entry.kind, externalId, error: result.error });
399
406
  continue;
@@ -405,19 +412,25 @@ export class MirrorSource {
405
412
  const graphDropped = {};
406
413
  if (windows.length === WINDOW_HOURS.length) {
407
414
  // A row's redacted, checked form depends on the roots the redactor knows and the secrets, and on nothing else.
408
- this.rowMemo.begin(this.redaction.basis(secrets));
415
+ this.rowMemo.begin(basis);
409
416
  const options = { redact: (value) => redactor.redactValue(value), secrets, memo: this.rowMemo };
410
- const built = mergeWindows(windows.map(({ hours, body, fresh }) => {
417
+ const perWindow = windows.map(({ hours, body, fresh }) => {
411
418
  if (!fresh) return this.windowCache.get(hours).rows;
412
419
  const rows = windowRows(hours, body, options);
413
420
  this.windowCache.set(hours, { body, rows });
414
421
  return rows;
415
- }));
422
+ });
423
+ // Each checked row's canonical text, so an item is keyed by its windows and that text, not stringified again.
424
+ const textOf = new Map(perWindow.flatMap(({ rows }) => rows.map(({ row, text }) => [row, text])));
425
+ const built = mergeWindows(perWindow);
416
426
  Object.assign(graphDropped, built.dropped);
417
427
  const updatedAt = firstTime(Math.max(...windows.map(({ body }) => Number(body.generatedAt) || 0)), fallback);
418
428
  for (const row of built.items) {
419
429
  const externalId = externalIdFor(row.externalId);
420
- const result = this.rowMemo.take(`item\0${JSON.stringify(row.data)}`, () => sanitizeItem('graph_window', row.data, { secrets }));
430
+ const { section, windows: held } = row.data;
431
+ const text = textOf.get(row.data[section]);
432
+ const key = text === undefined ? JSON.stringify(row.data) : `${section}\0${held.join(',')}\0${text}`;
433
+ const result = this.rowMemo.take(`item\0${key}`, () => sanitizeItem('graph_window', row.data, { secrets }));
421
434
  if (result.error) {
422
435
  withheld.push({ kind: 'graph_window', externalId, error: result.error });
423
436
  continue;