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.
- package/README.md +4 -2
- package/dist/cli-help.js +15 -1
- package/dist/cli-projection.js +61 -0
- package/dist/cli.js +6 -0
- package/dist/dashboard-html.js +2 -2
- package/dist/decision-audit.js +1 -1
- package/dist/execution-routing.js +117 -88
- package/dist/projected-store.js +67 -8
- package/dist/projection-outbox.js +268 -0
- package/dist/relay-store.js +26 -148
- package/dist/relayd.js +23 -3
- package/dist/router-evals.js +2 -2
- package/dist/router-rules.js +101 -0
- package/dist/routing-policy.js +4 -3
- package/dist/store-github.js +6 -5
- package/dist/store-task-definition.js +63 -0
- package/dist/store.js +4 -4
- package/dist/workflow/projection-conflict-v2.js +26 -0
- package/package.json +1 -1
- package/workflow/projection-conflict.mmd +11 -0
|
@@ -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
|
+
}
|
package/dist/relay-store.js
CHANGED
|
@@ -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
|
-
|
|
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.
|
|
59
|
-
|
|
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
|
-
|
|
91
|
-
|
|
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 <
|
|
114
|
-
|
|
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.
|
|
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
|
-
|
|
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',
|
|
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
|
+
}
|
package/dist/router-evals.js
CHANGED
|
@@ -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: '
|
|
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
|
|
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')
|