agents-relay 2.0.32 → 2.0.34

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 CHANGED
@@ -189,6 +189,8 @@ events, durable-thread reuse, resume, or retry behavior.
189
189
 
190
190
  Open the dashboard on localhost port 8787. The marker format is intentionally public and append-friendly. Daemon/workspace operation uses `ProjectedDurableStore` over SQLite RelayStore plus the authenticated GitHub projection adapter; GitHubStore is not the daemon operational state database.
191
191
 
192
+ Deterministic projection conflicts are safe and observable. A same-id/different-definition `append_task`/`append_plan` is classified once into a durable `conflict` state, stops consuming retry budget, keeps structured divergence evidence, and is never auto-overwritten. Inspect with `agents-relay projection conflicts --repo OWNER/REPO --pr NUMBER` and repair explicitly with `agents-relay projection resolve --repo OWNER/REPO --pr NUMBER --projection-id ID --action requeue|discard-local`. See [docs/architecture-v2.md](docs/architecture-v2.md).
193
+
192
194
  External product feedback uses the reusable `src/feedback.ts` contract. See [docs/feedback-intake.md](docs/feedback-intake.md) for the schema, attachment limits, privacy rules, materialization path, and provenance envelope for later Jobs/Tasks/PRs.
193
195
 
194
196
  reconcile leases and launches ready tasks without waiting inside the runtime scheduler; the CLI waits only long enough for its launched workers to persist results. agents-relayd (or the equivalent npx agents-relay serve) is the watchdog/dashboard mode. Model-backed workers require --events nats; the event transport carries authoritative terminal worker events as well as wake/progress traffic. See [docs/service.md](docs/service.md). --file PATH is explicit local demo/test mode only.
package/dist/adapters.js CHANGED
@@ -120,6 +120,8 @@ export class CodexAdapter {
120
120
  return execution;
121
121
  }
122
122
  }
123
+ export const CHATGPT_BROWSER_WORKSPACE = 'agents-replay';
124
+ export const CHATGPT_BROWSER_WORKSPACE_POOL_SIZE = 6;
123
125
  export function chatGptTabClosePolicy(value, fallback = 'after-start') {
124
126
  const policy = value || fallback;
125
127
  if (!['after-start', 'never', 'after-terminal'].includes(policy))
@@ -163,62 +165,129 @@ function parseDriverJson(output) {
163
165
  }
164
166
  throw new Error('browser-worker driver returned no submitted receipt');
165
167
  }
