agents-relay 1.0.177 → 2.0.0

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.
Files changed (65) hide show
  1. package/README.md +4 -2
  2. package/dist/adapters.js +26 -2
  3. package/dist/blockers.js +60 -65
  4. package/dist/cli-support.js +33 -1
  5. package/dist/cli.js +72 -86
  6. package/dist/dashboard-actions.js +13 -35
  7. package/dist/dashboard-html.js +1 -1
  8. package/dist/dashboard.js +39 -0
  9. package/dist/execution-recovery.js +37 -0
  10. package/dist/graph-runtime.js +1 -0
  11. package/dist/job-actions.js +53 -0
  12. package/dist/manual-plan.js +15 -17
  13. package/dist/planner-runtime.js +135 -0
  14. package/dist/planner.js +76 -60
  15. package/dist/pool.js +54 -10
  16. package/dist/projected-store.js +314 -0
  17. package/dist/reconciler-review.js +185 -110
  18. package/dist/reconciler-runtime.js +67 -7
  19. package/dist/reconciler.js +134 -282
  20. package/dist/relay-store.js +380 -0
  21. package/dist/relayd.js +122 -29
  22. package/dist/runtime-status.js +2 -1
  23. package/dist/scheduled-jobs.js +40 -31
  24. package/dist/scheduler.js +19 -18
  25. package/dist/store.js +1 -1
  26. package/dist/task-actions.js +139 -0
  27. package/dist/troubleshooter.js +14 -8
  28. package/dist/types.js +3 -3
  29. package/dist/unblock.js +31 -21
  30. package/dist/workflow/authoritative-unblock-v2.js +20 -0
  31. package/dist/workflow/blocker-resolution-v2.js +33 -0
  32. package/dist/workflow/contracts.js +21 -0
  33. package/dist/workflow/definitions-v2.js +75 -0
  34. package/dist/workflow/dispatcher.js +56 -0
  35. package/dist/workflow/entity-creation.js +32 -0
  36. package/dist/workflow/graph-v2.js +391 -0
  37. package/dist/workflow/job-reconcile-v2.js +19 -0
  38. package/dist/workflow/lifecycle.js +82 -4
  39. package/dist/workflow/planner-orchestration-v2.js +58 -0
  40. package/dist/workflow/planner-trigger-v2.js +24 -0
  41. package/dist/workflow/review-v2.js +42 -0
  42. package/dist/workflow/runtime.js +15 -1
  43. package/dist/workflow/schedule-cycle-v2.js +25 -0
  44. package/dist/workflow/scheduled-run-v2.js +30 -0
  45. package/dist/workflow/task-action-policy.js +19 -0
  46. package/dist/workflow/task-reconcile.js +6 -6
  47. package/dist/workflow/task-related-action-v2.js +42 -0
  48. package/dist/workflow/troubleshooter-v2.js +29 -0
  49. package/dist/workspace.js +6 -1
  50. package/package.json +1 -1
  51. package/skills/agents-relay/agents/planner.agent.md +5 -5
  52. package/skills/agents-relay/contracts/task-job-contract.md +2 -51
  53. package/workflow/authoritative-unblock-v2.mmd +15 -0
  54. package/workflow/blocker-resolution-v2.mmd +73 -0
  55. package/workflow/job-lifecycle-v2.mmd +70 -0
  56. package/workflow/job-lifecycle.mmd +2 -2
  57. package/workflow/planner-orchestration-v2.mmd +65 -0
  58. package/workflow/planner-trigger-v2.mmd +32 -0
  59. package/workflow/review-lifecycle-v2.mmd +124 -0
  60. package/workflow/schedule-cycle-v2.mmd +19 -0
  61. package/workflow/scheduled-run-v2.mmd +55 -0
  62. package/workflow/task-lifecycle-v2.mmd +201 -0
  63. package/workflow/task-lifecycle.mmd +3 -0
  64. package/workflow/task-related-action-v2.mmd +28 -0
  65. package/workflow/troubleshooter-v2.mmd +43 -0
