agents-relay 2.0.31 → 2.0.33

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.
@@ -0,0 +1,268 @@
1
+ export const SCHEMA_VERSION = 5;
2
+ const UNSAFE_QUEUED_KEY_SUFFIX = /:(?:queued|lost):\d+$/;
3
+ /** Logical operation key recovered from a possibly queued/lost idempotency key. */
4
+ export function logicalOperationKey(idempotencyKey) {
5
+ return idempotencyKey.replace(UNSAFE_QUEUED_KEY_SUFFIX, '');
6
+ }
7
+ export function rowToProjection(row) {
8
+ return {
9
+ id: Number(row.id),
10
+ idempotencyKey: row.idempotency_key,
11
+ operationKey: row.operation_key ?? logicalOperationKey(row.idempotency_key),
12
+ coalesceKey: row.coalesce_key,
13
+ operation: row.operation,
14
+ payload: JSON.parse(row.payload_json),
15
+ priority: Number(row.priority),
16
+ attempts: Number(row.attempts),
17
+ nextAttemptAt: row.next_attempt_at,
18
+ lastError: row.last_error,
19
+ createdAt: row.created_at,
20
+ updatedAt: row.updated_at,
21
+ };
22
+ }
23
+ export function rowToConflict(row) {
24
+ const evidence = row.conflict_evidence_json ? JSON.parse(row.conflict_evidence_json) : {};
25
+ const resolution = row.conflict_resolution_json ? JSON.parse(row.conflict_resolution_json) : null;
26
+ const operationKey = row.operation_key ?? logicalOperationKey(row.idempotency_key);
27
+ return {
28
+ id: Number(row.id),
29
+ status: (row.status ?? 'conflict'),
30
+ operation: row.operation,
31
+ operationKey,
32
+ attempts: Number(row.attempts),
33
+ diagnostic: {
34
+ kind: evidence.kind ?? 'task',
35
+ jobId: String(evidence.jobId ?? ''),
36
+ subjectId: String(evidence.subjectId ?? ''),
37
+ operation: row.operation,
38
+ operationKey,
39
+ localDefinition: row.conflict_definition_json ? JSON.parse(row.conflict_definition_json) : null,
40
+ remoteDefinition: evidence.remoteDefinition ?? null,
41
+ divergingFields: Array.isArray(evidence.divergingFields) ? evidence.divergingFields.map(String) : [],
42
+ detectedAt: row.conflict_detected_at ?? row.updated_at,
43
+ message: String(evidence.message ?? row.last_error ?? 'projection definition conflict'),
44
+ },
45
+ resolution,
46
+ };
47
+ }
48
+ const OUTBOX_COLUMNS = 'id,idempotency_key,operation_key,coalesce_key,operation,payload_json,priority,status,attempts,next_attempt_at,last_error,created_at,updated_at,conflict_definition_json,conflict_evidence_json,conflict_detected_at,conflict_disposition,conflict_resolution_json';
49
+ /**
50
+ * Durable projection outbox persistence.
51
+ *
52
+ * Owns the schema, pending/processing/conflict/dead state machine storage, coalescing,
53
+ * claiming, retry/backoff bookkeeping, deterministic-conflict diagnostics, and explicit repair.
54
+ * It is deliberately independent of Job/Task/Plan semantics: callers persist domain state
55
+ * separately and enqueue one projection request.
56
+ */
57
+ export class ProjectionOutbox {
58
+ db;
59
+ constructor(db) {
60
+ this.db = db;
61
+ }
62
+ static createTableSql(table = 'projection_outbox') {
63
+ return `CREATE TABLE IF NOT EXISTS ${table} (
64
+ id INTEGER PRIMARY KEY AUTOINCREMENT,
65
+ idempotency_key TEXT NOT NULL UNIQUE,
66
+ operation_key TEXT,
67
+ coalesce_key TEXT,
68
+ operation TEXT NOT NULL,
69
+ payload_json TEXT NOT NULL,
70
+ priority INTEGER NOT NULL DEFAULT 0,
71
+ status TEXT NOT NULL DEFAULT 'pending' CHECK(status IN ('pending','processing','conflict','dead')),
72
+ attempts INTEGER NOT NULL DEFAULT 0,
73
+ next_attempt_at TEXT,
74
+ last_error TEXT,
75
+ created_at TEXT NOT NULL,
76
+ updated_at TEXT NOT NULL,
77
+ conflict_definition_json TEXT,
78
+ conflict_evidence_json TEXT,
79
+ conflict_detected_at TEXT,
80
+ conflict_disposition TEXT,
81
+ conflict_resolution_json TEXT
82
+ );`;
83
+ }
84
+ static createIndexSql() {
85
+ return `
86
+ CREATE INDEX IF NOT EXISTS projection_due_idx ON projection_outbox(status, next_attempt_at, priority DESC, id);
87
+ CREATE INDEX IF NOT EXISTS projection_coalesce_idx ON projection_outbox(coalesce_key, status);
88
+ `;
89
+ }
90
+ /** Rebuild a pre-v5 outbox, backfill logical operation keys, and classify legacy dead conflicts. */
91
+ static upgrade(db, previousVersion) {
92
+ const schema = db.prepare("SELECT sql FROM sqlite_master WHERE type='table' AND name='projection_outbox'").get();
93
+ const sql = schema?.sql ?? '';
94
+ const needsRebuild = sql.length > 0 && (!sql.includes("'conflict'") || !sql.includes('operation_key'));
95
+ db.exec('BEGIN IMMEDIATE');
96
+ try {
97
+ if (needsRebuild) {
98
+ db.exec(`
99
+ DROP INDEX IF EXISTS projection_due_idx;
100
+ DROP INDEX IF EXISTS projection_coalesce_idx;
101
+ ALTER TABLE projection_outbox RENAME TO projection_outbox_legacy;
102
+ ${ProjectionOutbox.createTableSql()}
103
+ INSERT INTO projection_outbox(id,idempotency_key,coalesce_key,operation,payload_json,priority,status,attempts,next_attempt_at,last_error,created_at,updated_at)
104
+ SELECT id,idempotency_key,coalesce_key,operation,payload_json,priority,status,attempts,next_attempt_at,last_error,created_at,updated_at FROM projection_outbox_legacy;
105
+ DROP TABLE projection_outbox_legacy;
106
+ `);
107
+ }
108
+ db.exec(ProjectionOutbox.createIndexSql());
109
+ db.prepare(`
110
+ UPDATE projection_outbox
111
+ SET operation_key = CASE
112
+ WHEN operation_key IS NOT NULL AND operation_key<>'' THEN operation_key
113
+ WHEN idempotency_key GLOB '*:queued:*' THEN substr(idempotency_key, 1, instr(idempotency_key, ':queued:') - 1)
114
+ ELSE idempotency_key END
115
+ WHERE operation_key IS NULL OR operation_key=''
116
+ `).run();
117
+ const diagnostics = db.prepare(`
118
+ SELECT id,operation,idempotency_key,last_error,attempts,updated_at FROM projection_outbox
119
+ WHERE status='dead' AND last_error LIKE '%already exists with a different definition%'
120
+ `).all();
121
+ const promote = db.prepare(`
122
+ UPDATE projection_outbox
123
+ SET status='conflict', conflict_detected_at=?, conflict_disposition='classified_on_migration',
124
+ conflict_evidence_json=?, conflict_definition_json=COALESCE(conflict_definition_json, payload_json)
125
+ WHERE id=?
126
+ `);
127
+ for (const row of diagnostics) {
128
+ const evidence = JSON.stringify({ kind: row.operation === 'append_plan' ? 'plan' : 'task', subjectId: '', jobId: '', divergingFields: [], message: row.last_error ?? 'legacy deterministic projection conflict', migratedFromAttempts: Number(row.attempts), recoveredFromSchema: previousVersion });
129
+ promote.run(row.updated_at, evidence, row.id);
130
+ }
131
+ db.exec('COMMIT');
132
+ }
133
+ catch (error) {
134
+ db.exec('ROLLBACK');
135
+ throw error;
136
+ }
137
+ }
138
+ resetProcessing(now) {
139
+ this.db.prepare("UPDATE projection_outbox SET status='pending', updated_at=? WHERE status='processing'").run(now);
140
+ }
141
+ enqueue(request, now) {
142
+ const operationKey = logicalOperationKey(request.idempotencyKey);
143
+ if (request.coalesceKey) {
144
+ this.db.prepare(`
145
+ DELETE FROM projection_outbox
146
+ WHERE coalesce_key=? AND status='pending' AND operation_key<>?
147
+ `).run(request.coalesceKey, operationKey);
148
+ }
149
+ const existing = this.db.prepare('SELECT status FROM projection_outbox WHERE operation_key=? ORDER BY id DESC LIMIT 1')
150
+ .get(operationKey);
151
+ // A writer may be applying this key outside the transaction, or a deterministic
152
+ // conflict may already be recorded for it. Never rewrite that row's payload: its
153
+ // completion must only remove its own row, and a recorded conflict must stay the
154
+ // classification evidence until explicitly repaired. New mutations queue beside it.
155
+ const nextId = Number(this.db.prepare('SELECT COALESCE(MAX(id),0)+1 AS next_id FROM projection_outbox').get().next_id ?? 1);
156
+ const collides = existing?.status === 'processing' || existing?.status === 'conflict' || existing?.status === 'dead';
157
+ const idempotencyKey = collides ? `${operationKey}:queued:${nextId}` : operationKey;
158
+ this.db.prepare(`
159
+ INSERT INTO projection_outbox(
160
+ idempotency_key,operation_key,coalesce_key,operation,payload_json,priority,status,
161
+ attempts,next_attempt_at,last_error,created_at,updated_at
162
+ ) VALUES(?,?,?,?,?,?,'pending',0,NULL,NULL,?,?)
163
+ ON CONFLICT(idempotency_key) DO UPDATE SET
164
+ operation_key=excluded.operation_key,
165
+ coalesce_key=excluded.coalesce_key,
166
+ operation=excluded.operation,
167
+ payload_json=excluded.payload_json,
168
+ priority=MAX(projection_outbox.priority,excluded.priority),
169
+ status='pending',
170
+ next_attempt_at=NULL,
171
+ last_error=NULL,
172
+ updated_at=excluded.updated_at
173
+ `).run(idempotencyKey, operationKey, request.coalesceKey ?? null, request.operation, JSON.stringify(request.payload), request.priority ?? 0, now, now);
174
+ }
175
+ claimDue(limit, now) {
176
+ const observedAt = now.toISOString();
177
+ this.db.exec('BEGIN IMMEDIATE');
178
+ try {
179
+ const rows = this.db.prepare(`
180
+ SELECT ${OUTBOX_COLUMNS}
181
+ FROM projection_outbox
182
+ WHERE status='pending' AND (next_attempt_at IS NULL OR next_attempt_at<=?)
183
+ ORDER BY priority DESC, id ASC
184
+ LIMIT ?
185
+ `).all(observedAt, Math.max(1, Math.floor(limit)));
186
+ const mark = this.db.prepare("UPDATE projection_outbox SET status='processing', updated_at=? WHERE id=? AND status='pending'");
187
+ const claimed = [];
188
+ for (const row of rows) {
189
+ const result = mark.run(observedAt, row.id);
190
+ if (Number(result.changes) === 1)
191
+ claimed.push({ ...row, updated_at: observedAt });
192
+ }
193
+ this.db.exec('COMMIT');
194
+ return claimed.map(rowToProjection);
195
+ }
196
+ catch (error) {
197
+ this.db.exec('ROLLBACK');
198
+ throw error;
199
+ }
200
+ }
201
+ complete(id) {
202
+ this.db.prepare('DELETE FROM projection_outbox WHERE id=?').run(id);
203
+ }
204
+ retry(id, error, nextAttemptAt) {
205
+ this.db.prepare(`
206
+ UPDATE projection_outbox
207
+ SET status='pending', attempts=attempts+1, next_attempt_at=?, last_error=?, updated_at=?
208
+ WHERE id=?
209
+ `).run(nextAttemptAt.toISOString(), error, new Date().toISOString(), id);
210
+ }
211
+ deadLetter(id, error) {
212
+ this.db.prepare(`
213
+ UPDATE projection_outbox
214
+ SET status='dead', attempts=attempts+1, next_attempt_at=NULL, last_error=?, updated_at=?
215
+ WHERE id=?
216
+ `).run(error, new Date().toISOString(), id);
217
+ }
218
+ recordConflict(id, diagnostic) {
219
+ const evidence = JSON.stringify({
220
+ kind: diagnostic.kind,
221
+ jobId: diagnostic.jobId,
222
+ subjectId: diagnostic.subjectId,
223
+ remoteDefinition: diagnostic.remoteDefinition,
224
+ divergingFields: diagnostic.divergingFields,
225
+ message: diagnostic.message,
226
+ });
227
+ this.db.prepare(`
228
+ UPDATE projection_outbox
229
+ SET status='conflict', attempts=attempts+1, next_attempt_at=NULL, last_error=?,
230
+ conflict_definition_json=?, conflict_evidence_json=?, conflict_detected_at=?,
231
+ conflict_disposition='awaiting_repair', conflict_resolution_json=NULL, updated_at=?
232
+ WHERE id=? AND status NOT IN ('conflict','dead')
233
+ `).run(diagnostic.message, JSON.stringify(diagnostic.localDefinition), evidence, diagnostic.detectedAt, diagnostic.detectedAt, id);
234
+ }
235
+ listConflicts() {
236
+ const rows = this.db.prepare(`
237
+ SELECT ${OUTBOX_COLUMNS} FROM projection_outbox WHERE status='conflict' ORDER BY id ASC
238
+ `).all();
239
+ return rows.map(rowToConflict);
240
+ }
241
+ /** Explicit repair: requeue the original projection, or discard the blocked local mutation for a fresh one. */
242
+ resolveConflict(id, resolution) {
243
+ const now = new Date().toISOString();
244
+ const record = this.db.prepare('SELECT status FROM projection_outbox WHERE id=?').get(id);
245
+ if (!record || record.status !== 'conflict')
246
+ return false;
247
+ const resolutionJson = JSON.stringify({ action: resolution.action, resolvedAt: now, ...(resolution.note ? { note: resolution.note } : {}) });
248
+ if (resolution.action === 'requeue') {
249
+ this.db.prepare(`
250
+ UPDATE projection_outbox
251
+ SET status='pending', attempts=0, next_attempt_at=NULL, conflict_disposition='requeued',
252
+ conflict_resolution_json=?, updated_at=?
253
+ WHERE id=? AND status='conflict'
254
+ `).run(resolutionJson, now, id);
255
+ }
256
+ else {
257
+ this.db.prepare('DELETE FROM projection_outbox WHERE id=? AND status=?').run(id, 'conflict');
258
+ }
259
+ return true;
260
+ }
261
+ conflictCount() { return this.count("status='conflict'"); }
262
+ pendingCount() { return this.count("status IN ('pending','processing')"); }
263
+ terminalCount() { return this.count("status='dead'"); }
264
+ count(where) {
265
+ const row = this.db.prepare(`SELECT COUNT(*) AS count FROM projection_outbox WHERE ${where}`).get();
266
+ return Number(row?.count ?? 0);
267
+ }
268
+ }
@@ -2,7 +2,7 @@ import { DatabaseSync } from 'node:sqlite';
2
2
  import { dirname } from 'node:path';