166
- function directBrowserWorkerRunner(root) {
167
- return (_definition, request, _route, signal) => {
168
- const releaseFile = join(process.env.TMPDIR ?? '/tmp', `agents-relay-chatgpt-release-${randomUUID()}`);
169
- const args = [join(root, 'scripts', 'temporary_bh.py'), '--prompt', request.prompt, '--release-file', releaseFile, '--close-policy', request.tabClosePolicy ?? 'after-start'];
170
- for (const file of request.files ?? [])
171
- args.push('--file', file);
172
- const child = spawn('python3', args, { shell: false, stdio: ['ignore', 'pipe', 'pipe'] });
168
+ function browserWorkspaceCommand(command, args, stdin) {
169
+ return new Promise((resolve, reject) => {
170
+ const child = spawn(command, [...args], { shell: false, stdio: [stdin === undefined ? 'ignore' : 'pipe', 'pipe', 'pipe'] });
173
171
  let stdout = '';
174
172
  let stderr = '';
175
- let submitted = false;
173
+ child.stdout?.on('data', (chunk) => { stdout += chunk.toString(); });
174
+ child.stderr?.on('data', (chunk) => { stderr = appendTail(stderr, chunk.toString()); });
175
+ child.on('error', reject);
176
+ child.on('close', code => {
177
+ if (code === 0)
178
+ resolve(stdout.trim());
179
+ else
180
+ reject(new Error(stderr.trim() || stdout.trim() || `browser-workspace exited ${code}`));
181
+ });
182
+ if (stdin !== undefined) {
183
+ child.stdin?.end(stdin);
184
+ }
185
+ });
186
+ }
187
+ function parseWorkspaceJson(output) {
188
+ const lines = output.trim().split(/\r?\n/).filter(Boolean);
189
+ for (let index = lines.length - 1; index >= 0; index -= 1) {
190
+ try {
191
+ return JSON.parse(lines[index]);
192
+ }
193
+ catch { /* diagnostics may precede JSON */ }
194
+ }
195
+ throw new Error('browser-workspace returned no JSON result');
196
+ }
197
+ export async function ensureChatGptBrowserWorkspace(command = process.env.AGENTS_RELAY_BROWSER_WORKSPACE_COMMAND ?? 'browser-workspace') {
198
+ await browserWorkspaceCommand(command, ['create', CHATGPT_BROWSER_WORKSPACE, String(CHATGPT_BROWSER_WORKSPACE_POOL_SIZE)]);
199
+ }
200
+ function sessionBrowserWorkerRunner(root) {
201
+ const command = process.env.AGENTS_RELAY_BROWSER_WORKSPACE_COMMAND ?? 'browser-workspace';
202
+ return (_definition, request, _route, signal) => {
203
+ const policy = request.tabClosePolicy ?? 'after-start';
204
+ let activeSessionId;
205
+ let released = false;
206
+ let releaseResolve;
207
+ const releaseBarrier = new Promise(resolve => { releaseResolve = resolve; });
208
+ const release = () => {
209
+ if (released)
210
+ return;
211
+ released = true;
212
+ releaseResolve();
213
+ };
176
214
  let submittedResolve;
177
215
  let submittedReject;
178
- const submittedPromise = new Promise((resolve, reject) => { submittedResolve = resolve; submittedReject = reject; });
179
- child.stdout?.on('data', (chunk) => {
180
- stdout += chunk.toString();
181
- if (!submitted) {
182
- try {
183
- const response = parseDriverJson(stdout);
184
- submitted = true;
185
- submittedResolve?.(JSON.stringify(response));
186
- }
187
- catch { /* receipt may be split across chunks */ }
188
- }
216
+ const submitted = new Promise((resolve, reject) => {
217
+ submittedResolve = resolve;
218
+ submittedReject = reject;
189
219
  });
190
- child.stderr?.on('data', (chunk) => {
191
- stderr = appendTail(stderr, chunk.toString());
192
- });
193
- const promise = new Promise((resolve, reject) => {
194
- child.on('error', reject);
195
- child.on('close', code => {
196
- if (code !== 0) {
197
- const error = new Error(stderr.trim() || stdout.trim() || `browser-worker driver exited ${code}`);
198
- submittedReject?.(error);
199
- reject(error);
200
- return;
220
+ const promise = (async () => {
221
+ const configFile = join(process.env.TMPDIR ?? '/tmp', `agents-relay-chatgpt-${randomUUID()}.json`);
222
+ try {
223
+ const [readiness, submitSource, temporarySource] = await Promise.all([
224
+ readFile(join(root, 'scripts', '_submit_readiness.py'), 'utf8'),
225
+ readFile(join(root, 'scripts', '_submit_bh.py'), 'utf8'),
226
+ readFile(join(root, 'scripts', '_temporary_bh.py'), 'utf8'),
227
+ ]);
228
+ await writeFile(configFile, JSON.stringify({
229
+ prompt: request.prompt,
230
+ file: request.files ?? [],
231
+ close_policy: policy,
232
+ release_file: '',
233
+ }));
234
+ const startOutput = await browserWorkspaceCommand(command, ['session', 'start', '--workspace', CHATGPT_BROWSER_WORKSPACE]);
235
+ const started = parseWorkspaceJson(startOutput);
236
+ if (typeof started.session_id !== 'string') {
237
+ throw new Error('browser-workspace session start returned no session_id');
201
238
  }
202
- try {
203
- const result = JSON.stringify(parseDriverJson(stdout));
204
- if (!submitted) {
205
- submitted = true;
206
- submittedResolve?.(result);
207
- }
208
- resolve(result);
239
+ activeSessionId = started.session_id;
240
+ if (signal.aborted)
241
+ throw new Error('browser-worker execution aborted');
242
+ const escapedConfig = configFile.replaceAll('\\', '\\\\').replaceAll('"', '\\"');
243
+ const source = [
244
+ readiness,
245
+ submitSource.replace(/^from browser_harness import \*\s*$/m, ''),
246
+ temporarySource.replace(/^_wait_for_release\(\)\s*$/m, ''),
247
+ ].join('\n\n').replaceAll('__CFG_PATH__', escapedConfig);
248
+ const execOutput = await browserWorkspaceCommand(command, ['session', 'exec', activeSessionId], source);
249
+ const wrapper = parseWorkspaceJson(execOutput);
250
+ if (wrapper.ok !== true) {
251
+ throw new Error(typeof wrapper.error === 'string' ? wrapper.error : 'browser-workspace session exec failed');
209
252
  }
210
- catch (error) {
211
- reject(error);
253
+ const receipt = parseDriverJson(typeof wrapper.stdout === 'string' ? wrapper.stdout : '');
254
+ const result = JSON.stringify(receipt);
255
+ submittedResolve(result);
256
+ if (policy === 'never') {
257
+ await unlink(configFile).catch(() => { });
258
+ activeSessionId = undefined;
259
+ return result;
212
260
  }
213
- });
214
- if (signal.aborted)
215
- child.kill('SIGTERM');
216
- else
217
- signal.addEventListener('abort', () => child.kill('SIGTERM'), { once: true });
218
- });
219
- const release = () => { void writeFile(releaseFile, 'release').catch(() => { }); };
220
- promise.finally(() => { void unlink(releaseFile).catch(() => { }); }).catch(() => { });
221
- return { promise, submitted: submittedPromise, release, cancel: () => { release(); child.kill('SIGTERM'); } };
261
+ await releaseBarrier;
262
+ if (activeSessionId) {
263
+ await browserWorkspaceCommand(command, ['session', 'stop', activeSessionId]);
264
+ activeSessionId = undefined;
265
+ }
266
+ return result;
267
+ }
268
+ catch (failure) {
269
+ if (activeSessionId) {
270
+ await browserWorkspaceCommand(command, ['session', 'stop', activeSessionId]).catch(() => { });
271
+ activeSessionId = undefined;
272
+ }
273
+ await unlink(configFile).catch(() => { });
274
+ const error = failure instanceof Error ? failure : new Error(String(failure));
275
+ submittedReject(error);
276
+ throw error;
277
+ }
278
+ })();
279
+ const cancel = () => {
280
+ release();
281
+ if (activeSessionId) {
282
+ void browserWorkspaceCommand(command, ['session', 'stop', activeSessionId]);
283
+ activeSessionId = undefined;
284
+ }
285
+ };
286
+ if (signal.aborted)
287
+ cancel();
288
+ else
289
+ signal.addEventListener('abort', cancel, { once: true });
290
+ return { promise, submitted, release, cancel };
222
291
  };
223
292
  }
224
293
  function parseBrowserWorkerResponse(output) {
@@ -243,7 +312,7 @@ function parseBrowserWorkerResponse(output) {
243
312
  export class ChatGptAdapter {
244
313
  name = 'chatgpt';
245
314
  id = 'chatgpt/browser-worker';
246
- capabilities = ['model', 'chatgpt', 'browser-harness'];
315
+ capabilities = ['model', 'chatgpt', 'browser-workspace'];
247
316
  agentPath;
248
317
  runner;
249
318
  tabClosePolicy;
@@ -276,7 +345,7 @@ export class ChatGptAdapter {
276
345
  if (controller.signal.aborted)
277
346
  throw new Error('browser-worker execution aborted');
278
347
  const policy = chatGptTabClosePolicy(task.chatgptTabClosePolicy, this.tabClosePolicy);
279
- const run = (this.runner ?? directBrowserWorkerRunner(runtime.root))(runtime.definition, { prompt: task.input, ...(task.attachments?.length ? { files: task.attachments } : {}), tabClosePolicy: policy }, route, controller.signal);
348
+ const run = (this.runner ?? sessionBrowserWorkerRunner(runtime.root))(runtime.definition, { prompt: task.input, ...(task.attachments?.length ? { files: task.attachments } : {}), tabClosePolicy: policy }, route, controller.signal);
280
349
  try {
281
350
  const output = await (run.submitted ?? run.promise);
282
351
  const response = parseBrowserWorkerResponse(output);
package/dist/cli-help.js CHANGED
@@ -1,4 +1,4 @@
1
- export const usage = 'agents-relay <job|task|plan|blocker|context|init|record|agent-register|agent-discover|status|reconcile|serve> [options]';
1
+ export const usage = 'agents-relay <job|task|plan|blocker|projection|context|init|record|agent-register|agent-discover|status|reconcile|serve> [options]';
2
2
  const help = {
3
3
  root: `Agents Relay coordinates durable asynchronous agent jobs through GitHub pull requests.
4
4
 
@@ -10,6 +10,7 @@ Commands:
10
10
  task create|update|show|list|complete|retry|unblock|cancel Manage durable tasks
11
11
  plan Invoke the Planner for an existing managed Job
12
12
  blocker add|resolve|show Manage cross-Job blocker dependencies
13
+ projection conflicts|resolve Inspect and repair deterministic GitHub projection conflicts
13
14
  context resolve Resolve shared project instructions, skills, agents, and runtimes
14
15
  init Create a local demo job or initialize a GitHub job
15
16
  record Backfill a completed task into durable history
@@ -203,6 +204,17 @@ Common: --capabilities a,b, --runtime shell|codex|chatgpt, --provider NAME, --mo
203
204
 
204
205
  Example:
205
206
  npx agents-relay agent-register --file .agents-relay.json --name builder --responsibility "Build code" --role engineer --endpoint shell`,
207
+ projection: `Inspect and explicitly repair deterministic V2 GitHub projection conflicts.
208
+
209
+ Usage:
210
+ npx agents-relay projection conflicts --repo OWNER/REPO --pr NUMBER [--store PATH]
211
+ npx agents-relay projection resolve --repo OWNER/REPO --pr NUMBER --projection-id ID --action requeue|discard-local
212
+
213
+ A deterministic same-id/different-definition append conflict is classified once, stops
214
+ consuming retry budget, and retains structured diagnostic evidence. It is never
215
+ auto-overwritten: an operator resolves it after reconciling the divergent definition.
216
+ requeue Re-arm the recorded projection after the divergent side is reconciled
217
+ discard-local Drop the blocked local mutation so a fresh one can be enqueued`,
206
218
  'agent-discover': `Find registered agents using hard filters.
207
219
 
208
220
  Required: --file PATH or managed GitHub options.
@@ -220,5 +232,7 @@ export function helpText(command, action) {
220
232
  return action ? help[`context ${action}`] ?? help.context : help.context;
221
233
  if (command === 'blocker')
222
234
  return action ? help[`blocker ${action}`] ?? help.blocker : help.blocker;
235
+ if (command === 'projection')
236
+ return help.projection;
223
237
  return command ? help[command] ?? help.root : help.root;
224
238
  }
@@ -0,0 +1,61 @@
1
+ import { homedir } from 'node:os';
2
+ import { join } from 'node:path';
3
+ import { SQLiteRelayStore, relayStorePath } from './relay-store.js';
4
+ import { arg, integerArg, pullRequest, repository } from './cli-support.js';
5
+ const DEFAULT_STORE_DIR = join(homedir(), '.agents-relay', 'stores');
6
+ function storePath(args) {
7
+ const explicit = arg(args, '--store');
8
+ if (explicit)
9
+ return explicit;
10
+ return relayStorePath(arg(args, '--store-dir', DEFAULT_STORE_DIR), repository(args), pullRequest(args));
11
+ }
12
+ export async function runProjectionCommand(action, args) {
13
+ const store = new SQLiteRelayStore(storePath(args));
14
+ try {
15
+ if (action === 'conflicts' || action === 'list') {
16
+ const conflicts = await store.listProjectionConflicts();
17
+ console.log(JSON.stringify(conflicts, null, 2));
18
+ return;
19
+ }
20
+ if (action === 'resolve') {
21
+ const id = integerArg(args, '--projection-id', 0);
22
+ if (id <= 0)
23
+ throw new Error('projection resolve requires --projection-id ID');
24
+ const raw = arg(args, '--action', 'requeue');
25
+ if (raw !== 'requeue' && raw !== 'discard-local' && raw !== 'discard_local')
26
+ throw new Error('--action must be requeue or discard-local');
27
+ const resolution = raw === 'requeue' ? 'requeue' : 'discard_local';
28
+ const note = arg(args, '--note') || undefined;
29
+ const resolved = await store.resolveProjectionConflict(id, { action: resolution, ...(note ? { note } : {}) });
30
+ if (!resolved)
31
+ throw new Error(`Projection ${id} is not in a resolvable conflict state`);
32
+ console.log(`Resolved projection ${id} with ${resolution}`);
33
+ return;
34
+ }
35
+ throw new Error(`projection ${action || '<action>'} is not supported; use conflicts or resolve`);
36
+ }
37
+ finally {
38
+ store.close();
39
+ }
40
+ }
41
+ export function projectionHelp(action) {
42
+ if (action === 'resolve') {
43
+ return `Explicitly repair one classified projection conflict.
44
+
45
+ Required: --projection-id ID --action requeue|discard-local, plus --store PATH or --repo OWNER/REPO --pr NUMBER.
46
+ Actions:
47
+ requeue Re-arm the recorded projection after the divergent side is reconciled
48
+ discard-local Drop the blocked local mutation so a fresh one can be enqueued
49
+
50
+ Never silently overwrites either side. The recorded diagnostic remains until resolution.`;
51
+ }
52
+ return `Inspect and explicitly repair deterministic V2 GitHub projection conflicts.
53
+
54
+ Usage:
55
+ npx agents-relay projection conflicts --repo OWNER/REPO --pr NUMBER [--store PATH]
56
+ npx agents-relay projection resolve --repo OWNER/REPO --pr NUMBER --projection-id ID --action requeue|discard-local
57
+
58
+ A deterministic same-id/different-definition append conflict is classified once, stops
59
+ consuming retry budget, and retains structured diagnostic evidence. It is never
60
+ auto-overwritten: an operator resolves it after reconciling the divergent definition.`;
61
+ }
package/dist/cli.js CHANGED
@@ -23,6 +23,7 @@ import { applyJobActivateAction, applyJobCancelAction } from './job-actions.js';
23
23
  import { resolveExecutionContext } from './execution-context.js';
24
24
  import { defaultWorkspaceRoot, resolveManagedProjectPath, resolveRepositoryProjectPath } from './workspace.js';
25
25
  import { runBlockerCommand } from './cli-blocker.js';
26
+ import { runProjectionCommand } from './cli-projection.js';
26
27
  import { helpText, usage } from './cli-help.js';
27
28
  import { parseAgentGraph } from './agent-graph.js';
28
29
  import { runJobUnblockCommand, runTaskUnblockCommand } from './cli-unblock.js';
@@ -571,6 +572,11 @@ async function main() {
571
572
  await runBlockerCommand(action ?? '', blockerArgs);
572
573
  return;
573
574
  }
575
+ if (command === 'projection') {
576
+ const [action, ...projectionArgs] = args;
577
+ await runProjectionCommand(action ?? '', projectionArgs);
578
+ return;
579
+ }
574
580
  if (command === 'init') {
575
581
  const now = new Date().toISOString();
576
582
  const initialSchedule = scheduleFromArgs(args);
@@ -1,5 +1,6 @@
1
1
  import { registerBlockerDependency, resolveBlockerDependencies } from './blockers.js';
2
- import { sameTaskDefinition } from './store-task-definition.js';
2
+ import { DurableDefinitionConflictError, isDurableDefinitionConflict, sameTaskDefinition } from './store-task-definition.js';
3
+ import { decideProjectionFailure } from './workflow/projection-conflict-v2.js';
3
4
  function taskKey(task) { return `task:${task.jobId ?? ''}:${task.id}`; }
4
5
  function planKey(plan) { return `plan:${plan.jobId}:${plan.id}`; }
5
6
  function retryHeader(error, name) {
@@ -27,17 +28,60 @@ export function githubProjectionRetryAt(error, attempt, now = new Date()) {
27
28
  const backoffMs = Math.min(300_000, 1_000 * (2 ** Math.min(8, Math.max(0, attempt))));
28
29
  return new Date(now.getTime() + backoffMs);
29
30
  }
31
+ const DEFINITION_CONFLICT_MESSAGE = /already exists with a different definition$/;
32
+ /**
33
+ * Core deterministic-conflict classifier.
34
+ *
35
+ * A structured {@link DurableDefinitionConflictError} is authoritative; a plain error
36
+ * whose message matches the canonical GitHub marker conflict text is classified the
37
+ * same way so remote stores and legacy callers converge on one behavior.
38
+ */
30
39
  export function isTerminalProjectionFailure(item, error) {
40
+ if (item.operation !== 'append_task' && item.operation !== 'append_plan')
41
+ return false;
31
42
  const message = error instanceof Error ? error.message : String(error);
32
- return (item.operation === 'append_task' || item.operation === 'append_plan')
33
- && /already exists with a different definition$/.test(message);
43
+ return isDurableDefinitionConflict(error) || DEFINITION_CONFLICT_MESSAGE.test(message);
44
+ }
45
+ /** Builds durable diagnostic evidence even when a plain error carries no structured fields. */
46
+ function conflictDiagnostic(item, error, detectedAt) {
47
+ const message = error instanceof Error ? error.message : String(error);
48
+ if (error instanceof DurableDefinitionConflictError) {
49
+ return {
50
+ kind: error.kind,
51
+ jobId: error.jobId,
52
+ subjectId: error.subjectId,
53
+ operation: item.operation,
54
+ operationKey: item.operationKey,
55
+ localDefinition: error.local,
56
+ remoteDefinition: error.remote,
57
+ divergingFields: [...error.divergingFields],
58
+ detectedAt,
59
+ message,
60
+ };
61
+ }
62
+ const payload = item.payload;
63
+ const kind = item.operation === 'append_plan' ? 'plan' : 'task';
64
+ return {
65
+ kind,
66
+ jobId: typeof payload?.value?.jobId === 'string' ? payload.value.jobId : '',
67
+ subjectId: typeof payload?.value?.id === 'string' ? payload.value.id : '',
68
+ operation: item.operation,
69
+ operationKey: item.operationKey,
70
+ localDefinition: payload?.value ?? item.payload ?? null,
71
+ remoteDefinition: null,
72
+ divergingFields: [],
73
+ detectedAt,
74
+ message,
75
+ };
34
76
  }
35
77
  export class GitHubProjectionWriter {
36
78
  relay;
37
79
  github;
38
- constructor(relay, github) {
80
+ onConflict;
81
+ constructor(relay, github, onConflict) {
39
82
  this.relay = relay;
40
83
  this.github = github;
84
+ this.onConflict = onConflict;
41
85
  }
42
86
  async flushOnce(limit = 50, now = new Date()) {
43
87
  const items = await this.relay.claimDueProjections(limit, now);
@@ -51,8 +95,13 @@ export class GitHubProjectionWriter {
51
95
  }
52
96
  catch (error) {
53
97
  const message = error instanceof Error ? error.message : String(error);
54
- if (isTerminalProjectionFailure(item, error))
55
- await this.relay.deadLetterProjection(item.id, message);
98
+ const deterministic = isTerminalProjectionFailure(item, error);
99
+ const decision = decideProjectionFailure({ deterministicDefinitionConflict: deterministic });
100
+ if (decision.accepted && decision.target === 'conflict') {
101
+ const diagnostic = conflictDiagnostic(item, error, now.toISOString());
102
+ await this.relay.recordProjectionConflict(item.id, diagnostic);
103
+ await this.notifyConflict(diagnostic, message);
104
+ }
56
105
  else {
57
106
  await this.relay.retryProjection(item.id, message, githubProjectionRetryAt(error, item.attempts, now));
58
107
  deferred += 1;
@@ -61,6 +110,16 @@ export class GitHubProjectionWriter {
61
110
  }
62
111
  return { projected, deferred };
63
112
  }
113
+ async notifyConflict(diagnostic, message) {
114
+ if (!this.onConflict)
115
+ return;
116
+ try {
117
+ await this.onConflict({ type: 'projection.conflict', status: 'conflict', visibility: 'orchestrator', message, data: diagnostic });
118
+ }
119
+ catch (error) {
120
+ process.stderr.write(`warning: projection conflict event unavailable: ${error instanceof Error ? error.message : String(error)}\n`);
121
+ }
122
+ }
64
123
  async apply(item) {
65
124
  const payload = item.payload;
66
125
  switch (payload.operation) {
@@ -106,11 +165,11 @@ export class ProjectedDurableStore {
106
165
  relatedStore;
107
166
  projection;
108
167
  seededFacts;
109
- constructor(relay, github, seed = null, relatedStore) {
168
+ constructor(relay, github, seed = null, relatedStore, onConflict) {
110
169
  this.relay = relay;
111
170
  this.github = github;
112
171
  this.relatedStore = relatedStore;
113
- this.projection = new GitHubProjectionWriter(relay, github);
172
+ this.projection = new GitHubProjectionWriter(relay, github, onConflict);
114
173
  this.seededFacts = pullRequestFactsFromSeed(seed);
115
174
  if (this.seededFacts)
116
175
  void this.relay.savePullRequestFacts(this.seededFacts).catch(() => { });
@@ -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
@@ -3,17 +3,18 @@ import { once } from 'node:events';
3
3
  import { readFile } from 'node:fs/promises';
4
4
  import { homedir } from 'node:os';
5
5
  import { join } from 'node:path';
6
- import { ChatGptAdapter, CodexAdapter } from './adapters.js';
6
+ import { ChatGptAdapter, CodexAdapter, ensureChatGptBrowserWorkspace } from './adapters.js';
7
7
  import { CodexThreadContinuation, CommandContinuation, WebhookContinuation } from './continuation.js';
8
8
  import { serveDashboard } from './dashboard.js';
9
9
  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';
@@ -34,6 +35,15 @@ export async function overlayOperationalOverviews(overviews, storeFor) {
34
35
  return { ...item, job };
35
36
  }));
36
37
  }
38
+ function managedOverviewKey(item) {
39
+ return item.job.repository + '#' + item.job.prNumber + ':' + item.job.id;
40
+ }
41
+ export function mergeOperationalInventory(current, discovered) {
42
+ const merged = new Map(current.map(item => [managedOverviewKey(item), item]));
43
+ for (const item of discovered)
44
+ merged.set(managedOverviewKey(item), item);
45
+ return [...merged.values()];
46
+ }
37
47
  const PR_REFRESH_COALESCE_MS = 1000;
38
48
  export const SCHEDULE_HEARTBEAT_MS = 600_000;
39
49
  export function dueScheduledOverviews(overviews, now = new Date()) {
@@ -119,6 +129,7 @@ export async function runDaemon(argv) {
119
129
  return;
120
130
  }
121
131
  configureExecutionRouterRulesPath(argv);
132
+ await ensureChatGptBrowserWorkspace();
122
133
  const normalized = normalizeDaemonArguments(argv);
123
134
  if (normalized.pullRequest && normalized.jobId) {
124
135
  await startService([...argv]);
@@ -147,11 +158,17 @@ export async function runDaemon(argv) {
147
158
  const key = `${repository}#${prNumber}`;
148
159
  let relay = relayStores.get(key);
149
160
  if (!relay) {
150
- relay = new SQLiteRelayStore(join(homedir(), '.agents-relay', 'stores', `${repository.replace('/', '_')}-${prNumber}.db`));
161
+ relay = new SQLiteRelayStore(relayStorePath(join(homedir(), '.agents-relay', 'stores'), repository, prNumber));
151
162
  relayStores.set(key, relay);
152
163
  }
153
164
  const github = new GitHubStore(client, repository, prNumber, trusted, seed);
154
- const projected = new ProjectedDurableStore(relay, github, seed ?? null, (relatedRepository, relatedPrNumber) => runtimeStore(relatedRepository, relatedPrNumber));
165
+ const projected = new ProjectedDurableStore(relay, github, seed ?? null, (relatedRepository, relatedPrNumber) => runtimeStore(relatedRepository, relatedPrNumber), conflict => {
166
+ process.stderr.write(`projection conflict for ${repository}#${prNumber}: ${conflict.message} (fields: ${conflict.data.divergingFields.join(', ') || 'unknown'}; repair with agents-relay projection resolve)\n`);
167
+ if (bus) {
168
+ const jobId = conflict.data.jobId || conflictJobId(repository, prNumber);
169
+ void Promise.resolve().then(() => bus.publish(eventFor(jobId, 'projection', null, 'projection.conflict', 'blocked', conflict.message, 'orchestrator', { ...conflict.data, repository, prNumber }))).catch(() => undefined);
170
+ }
171
+ });
155
172
  projectionWriters.set(key, projected.projection);
156
173
  return projected;
157
174
  };
@@ -176,9 +193,8 @@ export async function runDaemon(argv) {
176
193
  try {
177
194
  githubReadPaths.workspaceScans += 1;
178
195
  const refreshed = await withGitHubRequestContext(client, 'relayd.workspace-refresh', () => aggregateManagedGitHubJobs(client, scanRepositories, trusted));
179
- const scanned = new Set(scanRepositories);
180
- const merged = dashboardManagedJobs([...cachedOverviews.filter(item => !scanned.has(item.job.repository)), ...refreshed]);
181
- cachedOverviews = await overlayOperationalOverviews(merged, (repository, prNumber) => runtimeStore(repository, prNumber));
196
+ const merged = mergeOperationalInventory(cachedOverviews, refreshed);
197
+ cachedOverviews = dashboardManagedJobs(await overlayOperationalOverviews(merged, (repository, prNumber) => runtimeStore(repository, prNumber)));
182
198
  return cachedOverviews;
183
199
  }
184
200
  catch (error) {
@@ -375,7 +391,7 @@ export async function runDaemon(argv) {
375
391
  const runtime = summarizeRuntime(cachedOverviews.map(item => item.job));
376
392
  const workers = workerCandidates([
377
393
  { name: 'codex', id: 'codex', capabilities: ['model', 'codex'] },
378
- { name: 'chatgpt', id: 'chatgpt/browser-worker', capabilities: ['model', 'chatgpt', 'browser-harness'] },
394
+ { name: 'chatgpt', id: 'chatgpt/browser-worker', capabilities: ['model', 'chatgpt', 'browser-workspace'] },
379
395
  ], [], usage);
380
396
  const result = await routeExecution(workers, task.workerRequirements ?? { capabilities: task.capabilities ?? [] }, {
381
397
  decisionEngine: new LayaCliDecisionEngine(),
@@ -483,3 +499,6 @@ export async function runDaemon(argv) {
483
499
  }
484
500
  if (isEntrypoint(import.meta.url))
485
501
  runDaemon(process.argv.slice(2)).catch(error => { console.error(error instanceof Error ? error.message : String(error)); process.exitCode = 1; });
502
+ function conflictJobId(repository, prNumber) {
503
+ return `${repository}#${prNumber}`;
504
+ }
@@ -1,5 +1,5 @@
1
1
  import { parseAgent, parseJob, parsePlan, parseTask, renderAgentComment, renderJobBody, renderPlanComment, renderTaskComment, JOB_MARKER } from './markers.js';
2
- import { isTaskForJob, sameTaskDefinition } from './store-task-definition.js';
2
+ import { DurableDefinitionConflictError, isTaskForJob, planDefinitionDivergence, samePlanDefinition, sameTaskDefinition, taskDefinitionDivergence } from './store-task-definition.js';
3
3
  import { registerBlockerDependency, resolveBlockerDependencies } from './blockers.js';
4
4
  const TASK_WRITE_OVERLAY = new Map();
5
5
  const PLAN_WRITE_OVERLAY = new Map();
@@ -322,8 +322,8 @@ export class GitHubStore {
322
322
  return;
323
323
  }
324
324
  const existing = parsePlan(matches[0].body);
325
- if (!existing || JSON.stringify({ ...existing, planUrl: undefined }) !== JSON.stringify({ ...plan, planUrl: undefined }))
326
- throw new Error(`Plan ${plan.id} already exists with a different definition`);
325
+ if (!existing || !samePlanDefinition(existing, plan))
326
+ throw new DurableDefinitionConflictError('plan', 'append_plan', plan.jobId, plan.id, plan, existing ?? null, existing ? planDefinitionDivergence(existing, plan) : []);
327
327
  for (const duplicate of matches.slice(1))
328
328
  await this.deleteComment(duplicate);
329
329
  }
@@ -357,8 +357,9 @@ export class GitHubStore {
357
357
  return;
358
358
  }
359
359
  const parsed = matches.map(comment => ({ comment, task: parseTask(comment.body) }));
360
- if (parsed.some(item => !sameTaskDefinition(item.task, task)))
361
- throw new Error(`Task ${task.id} already exists with a different definition`);
360
+ const divergent = parsed.find(item => !sameTaskDefinition(item.task, task));
361
+ if (divergent)
362
+ throw new DurableDefinitionConflictError('task', 'append_task', task.jobId ?? '', task.id, task, divergent.task, taskDefinitionDivergence(divergent.task, task));
362
363
  for (const duplicate of parsed.slice(1))
363
364
  await this.deleteComment(duplicate.comment);
364
365
  }
@@ -1,3 +1,37 @@
1
+ /**
2
+ * Deterministic conflict between two different definitions that claim the same durable id.
3
+ *
4
+ * This is not a transient projection failure: the same id is already occupied by a
5
+ * different immutable definition, so retrying the same append can never succeed.
6
+ * Callers classify it once, stop consuming retry budget, and record diagnostic evidence.
7
+ */
8
+ export class DurableDefinitionConflictError extends Error {
9
+ kind;
10
+ operation;
11
+ jobId;
12
+ subjectId;
13
+ local;
14
+ remote;
15
+ divergingFields;
16
+ deterministic = true;
17
+ constructor(kind, operation, jobId, subjectId, local, remote, divergingFields) {
18
+ super(kind === 'task'
19
+ ? `Task ${subjectId} already exists with a different definition`
20
+ : `Plan ${subjectId} already exists with a different definition`);
21
+ this.kind = kind;
22
+ this.operation = operation;
23
+ this.jobId = jobId;
24
+ this.subjectId = subjectId;
25
+ this.local = local;
26
+ this.remote = remote;
27
+ this.divergingFields = divergingFields;
28
+ this.name = 'DurableDefinitionConflictError';
29
+ }
30
+ }
31
+ export function isDurableDefinitionConflict(error) {
32
+ return error instanceof DurableDefinitionConflictError
33
+ || (typeof error === 'object' && error !== null && error.name === 'DurableDefinitionConflictError');
34
+ }
1
35
  function immutableTaskDefinition(task) {
2
36
  const routing = task.routing ? {
3
37
  provider: task.routing.provider, model: task.routing.model, profile: task.routing.profile,
@@ -12,6 +46,35 @@ function immutableTaskDefinition(task) {
12
46
  export function sameTaskDefinition(left, right) {
13
47
  return JSON.stringify(immutableTaskDefinition(left)) === JSON.stringify(immutableTaskDefinition(right));
14
48
  }
49
+ /** Plan identity ignores the resolved comment URL, which is projection metadata rather than definition. */
50
+ export function planDefinition(plan) {
51
+ const { planUrl: _planUrl, ...rest } = plan;
52
+ return rest;
53
+ }
54
+ export function samePlanDefinition(left, right) {
55
+ return JSON.stringify(planDefinition(left)) === JSON.stringify(planDefinition(right));
56
+ }
57
+ function observableDefinition(kind, definition) {
58
+ if (typeof definition !== 'object' || definition === null)
59
+ return {};
60
+ if (kind === 'task')
61
+ return immutableTaskDefinition(definition);
62
+ const { planUrl: _planUrl, ...rest } = definition;
63
+ return rest;
64
+ }
65
+ /** Top-level field names whose definition values diverge; enough to diagnose a conflicted id. */
66
+ export function divergingDefinitionFields(kind, left, right) {
67
+ const observed = observableDefinition(kind, left);
68
+ const other = observableDefinition(kind, right);
69
+ const keys = [...new Set([...Object.keys(observed), ...Object.keys(other)])].sort();
70
+ return keys.filter(key => JSON.stringify(observed[key]) !== JSON.stringify(other[key]));
71
+ }
72
+ export function taskDefinitionDivergence(left, right) {
73
+ return divergingDefinitionFields('task', left, right);
74
+ }
75
+ export function planDefinitionDivergence(left, right) {
76
+ return divergingDefinitionFields('plan', left, right);
77
+ }
15
78
  export function isTaskForJob(task, jobId, soleTrustedJob) {
16
79
  if (task.jobId === jobId)
17
80
  return true;
package/dist/store.js CHANGED
@@ -1,5 +1,5 @@
1
1
  import { parseAgent, parseJob, parsePlan, parseTask, renderAgentComment, renderJobComment, renderPlanComment, renderTaskComment, AGENT_MARKER, JOB_MARKER, PLAN_MARKER, TASK_MARKER } from './markers.js';
2
- import { isTaskForJob, sameTaskDefinition } from './store-task-definition.js';
2
+ import { DurableDefinitionConflictError, isTaskForJob, planDefinitionDivergence, samePlanDefinition, sameTaskDefinition, taskDefinitionDivergence } from './store-task-definition.js';
3
3
  import { parsePackagePublicationMarker } from './github-webhook.js';
4
4
  function publicationFromComments(rows, trustedAuthors) {
5
5
  const allowed = rows.filter(item => {
@@ -70,7 +70,7 @@ export async function loadManagedGitHubJobGraphql(client, repository, prNumber,
70
70
  return (await loadManagedGitHubPullRequestGraphql(client, repository, prNumber, trustedAuthors)).find(item => item.job.id === jobId) ?? null;
71
71
  }
72
72
  const COMPLETED_JOB_HISTORY_LIMIT = 10;
73
- function isCompletedManagedJob(item) { return ['COMPLETED', 'CANCELLED'].includes(item.job.state) || item.githubState !== 'OPEN'; }
73
+ function isCompletedManagedJob(item) { return ['COMPLETED', 'CANCELLED'].includes(item.job.state); }
74
74
  function completedAt(item) {
75
75
  return [item.job.updatedAt, item.mergedAt, item.closedAt].filter((value) => typeof value === 'string' && value.length > 0).sort().at(-1) ?? '';
76
76
  }
@@ -95,9 +95,9 @@ export class InMemoryStore {
95
95
  async saveJob(job) { this.values.set(`${JOB_MARKER}:${job.id}`, renderJobComment(job)); }
96
96
  async appendPlan(plan) { const key = `${PLAN_MARKER}:${plan.jobId}:${plan.id}`; const existing = this.values.get(key); if (existing) {
97
97
  const current = parsePlan(existing);
98
- if (current && JSON.stringify(current) === JSON.stringify(plan))
98
+ if (current && samePlanDefinition(current, plan))
99
99
  return;
100
- throw new Error(`Plan ${plan.id} already exists with a different definition`);
100
+ throw new DurableDefinitionConflictError('plan', 'append_plan', plan.jobId, plan.id, plan, current ?? null, current ? planDefinitionDivergence(current, plan) : []);
101
101
  } this.values.set(key, renderPlanComment(plan)); }
102
102
  async savePlan(plan) { this.values.set(`${PLAN_MARKER}:${plan.jobId}:${plan.id}`, renderPlanComment(plan)); }
103
103
  async appendTask(task) {
@@ -107,7 +107,7 @@ export class InMemoryStore {
107
107
  const current = parseTask(existing);
108
108
  if (current && sameTaskDefinition(current, task))
109
109
  return;
110
- throw new Error(`Task ${task.id} already exists with a different definition`);
110
+ throw new DurableDefinitionConflictError('task', 'append_task', task.jobId ?? '', task.id, task, current ?? null, current ? taskDefinitionDivergence(current, task) : []);
111
111
  }
112
112
  this.values.set(key, renderTaskComment(task));
113
113
  }
@@ -0,0 +1,26 @@
1
+ import { readFileSync } from 'node:fs';
2
+ import { GraphRuntime } from './graph-v2.js';
3
+ import { dispatchWorkflowGraphSync } from './dispatcher.js';
4
+ const source = readFileSync(new URL('../../workflow/projection-conflict.mmd', import.meta.url), 'utf8');
5
+ const boolGuard = (name) => (facts) => facts.durableFacts[name] === true;
6
+ export const projectionConflictV2 = new GraphRuntime(source, {
7
+ states: new Set(['classify', 'retry', 'conflict']),
8
+ guards: { deterministicDefinitionConflict: boolGuard('deterministicDefinitionConflict') },
9
+ actions: {},
10
+ events: { 'projection.failed': { id: 'projection.failed' } },
11
+ });
12
+ /**
13
+ * Core projection-failure disposition policy.
14
+ *
15
+ * A deterministic same-id/different-definition conflict stops consuming retry budget;
16
+ * any other failure keeps the normal backoff/retry path. This branch is owned by the
17
+ * canonical Mermaid graph, not by a procedural `if` in the projection writer.
18
+ */
19
+ export function decideProjectionFailure(facts) {
20
+ return dispatchWorkflowGraphSync(projectionConflictV2, {
21
+ initialState: 'classify',
22
+ input: { transport: 'event', id: 'projection.failed', payload: {} },
23
+ durableFacts: facts,
24
+ maxSteps: 1,
25
+ });
26
+ }
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "agents-relay",
3
- "version": "2.0.32",
3
+ "version": "2.0.34",
4
4
  "description": "Durable async agent jobs coordinated through GitHub pull requests",
5
5
  "type": "module",
6
6
  "bin": {
@@ -0,0 +1,11 @@
1
+ flowchart LR
2
+ %% relay: initial classify
3
+ classify["state: classify"]
4
+ retry["state: retry"]
5
+ conflict["state: conflict"]
6
+
7
+ deterministic["guard: deterministicDefinitionConflict"]
8
+
9
+ classify -->|on: projection.failed| deterministic
10
+ deterministic -->|true| conflict
11
+ deterministic -->|false| retry