@@ -0,0 +1,380 @@
1
+ import { DatabaseSync } from 'node:sqlite';
2
+ import { dirname } from 'node:path';
3
+ import { mkdirSync } from 'node:fs';
4
+ import { readdir } from 'node:fs/promises';
5
+ const SCHEMA_VERSION = 4;
6
+ function newerRecord(local, remote) {
7
+ if (!local)
8
+ return structuredClone(remote);
9
+ return remote.updatedAt > local.updatedAt ? structuredClone(remote) : structuredClone(local);
10
+ }
11
+ function mergeRecords(local, remote) {
12
+ const remoteById = new Map(remote.map(item => [item.id, item]));
13
+ const merged = local.map(item => newerRecord(item, remoteById.get(item.id) ?? item));
14
+ const localIds = new Set(local.map(item => item.id));
15
+ for (const item of remote)
16
+ if (!localIds.has(item.id))
17
+ merged.push(structuredClone(item));
18
+ return merged;
19
+ }
20
+ export function mergeInboundJobSnapshot(local, remote) {
21
+ const base = remote.updatedAt > local.updatedAt ? structuredClone(remote) : structuredClone(local);
22
+ base.tasks = mergeRecords(local.tasks, remote.tasks);
23
+ const localPlans = local.plans ?? [];
24
+ const remotePlans = remote.plans ?? [];
25
+ if (localPlans.length > 0 || remotePlans.length > 0) {
26
+ base.plans = mergeRecords(localPlans, remotePlans).sort((a, b) => a.round - b.round);
27
+ }
28
+ else {
29
+ delete base.plans;
30
+ }
31
+ return base;
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
+ export class SQLiteRelayStore {
49
+ path;
50
+ db;
51
+ constructor(path) {
52
+ this.path = path;
53
+ if (path !== ':memory:')
54
+ mkdirSync(dirname(path), { recursive: true });
55
+ this.db = new DatabaseSync(path);
56
+ this.db.exec('PRAGMA journal_mode=WAL; PRAGMA synchronous=NORMAL; PRAGMA busy_timeout=5000; PRAGMA foreign_keys=ON;');
57
+ this.migrate();
58
+ this.db.prepare("UPDATE projection_outbox SET status='pending', updated_at=? WHERE status='processing'")
59
+ .run(new Date().toISOString());
60
+ }
61
+ migrate() {
62
+ this.db.exec(`
63
+ CREATE TABLE IF NOT EXISTS relay_meta (
64
+ key TEXT PRIMARY KEY,
65
+ value TEXT NOT NULL
66
+ );
67
+ CREATE TABLE IF NOT EXISTS job_snapshot (
68
+ job_id TEXT PRIMARY KEY,
69
+ json TEXT NOT NULL,
70
+ updated_at TEXT NOT NULL
71
+ );
72
+ CREATE TABLE IF NOT EXISTS pull_request_facts (
73
+ id INTEGER PRIMARY KEY CHECK(id=1),
74
+ state TEXT NOT NULL CHECK(state IN ('OPEN','MERGED','CLOSED')),
75
+ head_ref TEXT,
76
+ head_sha TEXT,
77
+ observed_at TEXT NOT NULL
78
+ );
79
+ CREATE TABLE IF NOT EXISTS agent_snapshot (
80
+ agent_id TEXT PRIMARY KEY,
81
+ json TEXT NOT NULL,
82
+ updated_at TEXT NOT NULL
83
+ );
84
+ CREATE TABLE IF NOT EXISTS event_receipt (
85
+ event_id TEXT PRIMARY KEY,
86
+ stream TEXT,
87
+ sequence INTEGER,
88
+ received_at TEXT NOT NULL
89
+ );
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);
108
+ `);
109
+ const current = this.db.prepare("SELECT value FROM relay_meta WHERE key='schema_version'").get();
110
+ const version = current?.value ? Number(current.value) : 0;
111
+ if (version > SCHEMA_VERSION)
112
+ 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
+ }
144
+ this.db.prepare("INSERT INTO relay_meta(key,value) VALUES('schema_version',?) ON CONFLICT(key) DO UPDATE SET value=excluded.value")
145
+ .run(String(SCHEMA_VERSION));
146
+ }
147
+ async loadJob(jobId) {
148
+ const row = this.db.prepare('SELECT json FROM job_snapshot WHERE job_id=?').get(jobId);
149
+ return row?.json ? JSON.parse(row.json) : null;
150
+ }
151
+ async listJobs() {
152
+ const rows = this.db.prepare('SELECT json FROM job_snapshot ORDER BY job_id').all();
153
+ return rows.map(row => JSON.parse(row.json));
154
+ }
155
+ async saveJob(job) {
156
+ this.writeJobSnapshot(job, new Date().toISOString());
157
+ }
158
+ async ingestJobSnapshot(job) {
159
+ const now = new Date().toISOString();
160
+ this.db.exec('BEGIN IMMEDIATE');
161
+ try {
162
+ const row = this.db.prepare('SELECT json FROM job_snapshot WHERE job_id=?').get(job.id);
163
+ const local = row?.json ? JSON.parse(row.json) : null;
164
+ const merged = local ? mergeInboundJobSnapshot(local, job) : structuredClone(job);
165
+ this.writeJobSnapshot(merged, now);
166
+ this.db.exec('COMMIT');
167
+ return structuredClone(merged);
168
+ }
169
+ catch (error) {
170
+ this.db.exec('ROLLBACK');
171
+ throw error;
172
+ }
173
+ }
174
+ async saveJobWithProjection(job, request) {
175
+ const now = new Date().toISOString();
176
+ this.db.exec('BEGIN IMMEDIATE');
177
+ try {
178
+ this.writeJobSnapshot(job, now);
179
+ this.enqueueProjectionInTransaction(request, now);
180
+ this.db.exec('COMMIT');
181
+ }
182
+ catch (error) {
183
+ this.db.exec('ROLLBACK');
184
+ throw error;
185
+ }
186
+ }
187
+ writeJobSnapshot(job, now) {
188
+ this.db.prepare(`
189
+ INSERT INTO job_snapshot(job_id,json,updated_at) VALUES(?,?,?)
190
+ ON CONFLICT(job_id) DO UPDATE SET json=excluded.json, updated_at=excluded.updated_at
191
+ `).run(job.id, JSON.stringify(job), now);
192
+ }
193
+ async loadAgents() {
194
+ const observed = this.db.prepare("SELECT value FROM relay_meta WHERE key='agent_registry_observed_at'").get();
195
+ if (!observed?.value)
196
+ return null;
197
+ const rows = this.db.prepare('SELECT json FROM agent_snapshot ORDER BY agent_id').all();
198
+ return rows.map(row => JSON.parse(row.json));
199
+ }
200
+ async saveAgents(agents) {
201
+ const now = new Date().toISOString();
202
+ this.db.exec('BEGIN IMMEDIATE');
203
+ try {
204
+ this.db.prepare('DELETE FROM agent_snapshot').run();
205
+ const insert = this.db.prepare('INSERT INTO agent_snapshot(agent_id,json,updated_at) VALUES(?,?,?)');
206
+ for (const agent of agents)
207
+ insert.run(agent.id, JSON.stringify(agent), now);
208
+ this.markAgentRegistryObserved(now);
209
+ this.db.exec('COMMIT');
210
+ }
211
+ catch (error) {
212
+ this.db.exec('ROLLBACK');
213
+ throw error;
214
+ }
215
+ }
216
+ async saveAgentWithProjection(agent, request) {
217
+ const now = new Date().toISOString();
218
+ this.db.exec('BEGIN IMMEDIATE');
219
+ try {
220
+ this.writeAgentSnapshot(agent, now);
221
+ this.markAgentRegistryObserved(now);
222
+ this.enqueueProjectionInTransaction(request, now);
223
+ this.db.exec('COMMIT');
224
+ }
225
+ catch (error) {
226
+ this.db.exec('ROLLBACK');
227
+ throw error;
228
+ }
229
+ }
230
+ writeAgentSnapshot(agent, now) {
231
+ this.db.prepare(`
232
+ INSERT INTO agent_snapshot(agent_id,json,updated_at) VALUES(?,?,?)
233
+ ON CONFLICT(agent_id) DO UPDATE SET json=excluded.json, updated_at=excluded.updated_at
234
+ `).run(agent.id, JSON.stringify(agent), now);
235
+ }
236
+ markAgentRegistryObserved(now) {
237
+ this.db.prepare(`
238
+ INSERT INTO relay_meta(key,value) VALUES('agent_registry_observed_at',?)
239
+ ON CONFLICT(key) DO UPDATE SET value=excluded.value
240
+ `).run(now);
241
+ }
242
+ async loadPullRequestFacts() {
243
+ const row = this.db.prepare('SELECT state,head_ref,head_sha,observed_at FROM pull_request_facts WHERE id=1').get();
244
+ if (!row?.state || !row.observed_at)
245
+ return null;
246
+ return { state: row.state, head: row.head_ref && row.head_sha ? { ref: row.head_ref, sha: row.head_sha } : null, observedAt: row.observed_at };
247
+ }
248
+ async savePullRequestFacts(facts) {
249
+ this.db.prepare(`
250
+ INSERT INTO pull_request_facts(id,state,head_ref,head_sha,observed_at) VALUES(1,?,?,?,?)
251
+ ON CONFLICT(id) DO UPDATE SET state=excluded.state,head_ref=excluded.head_ref,head_sha=excluded.head_sha,observed_at=excluded.observed_at
252
+ `).run(facts.state, facts.head?.ref ?? null, facts.head?.sha ?? null, facts.observedAt);
253
+ }
254
+ async markEventProcessed(eventId, stream = null, sequence = null) {
255
+ const result = this.db.prepare(`
256
+ INSERT OR IGNORE INTO event_receipt(event_id,stream,sequence,received_at) VALUES(?,?,?,?)
257
+ `).run(eventId, stream, sequence, new Date().toISOString());
258
+ return Number(result.changes) === 1;
259
+ }
260
+ async enqueueProjection(request) {
261
+ const now = new Date().toISOString();
262
+ this.db.exec('BEGIN IMMEDIATE');
263
+ try {
264
+ this.enqueueProjectionInTransaction(request, now);
265
+ this.db.exec('COMMIT');
266
+ }
267
+ catch (error) {
268
+ this.db.exec('ROLLBACK');
269
+ throw error;
270
+ }
271
+ }
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
+ 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);
353
+ }
354
+ close() { this.db.close(); }
355
+ }
356
+ export async function discoverLocalRelayStoreSnapshots(directory) {
357
+ let names;
358
+ try {
359
+ names = await readdir(directory);
360
+ }
361
+ catch {
362
+ return [];
363
+ }
364
+ const snapshots = [];
365
+ for (const name of names.filter(item => item.endsWith('.db')).sort()) {
366
+ const path = directory + '/' + name;
367
+ let store;
368
+ try {
369
+ store = new SQLiteRelayStore(path);
370
+ snapshots.push({ path, jobs: await store.listJobs(), facts: await store.loadPullRequestFacts() });
371
+ }
372
+ catch {
373
+ // Ignore unrelated/corrupt files so one bad file cannot block daemon startup.
374
+ }
375
+ finally {
376
+ store?.close();
377
+ }
378
+ }
379
+ return snapshots;
380
+ }
package/dist/relayd.js CHANGED
@@ -10,8 +10,12 @@ import { NatsEventBus } from './events.js';
10
10
  import { RepositoryWorkerPool, aggregateManagedGitHubJobs } from './pool.js';