3
3
  import { mkdirSync } from 'node:fs';
4
4
  import { readdir } from 'node:fs/promises';
5
- const SCHEMA_VERSION = 4;
5
+ import { ProjectionOutbox, SCHEMA_VERSION, } from './projection-outbox.js';
6
6
  function newerRecord(local, remote) {
7
7
  if (!local)
8
8
  return structuredClone(remote);
@@ -30,24 +30,10 @@ export function mergeInboundJobSnapshot(local, remote) {
30
30
  }
31
31
  return base;
32
32
  }
33
- function rowToProjection(row) {
34
- return {
35
- id: Number(row.id),
36
- idempotencyKey: row.idempotency_key,
37
- coalesceKey: row.coalesce_key,
38
- operation: row.operation,
39
- payload: JSON.parse(row.payload_json),
40
- priority: Number(row.priority),
41
- attempts: Number(row.attempts),
42
- nextAttemptAt: row.next_attempt_at,
43
- lastError: row.last_error,
44
- createdAt: row.created_at,
45
- updatedAt: row.updated_at,
46
- };
47
- }
48
33
  export class SQLiteRelayStore {
49
34
  path;
50
35
  db;
36
+ outbox;
51
37
  constructor(path) {
52
38
  this.path = path;
53
39
  if (path !== ':memory:')
@@ -55,8 +41,8 @@ export class SQLiteRelayStore {
55
41
  this.db = new DatabaseSync(path);
56
42
  this.db.exec('PRAGMA journal_mode=WAL; PRAGMA synchronous=NORMAL; PRAGMA busy_timeout=5000; PRAGMA foreign_keys=ON;');
57
43
  this.migrate();
58
- this.db.prepare("UPDATE projection_outbox SET status='pending', updated_at=? WHERE status='processing'")
59
- .run(new Date().toISOString());
44
+ this.outbox = new ProjectionOutbox(this.db);
45
+ this.outbox.resetProcessing(new Date().toISOString());
60
46
  }
61
47
  migrate() {
62
48
  this.db.exec(`
@@ -87,60 +73,15 @@ export class SQLiteRelayStore {
87
73
  sequence INTEGER,
88
74
  received_at TEXT NOT NULL
89
75
  );
90
- CREATE TABLE IF NOT EXISTS projection_outbox (
91
- id INTEGER PRIMARY KEY AUTOINCREMENT,
92
- idempotency_key TEXT NOT NULL UNIQUE,
93
- coalesce_key TEXT,
94
- operation TEXT NOT NULL,
95
- payload_json TEXT NOT NULL,
96
- priority INTEGER NOT NULL DEFAULT 0,
97
- status TEXT NOT NULL DEFAULT 'pending' CHECK(status IN ('pending','processing','dead')),
98
- attempts INTEGER NOT NULL DEFAULT 0,
99
- next_attempt_at TEXT,
100
- last_error TEXT,
101
- created_at TEXT NOT NULL,
102
- updated_at TEXT NOT NULL
103
- );
104
- CREATE INDEX IF NOT EXISTS projection_due_idx
105
- ON projection_outbox(status, next_attempt_at, priority DESC, id);
106
- CREATE INDEX IF NOT EXISTS projection_coalesce_idx
107
- ON projection_outbox(coalesce_key, status);
76
+ ${ProjectionOutbox.createTableSql()}
77
+ ${ProjectionOutbox.createIndexSql()}
108
78
  `);
109
79
  const current = this.db.prepare("SELECT value FROM relay_meta WHERE key='schema_version'").get();
110
80
  const version = current?.value ? Number(current.value) : 0;
111
81
  if (version > SCHEMA_VERSION)
112
82
  throw new Error(`Relay store schema ${version} is newer than supported ${SCHEMA_VERSION}`);
113
- if (version < 4) {
114
- const schema = this.db.prepare("SELECT sql FROM sqlite_master WHERE type='table' AND name='projection_outbox'").get();
115
- if (schema?.sql && !schema.sql.includes("'dead'")) {
116
- this.db.exec(`
117
- BEGIN IMMEDIATE;
118
- DROP INDEX IF EXISTS projection_due_idx;
119
- DROP INDEX IF EXISTS projection_coalesce_idx;
120
- ALTER TABLE projection_outbox RENAME TO projection_outbox_v3;
121
- CREATE TABLE projection_outbox (
122
- id INTEGER PRIMARY KEY AUTOINCREMENT,
123
- idempotency_key TEXT NOT NULL UNIQUE,
124
- coalesce_key TEXT,
125
- operation TEXT NOT NULL,
126
- payload_json TEXT NOT NULL,
127
- priority INTEGER NOT NULL DEFAULT 0,
128
- status TEXT NOT NULL DEFAULT 'pending' CHECK(status IN ('pending','processing','dead')),
129
- attempts INTEGER NOT NULL DEFAULT 0,
130
- next_attempt_at TEXT,
131
- last_error TEXT,
132
- created_at TEXT NOT NULL,
133
- updated_at TEXT NOT NULL
134
- );
135
- INSERT INTO projection_outbox(id,idempotency_key,coalesce_key,operation,payload_json,priority,status,attempts,next_attempt_at,last_error,created_at,updated_at)
136
- SELECT id,idempotency_key,coalesce_key,operation,payload_json,priority,status,attempts,next_attempt_at,last_error,created_at,updated_at FROM projection_outbox_v3;
137
- DROP TABLE projection_outbox_v3;
138
- CREATE INDEX projection_due_idx ON projection_outbox(status, next_attempt_at, priority DESC, id);
139
- CREATE INDEX projection_coalesce_idx ON projection_outbox(coalesce_key, status);
140
- COMMIT;
141
- `);
142
- }
143
- }
83
+ if (version < SCHEMA_VERSION)
84
+ ProjectionOutbox.upgrade(this.db, version);
144
85
  this.db.prepare("INSERT INTO relay_meta(key,value) VALUES('schema_version',?) ON CONFLICT(key) DO UPDATE SET value=excluded.value")
145
86
  .run(String(SCHEMA_VERSION));
146
87
  }
@@ -257,11 +198,14 @@ export class SQLiteRelayStore {
257
198
  `).run(eventId, stream, sequence, new Date().toISOString());
258
199
  return Number(result.changes) === 1;
259
200
  }
201
+ enqueueProjectionInTransaction(request, now) {
202
+ this.outbox.enqueue(request, now);
203
+ }
260
204
  async enqueueProjection(request) {
261
205
  const now = new Date().toISOString();
262
206
  this.db.exec('BEGIN IMMEDIATE');
263
207
  try {
264
- this.enqueueProjectionInTransaction(request, now);
208
+ this.outbox.enqueue(request, now);
265
209
  this.db.exec('COMMIT');
266
210
  }
267
211
  catch (error) {
@@ -269,90 +213,24 @@ export class SQLiteRelayStore {
269
213
  throw error;
270
214
  }
271
215
  }
272
- enqueueProjectionInTransaction(request, now) {
273
- if (request.coalesceKey) {
274
- this.db.prepare(`
275
- DELETE FROM projection_outbox
276
- WHERE coalesce_key=? AND status='pending' AND idempotency_key<>?
277
- `).run(request.coalesceKey, request.idempotencyKey);
278
- }
279
- const existing = this.db.prepare('SELECT status FROM projection_outbox WHERE idempotency_key=?').get(request.idempotencyKey);
280
- // A writer may be applying this key outside the transaction. Never rewrite its
281
- // payload: its completion must only remove its own row, not a newer mutation.
282
- const nextId = Number(this.db.prepare('SELECT COALESCE(MAX(id),0)+1 AS next_id FROM projection_outbox').get().next_id ?? 1);
283
- const idempotencyKey = existing?.status === 'processing' || existing?.status === 'dead'
284
- ? `${request.idempotencyKey}:queued:${nextId}`
285
- : request.idempotencyKey;
286
- this.db.prepare(`
287
- INSERT INTO projection_outbox(
288
- idempotency_key,coalesce_key,operation,payload_json,priority,status,
289
- attempts,next_attempt_at,last_error,created_at,updated_at
290
- ) VALUES(?,?,?,?,?,'pending',0,NULL,NULL,?,?)
291
- ON CONFLICT(idempotency_key) DO UPDATE SET
292
- coalesce_key=excluded.coalesce_key,
293
- operation=excluded.operation,
294
- payload_json=excluded.payload_json,
295
- priority=MAX(projection_outbox.priority,excluded.priority),
296
- status='pending',
297
- next_attempt_at=NULL,
298
- last_error=NULL,
299
- updated_at=excluded.updated_at
300
- `).run(idempotencyKey, request.coalesceKey ?? null, request.operation, JSON.stringify(request.payload), request.priority ?? 0, now, now);
301
- }
302
216
  async claimDueProjections(limit = 50, now = new Date()) {
303
- const observedAt = now.toISOString();
304
- this.db.exec('BEGIN IMMEDIATE');
305
- try {
306
- const rows = this.db.prepare(`
307
- SELECT id,idempotency_key,coalesce_key,operation,payload_json,priority,
308
- attempts,next_attempt_at,last_error,created_at,updated_at
309
- FROM projection_outbox
310
- WHERE status='pending' AND (next_attempt_at IS NULL OR next_attempt_at<=?)
311
- ORDER BY priority DESC, id ASC
312
- LIMIT ?
313
- `).all(observedAt, Math.max(1, Math.floor(limit)));
314
- const mark = this.db.prepare("UPDATE projection_outbox SET status='processing', updated_at=? WHERE id=? AND status='pending'");
315
- const claimed = [];
316
- for (const row of rows) {
317
- const result = mark.run(observedAt, row.id);
318
- if (Number(result.changes) === 1)
319
- claimed.push({ ...row, updated_at: observedAt });
320
- }
321
- this.db.exec('COMMIT');
322
- return claimed.map(rowToProjection);
323
- }
324
- catch (error) {
325
- this.db.exec('ROLLBACK');
326
- throw error;
327
- }
328
- }
329
- async completeProjection(id) {
330
- this.db.prepare('DELETE FROM projection_outbox WHERE id=?').run(id);
331
- }
332
- async retryProjection(id, error, nextAttemptAt) {
333
- this.db.prepare(`
334
- UPDATE projection_outbox
335
- SET status='pending', attempts=attempts+1, next_attempt_at=?, last_error=?, updated_at=?
336
- WHERE id=?
337
- `).run(nextAttemptAt.toISOString(), error, new Date().toISOString(), id);
338
- }
339
- async deadLetterProjection(id, error) {
340
- this.db.prepare(`
341
- UPDATE projection_outbox
342
- SET status='dead', attempts=attempts+1, next_attempt_at=NULL, last_error=?, updated_at=?
343
- WHERE id=?
344
- `).run(error, new Date().toISOString(), id);
345
- }
346
- async pendingProjectionCount() {
347
- const row = this.db.prepare("SELECT COUNT(*) AS count FROM projection_outbox WHERE status IN ('pending','processing')").get();
348
- return Number(row?.count ?? 0);
349
- }
350
- async terminalProjectionCount() {
351
- const row = this.db.prepare("SELECT COUNT(*) AS count FROM projection_outbox WHERE status='dead'").get();
352
- return Number(row?.count ?? 0);
217
+ return this.outbox.claimDue(limit, now);
353
218
  }
219
+ async completeProjection(id) { this.outbox.complete(id); }
220
+ async retryProjection(id, error, nextAttemptAt) { this.outbox.retry(id, error, nextAttemptAt); }
221
+ async deadLetterProjection(id, error) { this.outbox.deadLetter(id, error); }
222
+ async recordProjectionConflict(id, diagnostic) { this.outbox.recordConflict(id, diagnostic); }
223
+ async listProjectionConflicts() { return this.outbox.listConflicts(); }
224
+ async resolveProjectionConflict(id, resolution) { return this.outbox.resolveConflict(id, resolution); }
225
+ async conflictProjectionCount() { return this.outbox.conflictCount(); }
226
+ async pendingProjectionCount() { return this.outbox.pendingCount(); }
227
+ async terminalProjectionCount() { return this.outbox.terminalCount(); }
354
228
  close() { this.db.close(); }
355
229
  }
230
+ /** Canonical on-disk Relay Store location for one managed GitHub pull request. */
231
+ export function relayStorePath(directory, repository, prNumber) {
232
+ return directory + '/' + `${repository.replace('/', '_')}-${prNumber}.db`;
233
+ }
356
234
  export async function discoverLocalRelayStoreSnapshots(directory) {
357
235
  let names;
358
236
  try {
package/dist/relayd.js CHANGED
@@ -10,10 +10,11 @@ import { RepositoryWorkerPool, aggregateManagedGitHubJobs } from './pool.js';
10
10
  import { Reconciler } from './reconciler.js';
11
11
  import { dashboardManagedJobs, GitHubStore, InMemoryStore, loadManagedGitHubPullRequestSnapshotGraphql, withGitHubRequestContext } from './store.js';
12
12
  import { ProjectedDurableStore } from './projected-store.js';
13
- import { discoverLocalRelayStoreSnapshots, SQLiteRelayStore } from './relay-store.js';
13
+ import { discoverLocalRelayStoreSnapshots, relayStorePath, SQLiteRelayStore } from './relay-store.js';
14
14
  import { githubAuthContext } from './github-auth.js';
15
15
  import { dashboardJobAction, isEntrypoint, runtimePlanner, runtimeReviewer, startService, SERVICE_DEFAULTS } from './cli.js';
16
16
  import { eventBusResolutionMessage, publishOrchestratorTaskCompletion, resolveEventBus } from './cli-support.js';
17
+ import { eventFor } from './events.js';
17
18
  import { applyJobActivateAction } from './job-actions.js';
18
19
  import { applyOrchestratorCompleteAction } from './task-actions.js';
19
20
  import { dashboardJobSetState, dashboardTaskAction, dashboardTaskSetState } from './dashboard-actions.js';
@@ -63,6 +64,13 @@ Legacy single-job mode also accepts --repo OWNER/REPO --pr NUMBER --id JOB_ID.
63
64
  Examples:
64
65
  npx agents-relayd --repo OWNER/REPO
65
66
  npx agents-relayd --workspace ~/Workspace`;
67
+ function firstStageChoices(decisionInput) {
68
+ const stages = decisionInput?.stages;
69
+ if (!Array.isArray(stages) || stages.length === 0)
70
+ return [];
71
+ const contract = stages[0].contract;
72
+ return Array.isArray(contract?.choices) ? contract.choices.filter((item) => typeof item === 'string') : [];
73
+ }
66
74
  function value(args, name, fallback = '') { const index = args.indexOf(name); return index >= 0 ? args[index + 1] ?? fallback : fallback; }
67
75
  export function normalizeDaemonArguments(argv) {
68
76
  const repository = value(argv, '--repo') || null;
@@ -140,11 +148,17 @@ export async function runDaemon(argv) {
140
148
  const key = `${repository}#${prNumber}`;
141
149
  let relay = relayStores.get(key);
142
150
  if (!relay) {
143
- relay = new SQLiteRelayStore(join(homedir(), '.agents-relay', 'stores', `${repository.replace('/', '_')}-${prNumber}.db`));
151
+ relay = new SQLiteRelayStore(relayStorePath(join(homedir(), '.agents-relay', 'stores'), repository, prNumber));
144
152
  relayStores.set(key, relay);
145
153
  }
146
154
  const github = new GitHubStore(client, repository, prNumber, trusted, seed);
147
- const projected = new ProjectedDurableStore(relay, github, seed ?? null, (relatedRepository, relatedPrNumber) => runtimeStore(relatedRepository, relatedPrNumber));
155
+ const projected = new ProjectedDurableStore(relay, github, seed ?? null, (relatedRepository, relatedPrNumber) => runtimeStore(relatedRepository, relatedPrNumber), conflict => {
156
+ process.stderr.write(`projection conflict for ${repository}#${prNumber}: ${conflict.message} (fields: ${conflict.data.divergingFields.join(', ') || 'unknown'}; repair with agents-relay projection resolve)\n`);
157
+ if (bus) {
158
+ const jobId = conflict.data.jobId || conflictJobId(repository, prNumber);
159
+ void Promise.resolve().then(() => bus.publish(eventFor(jobId, 'projection', null, 'projection.conflict', 'blocked', conflict.message, 'orchestrator', { ...conflict.data, repository, prNumber }))).catch(() => undefined);
160
+ }
161
+ });
148
162
  projectionWriters.set(key, projected.projection);
149
163
  return projected;
150
164
  };
@@ -412,6 +426,9 @@ export async function runDaemon(argv) {
412
426
  providers: usage.providers,
413
427
  credits: usage.credits,
414
428
  },
429
+ effectiveChoices: result.audit?.contract?.effectiveChoices ?? [],
430
+ routingInstruction: result.audit?.contract?.routingInstruction ?? '',
431
+ mergedChoices: firstStageChoices(result.audit?.decision_input),
415
432
  decisionInput: result.audit?.decision_input ?? null,
416
433
  gates: result.audit?.gate_results ?? [],
417
434
  };
@@ -473,3 +490,6 @@ export async function runDaemon(argv) {
473
490
  }
474
491
  if (isEntrypoint(import.meta.url))
475
492
  runDaemon(process.argv.slice(2)).catch(error => { console.error(error instanceof Error ? error.message : String(error)); process.exitCode = 1; });
493
+ function conflictJobId(repository, prNumber) {
494
+ return `${repository}#${prNumber}`;
495
+ }
@@ -65,11 +65,11 @@ export async function runRouterEvals(engine, rulesPath) {
65
65
  user_input_rules: rules.text,
66
66
  runtime: { chatgpt_running: testCase.chatgptRunning ?? 0, preferred_chatgpt_concurrency: 10 },
67
67
  worker_candidates: workerCandidates,
68
- routing_instruction: 'For each selected provider, use its default model and reasoning unless user_input_rules explicitly provides another model/reasoning option.',
68
+ routing_instruction: 'Choose one route from the merged choice list. Profile defaults and explicit [choices] have already been merged and hard-gated; do not invent additional routes.',
69
69
  }));
70
70
  const decision = await engine.decide({ facts }, {
71
71
  type: 'choice',
72
- question: 'Choose one legal route. Each provider default is available. Additional model/reasoning routes exist only when explicitly provided by user_input_rules. Use context, code-plan quota, runtime load, and user_input_rules.',
72
+ question: 'Choose one legal route from the merged choice list. Each provider default is available; extra model/reasoning routes appear only when explicitly listed in [choices]. Use context, code-plan quota, runtime load, and routing_instruction.',
73
73
  choices,
74
74
  });
75
75
  if (decision.type !== 'choice')