11
11
  import { Reconciler } from './reconciler.js';
12
12
  import { dashboardManagedJobs, GitHubStore, InMemoryStore, loadManagedGitHubPullRequestSnapshotGraphql, withGitHubRequestContext } from './store.js';
13
+ import { ProjectedDurableStore } from './projected-store.js';
14
+ import { discoverLocalRelayStoreSnapshots, SQLiteRelayStore } from './relay-store.js';
13
15
  import { githubAuthContext } from './github-auth.js';
14
16
  import { dashboardJobAction, isEntrypoint, runtimePlanner, runtimeReviewer, startService, SERVICE_DEFAULTS } from './cli.js';
17
+ import { applyJobActivateAction } from './job-actions.js';
18
+ import { applyOrchestratorCompleteAction } from './task-actions.js';
15
19
  import { dashboardJobSetState, dashboardTaskAction, dashboardTaskSetState } from './dashboard-actions.js';
16
20
  import { codexAndZaiUsageRegistry } from './usage.js';
17
21
  import { defaultWorkspaceRoot, discoverWorkspaceRepositories, resolveManagedProjectPath, resolveRepositoryProjectPath } from './workspace.js';
@@ -19,8 +23,14 @@ import { deriveJobRuntimeStatus } from './runtime-status.js';
19
23
  import { decisionMode, LayaCliDecisionEngine } from './decision.js';
20
24
  import { JsonlDecisionAuditStore } from './decision-audit.js';
21
25
  import { summarizeRuntime } from './runtime-summary.js';
22
- import { runScheduledBucket } from './scheduled-jobs.js';
23
- import { scheduleBucket } from './scheduler.js';
26
+ export async function overlayOperationalOverviews(overviews, storeFor) {
27
+ return await Promise.all(overviews.map(async (item) => {
28
+ const store = storeFor(item.job.repository, item.job.prNumber);
29
+ await store.hydrate?.(item.job);
30
+ const job = await store.load(item.job.id);
31
+ return { ...item, job };
32
+ }));
33
+ }
24
34
  const PR_REFRESH_COALESCE_MS = 1000;
25
35
  export const SCHEDULE_HEARTBEAT_MS = 600_000;
26
36
  export function dueScheduledOverviews(overviews, now = new Date()) {
@@ -40,6 +50,7 @@ Options:
40
50
  --port PORT Dashboard port (default: 8787)
41
51
  --concurrency N Maximum concurrent workers (default: 4)
42
52
  --interval MS Recovery interval
53
+ --projection-ms MS GitHub projection flush interval (default: 1000)
43
54
  --events nats Enable event wakeups
44
55
 
45
56
  Legacy single-job mode also accepts --repo OWNER/REPO --pr NUMBER --id JOB_ID.
@@ -109,6 +120,20 @@ export async function runDaemon(argv) {
109
120
  let cachedOverviews = [];
110
121
  const githubReadPaths = { workspaceScans: 0, prRefreshes: 0, prRefreshesByTrigger: {}, prRefreshCoalesced: 0, prRefreshCoalescedByTrigger: {} };
111
122
  let dormantProbeIndex = 0;
123
+ const relayStores = new Map();
124
+ const projectionWriters = new Map();
125
+ const runtimeStore = (repository, prNumber, seed) => {
126
+ const key = `${repository}#${prNumber}`;
127
+ let relay = relayStores.get(key);
128
+ if (!relay) {
129
+ relay = new SQLiteRelayStore(join(homedir(), '.agents-relay', 'stores', `${repository.replace('/', '_')}-${prNumber}.db`));
130
+ relayStores.set(key, relay);
131
+ }
132
+ const github = new GitHubStore(client, repository, prNumber, trusted, seed);
133
+ const projected = new ProjectedDurableStore(relay, github, seed ?? null, (relatedRepository, relatedPrNumber) => runtimeStore(relatedRepository, relatedPrNumber));
134
+ projectionWriters.set(key, projected.projection);
135
+ return projected;
136
+ };
112
137
  const makeReconciler = (store, maxConcurrent) => new Reconciler(store, { owner: `relayd-${process.pid}`, maxConcurrent, leaseMs: Number(value(argv, '--lease-ms', '300000')), adapters: [new CodexAdapter(value(argv, '--codex', 'codex')), new ChatGptAdapter()], continuations: [new CodexThreadContinuation(value(argv, '--codex', 'codex')), new CommandContinuation(), new WebhookContinuation()], planner: runtimePlanner(argv), usageSnapshotProvider: () => usageRegistry.snapshot(), runtimeSummaryProvider: () => ({ chatgptRunning: summarizeRuntime(cachedOverviews.map(item => item.job)).workers.chatgpt }), decisionEngine: new LayaCliDecisionEngine(), decisionMode: decisionMode(), decisionAuditStore, eventBus: bus, reviewer: runtimeReviewer(), projectPathForJob: async (job) => resolveRepositoryProjectPath(repositoryPaths.get(job.repository) ?? await resolveManagedProjectPath(job.repository, explicitProjectPath, workspaceRoot), job.projectPath) });
113
138
  if (repositories.length === 0)
114
139
  throw new Error(`No Git repositories with GitHub origins found under ${normalized.workspaceRoot ?? defaultWorkspaceRoot()}`);
@@ -120,7 +145,7 @@ export async function runDaemon(argv) {
120
145
  cachedOverviews = parsed;
121
146
  }
122
147
  catch { /* first run or invalid cache */ }
123
- const refreshAll = async (full = false, failOnError = false) => {
148
+ const refreshAll = async (full = false) => {
124
149
  if (client.rateLimitStatus().limited)
125
150
  return cachedOverviews;
126
151
  const scanRepositories = full ? repositories : recoveryScanRepositories(repositories, cachedOverviews, dormantProbeIndex++);
@@ -130,7 +155,8 @@ export async function runDaemon(argv) {
130
155
  githubReadPaths.workspaceScans += 1;
131
156
  const refreshed = await withGitHubRequestContext(client, 'relayd.workspace-refresh', () => aggregateManagedGitHubJobs(client, scanRepositories, trusted));
132
157
  const scanned = new Set(scanRepositories);
133
- cachedOverviews = dashboardManagedJobs([...cachedOverviews.filter(item => !scanned.has(item.job.repository)), ...refreshed]);
158
+ const merged = dashboardManagedJobs([...cachedOverviews.filter(item => !scanned.has(item.job.repository)), ...refreshed]);
159
+ cachedOverviews = await overlayOperationalOverviews(merged, (repository, prNumber) => runtimeStore(repository, prNumber));
134
160
  try {
135
161
  await mkdir(cacheDirectory, { recursive: true });
136
162
  await writeFile(cacheFile, JSON.stringify(cachedOverviews), 'utf8');
@@ -139,13 +165,16 @@ export async function runDaemon(argv) {
139
165
  return cachedOverviews;
140
166
  }
141
167
  catch (error) {
142
- if (failOnError)
143
- throw error;
144
168
  process.stderr.write(`warning: workspace job refresh unavailable: ${error instanceof Error ? error.message : String(error)}\n`);
145
169
  return cachedOverviews;
146
170
  }
147
171
  };
148
- const overviews = async (forceRefresh = false, _trigger = 'internal.unknown') => forceRefresh ? refreshAll(true) : cachedOverviews;
172
+ const overviews = async (forceRefresh = false, _trigger = 'internal.unknown') => {
173
+ if (forceRefresh)
174
+ return refreshAll(true);
175
+ cachedOverviews = await overlayOperationalOverviews(cachedOverviews, (repository, prNumber) => runtimeStore(repository, prNumber));
176
+ return cachedOverviews;
177
+ };
149
178
  const persistCache = async () => {
150
179
  try {
151
180
  await mkdir(cacheDirectory, { recursive: true });
@@ -176,12 +205,14 @@ export async function runDaemon(argv) {
176
205
  const flight = (async () => {
177
206
  githubReadPaths.prRefreshes += 1;
178
207
  const snapshot = await withGitHubRequestContext(client, `relayd.pr-refresh:${trigger}`, () => loadManagedGitHubPullRequestSnapshotGraphql(client, repository, prNumber, trusted));
208
+ const operational = await overlayOperationalOverviews(snapshot.overviews, (repo, pr) => runtimeStore(repo, pr, repo === repository && pr === prNumber ? snapshot.seed : null));
179
209
  cachedOverviews = cachedOverviews.filter(item => item.job.repository !== repository || item.job.prNumber !== prNumber);
180
- cachedOverviews.push(...snapshot.overviews);
210
+ cachedOverviews.push(...operational);
181
211
  cachedOverviews = dashboardManagedJobs(cachedOverviews);
182
212
  await persistCache();
183
- pullRequestRefreshCache.set(key, { snapshot, completedAt: Date.now() });
184
- return snapshot;
213
+ const operationalSnapshot = { ...snapshot, overviews: operational };
214
+ pullRequestRefreshCache.set(key, { snapshot: operationalSnapshot, completedAt: Date.now() });
215
+ return operationalSnapshot;
185
216
  })();
186
217
  pullRequestRefreshFlights.set(key, flight);
187
218
  try {
@@ -193,15 +224,15 @@ export async function runDaemon(argv) {
193
224
  }
194
225
  };
195
226
  const refreshPullRequest = async (repository, prNumber, trigger, options) => (await refreshPullRequestSnapshot(repository, prNumber, trigger, options)).overviews;
196
- const pool = new RepositoryWorkerPool(client, repositories, trusted, concurrency, makeReconciler, () => client.rateLimitStatus().limited, (repository, prNumber, trigger) => refreshPullRequestSnapshot(repository, prNumber, trigger));
227
+ const pool = new RepositoryWorkerPool(client, repositories, trusted, concurrency, makeReconciler, () => client.rateLimitStatus().limited, (repository, prNumber, trigger) => refreshPullRequestSnapshot(repository, prNumber, trigger), (repository, prNumber, seed) => runtimeStore(repository, prNumber, seed));
197
228
  const initial = cachedOverviews;
198
229
  const fallback = initial[0]?.job;
199
- const dashboardStore = fallback ? new GitHubStore(client, fallback.repository, fallback.prNumber, trusted) : new InMemoryStore();
230
+ const dashboardStore = fallback ? runtimeStore(fallback.repository, fallback.prNumber) : new InMemoryStore();
200
231
  const workspaceJobAction = (action) => async (jobId) => {
201
232
  const overview = cachedOverviews.find(item => item.job.id === jobId);
202
233
  if (!overview)
203
234
  throw new Error(`Managed job ${jobId} not found`);
204
- const store = new GitHubStore(client, overview.job.repository, overview.job.prNumber, trusted);
235
+ const store = runtimeStore(overview.job.repository, overview.job.prNumber);
205
236
  if (action === 'reconcile') {
206
237
  await pool.reconcileTarget(overview.job.repository, overview.job.prNumber, jobId);
207
238
  return await store.load(jobId);
@@ -212,23 +243,56 @@ export async function runDaemon(argv) {
212
243
  const overview = cachedOverviews.find(item => item.job.id === jobId);
213
244
  if (!overview)
214
245
  throw new Error(`Managed job ${jobId} not found`);
215
- const store = new GitHubStore(client, overview.job.repository, overview.job.prNumber, trusted);
246
+ const store = runtimeStore(overview.job.repository, overview.job.prNumber);
216
247
  const result = await dashboardJobSetState(store)(jobId, state);
217
248
  await refreshPullRequest(overview.job.repository, overview.job.prNumber, 'dashboard.job-state', { force: true });
218
249
  return result;
219
250
  };
251
+ const workspaceTaskCreate = async (jobId, task) => {
252
+ const overview = cachedOverviews.find(item => item.job.id === jobId);
253
+ if (!overview)
254
+ throw new Error(`Managed job ${jobId} not found`);
255
+ if (task.jobId !== jobId)
256
+ throw new Error(`Task ${task.id} belongs to ${task.jobId}, not ${jobId}`);
257
+ const store = runtimeStore(overview.job.repository, overview.job.prNumber);
258
+ const job = await store.load(jobId);
259
+ if (['COMPLETED', 'CANCELLED'].includes(job.state))
260
+ throw new Error(`Cannot create task on terminal job ${job.id} (${job.state})`);
261
+ await store.appendTask(task);
262
+ const refreshed = await store.load(jobId);
263
+ if (refreshed.state === 'DRAFT') {
264
+ await applyJobActivateAction(refreshed);
265
+ await store.saveJob(refreshed);
266
+ }
267
+ await pool.reconcileTarget(overview.job.repository, overview.job.prNumber, jobId, 'control.task.create');
268
+ return await store.load(jobId);
269
+ };
270
+ const workspaceTaskComplete = async (jobId, taskId, summary) => {
271
+ const overview = cachedOverviews.find(item => item.job.id === jobId);
272
+ if (!overview)
273
+ throw new Error(`Managed job ${jobId} not found`);
274
+ const store = runtimeStore(overview.job.repository, overview.job.prNumber);
275
+ const job = await store.load(jobId);
276
+ const task = job.tasks.find(item => item.id === taskId);
277
+ if (!task)
278
+ throw new Error(`Task ${taskId} not found`);
279
+ await applyOrchestratorCompleteAction(task, summary);
280
+ await store.saveTask(task);
281
+ await pool.reconcileTarget(overview.job.repository, overview.job.prNumber, jobId, 'control.task.complete');
282
+ return await store.load(jobId);
283
+ };
220
284
  const workspaceTaskAction = (action) => async (jobId, taskId) => {
221
285
  const overview = cachedOverviews.find(item => item.job.id === jobId);
222
286
  if (!overview)
223
287
  throw new Error(`Managed job ${jobId} not found`);
224
- const store = new GitHubStore(client, overview.job.repository, overview.job.prNumber, trusted);
288
+ const store = runtimeStore(overview.job.repository, overview.job.prNumber);
225
289
  return await dashboardTaskAction(store, makeReconciler(store, concurrency), action)(jobId, taskId);
226
290
  };
227
291
  const workspaceTaskSetState = async (jobId, taskId, state) => {
228
292
  const overview = cachedOverviews.find(item => item.job.id === jobId);
229
293
  if (!overview)
230
294
  throw new Error(`Managed job ${jobId} not found`);
231
- const store = new GitHubStore(client, overview.job.repository, overview.job.prNumber, trusted);
295
+ const store = runtimeStore(overview.job.repository, overview.job.prNumber);
232
296
  const result = await dashboardTaskSetState(store)(jobId, taskId, state);
233
297
  await refreshPullRequest(overview.job.repository, overview.job.prNumber, 'dashboard.task-state', { force: true });
234
298
  return result;
@@ -273,13 +337,14 @@ export async function runDaemon(argv) {
273
337
  reconcile: workspaceJobAction('reconcile'),
274
338
  cancel: workspaceJobAction('cancel'),
275
339
  jobSetState: workspaceJobSetState,
340
+ taskCreate: workspaceTaskCreate,
276
341
  taskRetry: workspaceTaskAction('retry'),
277
342
  taskCancel: workspaceTaskAction('cancel'),
343
+ taskComplete: workspaceTaskComplete,
278
344
  taskSetState: workspaceTaskSetState,
279
345
  });
346
+ await once(server, 'listening');
280
347
  const reconcileSafely = async () => {
281
- if (client.rateLimitStatus().limited)
282
- return;
283
348
  try {
284
349
  await pool.reconcile();
285
350
  }
@@ -288,37 +353,65 @@ export async function runDaemon(argv) {
288
353
  }
289
354
  };
290
355
  const scheduleHeartbeatSafely = async () => {
291
- if (client.rateLimitStatus().limited)
292
- return;
293
356
  const now = new Date();
294
357
  for (const item of dueScheduledOverviews(cachedOverviews, now)) {
295
- const repository = item.job.repository;
296
358
  try {
297
- await runScheduledBucket(client, repository, trusted, { prNumber: item.job.prNumber, jobId: item.job.id, bucket: scheduleBucket(new Date(item.job.schedule.nextRunAt)) }, { reconcile: async () => { await pool.reconcileTarget(repository, item.job.prNumber, item.job.id); }, idle: async () => { } }, now);
359
+ await pool.scheduleTickSignal(bus, item.job, now);
360
+ }
361
+ catch (error) {
362
+ process.stderr.write(`warning: scheduled heartbeat unavailable for ${item.job.repository}#${item.job.prNumber}: ${error instanceof Error ? error.message : String(error)}\n`);
363
+ }
364
+ }
365
+ };
366
+ const flushProjectionsSafely = async () => {
367
+ for (const writer of projectionWriters.values()) {
368
+ try {
369
+ await writer.flushOnce();
298
370
  }
299
371
  catch (error) {
300
- process.stderr.write(`warning: scheduled heartbeat unavailable for ${repository}#${item.job.prNumber}: ${error instanceof Error ? error.message : String(error)}\n`);
372
+ process.stderr.write(`warning: GitHub projection flush unavailable: ${error instanceof Error ? error.message : String(error)}\n`);
301
373
  }
302
374
  }
303
375
  };
304
376
  const watchdogDefault = webhook ? SERVICE_DEFAULTS.webhookWatchdogMs : SERVICE_DEFAULTS.watchdogMs;
305
377
  let interval;
306
378
  let scheduleInterval;
379
+ let projectionInterval;
307
380
  const stop = () => { if (interval)
308
381
  clearInterval(interval); if (scheduleInterval)
309
- clearInterval(scheduleInterval); void stopWatch().finally(() => server.close(() => process.exit(0))); };
382
+ clearInterval(scheduleInterval); if (projectionInterval)
383
+ clearInterval(projectionInterval); void stopWatch().finally(() => { for (const store of relayStores.values())
384
+ store.close(); server.close(() => process.exit(0)); }); };
310
385
  process.once('SIGINT', stop);
311
386
  process.once('SIGTERM', stop);
312
- await once(server, 'listening');
313
- const startupItems = await refreshAll(true, true); // void refreshAll(true)
314
- await pool.reconcileKnown(startupItems);
315
- // Startup recovery must finish before normal polling or scheduled work can run.
387
+ const localSnapshots = await discoverLocalRelayStoreSnapshots(join(homedir(), '.agents-relay', 'stores'));
388
+ const knownRepositories = new Set(repositories);
389
+ const localOverviews = localSnapshots.flatMap(snapshot => {
390
+ if (!snapshot.facts)
391
+ return [];
392
+ return snapshot.jobs.filter(job => knownRepositories.has(job.repository)).map(job => ({
393
+ job, githubState: snapshot.facts.state, draft: false, mergedAt: null, closedAt: null, publication: null,
394
+ }));
395
+ });
396
+ const byJob = new Map();
397
+ for (const item of cachedOverviews)
398
+ byJob.set(item.job.repository + '#' + item.job.prNumber + ':' + item.job.id, item);
399
+ for (const item of localOverviews)
400
+ byJob.set(item.job.repository + '#' + item.job.prNumber + ':' + item.job.id, item);
401
+ cachedOverviews = dashboardManagedJobs([...byJob.values()]);
402
+ await pool.reconcileKnown(cachedOverviews);
316
403
  await scheduleHeartbeatSafely();
317
- interval = setInterval(() => void reconcileSafely(), Number(value(argv, '--interval', String(watchdogDefault))));
404
+ await flushProjectionsSafely();
405
+ interval = setInterval(() => void reconcileSafely().finally(flushProjectionsSafely), Number(value(argv, '--interval', String(watchdogDefault))));
318
406
  interval.unref();
407
+ projectionInterval = setInterval(() => void flushProjectionsSafely(), Number(value(argv, '--projection-ms', String(SERVICE_DEFAULTS.projectionFlushMs))));
408
+ projectionInterval.unref();
319
409
  scheduleInterval = setInterval(() => void scheduleHeartbeatSafely(), SCHEDULE_HEARTBEAT_MS);
320
410
  scheduleInterval.unref();
321
411
  console.log('Dashboard listening; repository worker pool active');
412
+ void refreshAll(true)
413
+ .then(items => pool.reconcileKnown(items))
414
+ .catch(error => process.stderr.write('warning: startup remote refresh unavailable: ' + (error instanceof Error ? error.message : String(error)) + '\n'));
322
415
  }
323
416
  if (isEntrypoint(import.meta.url))
324
417
  runDaemon(process.argv.slice(2)).catch(error => { console.error(error instanceof Error ? error.message : String(error)); process.exitCode = 1; });