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 +2 -0
- package/dist/adapters.js +119 -50
- package/dist/cli-help.js +15 -1
- package/dist/cli-projection.js +61 -0
- package/dist/cli.js +6 -0
- package/dist/projected-store.js +67 -8
- package/dist/projection-outbox.js +268 -0
- package/dist/relay-store.js +26 -148
- package/dist/relayd.js +27 -8
- package/dist/store-github.js +6 -5
- package/dist/store-task-definition.js +63 -0
- package/dist/store.js +5 -5
- package/dist/workflow/projection-conflict-v2.js +26 -0
- package/package.json +1 -1
- package/workflow/projection-conflict.mmd +11 -0
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
|
|
167
|
-
return (
|
|
168
|
-
const
|
|
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
|
-
|
|
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
|
|
179
|
-
|
|
180
|
-
|
|
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
|
-
|
|
191
|
-
|
|
192
|
-
|
|
193
|
-
|
|
194
|
-
|
|
195
|
-
|
|
196
|
-
|
|
197
|
-
|
|
198
|
-
|
|
199
|
-
|
|
200
|
-
|
|
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
|
-
|
|
203
|
-
|
|
204
|
-
|
|
205
|
-
|
|
206
|
-
|
|
207
|
-
|
|
208
|
-
|
|
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
|
-
|
|
211
|
-
|
|
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
|
-
|
|
215
|
-
|
|
216
|
-
|
|
217
|
-
|
|
218
|
-
|
|
219
|
-
|
|
220
|
-
|
|
221
|
-
|
|
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-
|
|
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 ??
|
|
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);
|
package/dist/projected-store.js
CHANGED
|
@@ -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 (
|
|
33
|
-
|
|
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
|
-
|
|
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
|
-
|
|
55
|
-
|
|
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
|
+
}
|
package/dist/relay-store.js
CHANGED
|
@@ -2,7 +2,7 @@ import { DatabaseSync } from 'node:sqlite';
|
|
|
2
2
|
import { dirname } from 'node:path';
|
|
3
3
|
import { mkdirSync } from 'node:fs';
|
|
4
4
|
import { readdir } from 'node:fs/promises';
|
|
5
|
-
|
|
5
|
+
import { ProjectionOutbox, SCHEMA_VERSION, } from './projection-outbox.js';
|
|
6
6
|
function newerRecord(local, remote) {
|
|
7
7
|
if (!local)
|
|
8
8
|
return structuredClone(remote);
|
|
@@ -30,24 +30,10 @@ export function mergeInboundJobSnapshot(local, remote) {
|
|
|
30
30
|
}
|
|
31
31
|
return base;
|
|
32
32
|
}
|
|
33
|
-
function rowToProjection(row) {
|
|
34
|
-
return {
|
|
35
|
-
id: Number(row.id),
|
|
36
|
-
idempotencyKey: row.idempotency_key,
|
|
37
|
-
coalesceKey: row.coalesce_key,
|
|
38
|
-
operation: row.operation,
|
|
39
|
-
payload: JSON.parse(row.payload_json),
|
|
40
|
-
priority: Number(row.priority),
|
|
41
|
-
attempts: Number(row.attempts),
|
|
42
|
-
nextAttemptAt: row.next_attempt_at,
|
|
43
|
-
lastError: row.last_error,
|
|
44
|
-
createdAt: row.created_at,
|
|
45
|
-
updatedAt: row.updated_at,
|
|
46
|
-
};
|
|
47
|
-
}
|
|
48
33
|
export class SQLiteRelayStore {
|
|
49
34
|
path;
|
|
50
35
|
db;
|
|
36
|
+
outbox;
|
|
51
37
|
constructor(path) {
|
|
52
38
|
this.path = path;
|
|
53
39
|
if (path !== ':memory:')
|
|
@@ -55,8 +41,8 @@ export class SQLiteRelayStore {
|
|
|
55
41
|
this.db = new DatabaseSync(path);
|
|
56
42
|
this.db.exec('PRAGMA journal_mode=WAL; PRAGMA synchronous=NORMAL; PRAGMA busy_timeout=5000; PRAGMA foreign_keys=ON;');
|
|
57
43
|
this.migrate();
|
|
58
|
-
this.
|
|
59
|
-
|
|
44
|
+
this.outbox = new ProjectionOutbox(this.db);
|
|
45
|
+
this.outbox.resetProcessing(new Date().toISOString());
|
|
60
46
|
}
|
|
61
47
|
migrate() {
|
|
62
48
|
this.db.exec(`
|
|
@@ -87,60 +73,15 @@ export class SQLiteRelayStore {
|
|
|
87
73
|
sequence INTEGER,
|
|
88
74
|
received_at TEXT NOT NULL
|
|
89
75
|
);
|
|
90
|
-
|
|
91
|
-
|
|
92
|
-
idempotency_key TEXT NOT NULL UNIQUE,
|
|
93
|
-
coalesce_key TEXT,
|
|
94
|
-
operation TEXT NOT NULL,
|
|
95
|
-
payload_json TEXT NOT NULL,
|
|
96
|
-
priority INTEGER NOT NULL DEFAULT 0,
|
|
97
|
-
status TEXT NOT NULL DEFAULT 'pending' CHECK(status IN ('pending','processing','dead')),
|
|
98
|
-
attempts INTEGER NOT NULL DEFAULT 0,
|
|
99
|
-
next_attempt_at TEXT,
|
|
100
|
-
last_error TEXT,
|
|
101
|
-
created_at TEXT NOT NULL,
|
|
102
|
-
updated_at TEXT NOT NULL
|
|
103
|
-
);
|
|
104
|
-
CREATE INDEX IF NOT EXISTS projection_due_idx
|
|
105
|
-
ON projection_outbox(status, next_attempt_at, priority DESC, id);
|
|
106
|
-
CREATE INDEX IF NOT EXISTS projection_coalesce_idx
|
|
107
|
-
ON projection_outbox(coalesce_key, status);
|
|
76
|
+
${ProjectionOutbox.createTableSql()}
|
|
77
|
+
${ProjectionOutbox.createIndexSql()}
|
|
108
78
|
`);
|
|
109
79
|
const current = this.db.prepare("SELECT value FROM relay_meta WHERE key='schema_version'").get();
|
|
110
80
|
const version = current?.value ? Number(current.value) : 0;
|
|
111
81
|
if (version > SCHEMA_VERSION)
|
|
112
82
|
throw new Error(`Relay store schema ${version} is newer than supported ${SCHEMA_VERSION}`);
|
|
113
|
-
if (version <
|
|
114
|
-
|
|
115
|
-
if (schema?.sql && !schema.sql.includes("'dead'")) {
|
|
116
|
-
this.db.exec(`
|
|
117
|
-
BEGIN IMMEDIATE;
|
|
118
|
-
DROP INDEX IF EXISTS projection_due_idx;
|
|
119
|
-
DROP INDEX IF EXISTS projection_coalesce_idx;
|
|
120
|
-
ALTER TABLE projection_outbox RENAME TO projection_outbox_v3;
|
|
121
|
-
CREATE TABLE projection_outbox (
|
|
122
|
-
id INTEGER PRIMARY KEY AUTOINCREMENT,
|
|
123
|
-
idempotency_key TEXT NOT NULL UNIQUE,
|
|
124
|
-
coalesce_key TEXT,
|
|
125
|
-
operation TEXT NOT NULL,
|
|
126
|
-
payload_json TEXT NOT NULL,
|
|
127
|
-
priority INTEGER NOT NULL DEFAULT 0,
|
|
128
|
-
status TEXT NOT NULL DEFAULT 'pending' CHECK(status IN ('pending','processing','dead')),
|
|
129
|
-
attempts INTEGER NOT NULL DEFAULT 0,
|
|
130
|
-
next_attempt_at TEXT,
|
|
131
|
-
last_error TEXT,
|
|
132
|
-
created_at TEXT NOT NULL,
|
|
133
|
-
updated_at TEXT NOT NULL
|
|
134
|
-
);
|
|
135
|
-
INSERT INTO projection_outbox(id,idempotency_key,coalesce_key,operation,payload_json,priority,status,attempts,next_attempt_at,last_error,created_at,updated_at)
|
|
136
|
-
SELECT id,idempotency_key,coalesce_key,operation,payload_json,priority,status,attempts,next_attempt_at,last_error,created_at,updated_at FROM projection_outbox_v3;
|
|
137
|
-
DROP TABLE projection_outbox_v3;
|
|
138
|
-
CREATE INDEX projection_due_idx ON projection_outbox(status, next_attempt_at, priority DESC, id);
|
|
139
|
-
CREATE INDEX projection_coalesce_idx ON projection_outbox(coalesce_key, status);
|
|
140
|
-
COMMIT;
|
|
141
|
-
`);
|
|
142
|
-
}
|
|
143
|
-
}
|
|
83
|
+
if (version < SCHEMA_VERSION)
|
|
84
|
+
ProjectionOutbox.upgrade(this.db, version);
|
|
144
85
|
this.db.prepare("INSERT INTO relay_meta(key,value) VALUES('schema_version',?) ON CONFLICT(key) DO UPDATE SET value=excluded.value")
|
|
145
86
|
.run(String(SCHEMA_VERSION));
|
|
146
87
|
}
|
|
@@ -257,11 +198,14 @@ export class SQLiteRelayStore {
|
|
|
257
198
|
`).run(eventId, stream, sequence, new Date().toISOString());
|
|
258
199
|
return Number(result.changes) === 1;
|
|
259
200
|
}
|
|
201
|
+
enqueueProjectionInTransaction(request, now) {
|
|
202
|
+
this.outbox.enqueue(request, now);
|
|
203
|
+
}
|
|
260
204
|
async enqueueProjection(request) {
|
|
261
205
|
const now = new Date().toISOString();
|
|
262
206
|
this.db.exec('BEGIN IMMEDIATE');
|
|
263
207
|
try {
|
|
264
|
-
this.
|
|
208
|
+
this.outbox.enqueue(request, now);
|
|
265
209
|
this.db.exec('COMMIT');
|
|
266
210
|
}
|
|
267
211
|
catch (error) {
|
|
@@ -269,90 +213,24 @@ export class SQLiteRelayStore {
|
|
|
269
213
|
throw error;
|
|
270
214
|
}
|
|
271
215
|
}
|
|
272
|
-
enqueueProjectionInTransaction(request, now) {
|
|
273
|
-
if (request.coalesceKey) {
|
|
274
|
-
this.db.prepare(`
|
|
275
|
-
DELETE FROM projection_outbox
|
|
276
|
-
WHERE coalesce_key=? AND status='pending' AND idempotency_key<>?
|
|
277
|
-
`).run(request.coalesceKey, request.idempotencyKey);
|
|
278
|
-
}
|
|
279
|
-
const existing = this.db.prepare('SELECT status FROM projection_outbox WHERE idempotency_key=?').get(request.idempotencyKey);
|
|
280
|
-
// A writer may be applying this key outside the transaction. Never rewrite its
|
|
281
|
-
// payload: its completion must only remove its own row, not a newer mutation.
|
|
282
|
-
const nextId = Number(this.db.prepare('SELECT COALESCE(MAX(id),0)+1 AS next_id FROM projection_outbox').get().next_id ?? 1);
|
|
283
|
-
const idempotencyKey = existing?.status === 'processing' || existing?.status === 'dead'
|
|
284
|
-
? `${request.idempotencyKey}:queued:${nextId}`
|
|
285
|
-
: request.idempotencyKey;
|
|
286
|
-
this.db.prepare(`
|
|
287
|
-
INSERT INTO projection_outbox(
|
|
288
|
-
idempotency_key,coalesce_key,operation,payload_json,priority,status,
|
|
289
|
-
attempts,next_attempt_at,last_error,created_at,updated_at
|
|
290
|
-
) VALUES(?,?,?,?,?,'pending',0,NULL,NULL,?,?)
|
|
291
|
-
ON CONFLICT(idempotency_key) DO UPDATE SET
|
|
292
|
-
coalesce_key=excluded.coalesce_key,
|
|
293
|
-
operation=excluded.operation,
|
|
294
|
-
payload_json=excluded.payload_json,
|
|
295
|
-
priority=MAX(projection_outbox.priority,excluded.priority),
|
|
296
|
-
status='pending',
|
|
297
|
-
next_attempt_at=NULL,
|
|
298
|
-
last_error=NULL,
|
|
299
|
-
updated_at=excluded.updated_at
|
|
300
|
-
`).run(idempotencyKey, request.coalesceKey ?? null, request.operation, JSON.stringify(request.payload), request.priority ?? 0, now, now);
|
|
301
|
-
}
|
|
302
216
|
async claimDueProjections(limit = 50, now = new Date()) {
|
|
303
|
-
|
|
304
|
-
this.db.exec('BEGIN IMMEDIATE');
|
|
305
|
-
try {
|
|
306
|
-
const rows = this.db.prepare(`
|
|
307
|
-
SELECT id,idempotency_key,coalesce_key,operation,payload_json,priority,
|
|
308
|
-
attempts,next_attempt_at,last_error,created_at,updated_at
|
|
309
|
-
FROM projection_outbox
|
|
310
|
-
WHERE status='pending' AND (next_attempt_at IS NULL OR next_attempt_at<=?)
|
|
311
|
-
ORDER BY priority DESC, id ASC
|
|
312
|
-
LIMIT ?
|
|
313
|
-
`).all(observedAt, Math.max(1, Math.floor(limit)));
|
|
314
|
-
const mark = this.db.prepare("UPDATE projection_outbox SET status='processing', updated_at=? WHERE id=? AND status='pending'");
|
|
315
|
-
const claimed = [];
|
|
316
|
-
for (const row of rows) {
|
|
317
|
-
const result = mark.run(observedAt, row.id);
|
|
318
|
-
if (Number(result.changes) === 1)
|
|
319
|
-
claimed.push({ ...row, updated_at: observedAt });
|
|
320
|
-
}
|
|
321
|
-
this.db.exec('COMMIT');
|
|
322
|
-
return claimed.map(rowToProjection);
|
|
323
|
-
}
|
|
324
|
-
catch (error) {
|
|
325
|
-
this.db.exec('ROLLBACK');
|
|
326
|
-
throw error;
|
|
327
|
-
}
|
|
328
|
-
}
|
|
329
|
-
async completeProjection(id) {
|
|
330
|
-
this.db.prepare('DELETE FROM projection_outbox WHERE id=?').run(id);
|
|
331
|
-
}
|
|
332
|
-
async retryProjection(id, error, nextAttemptAt) {
|
|
333
|
-
this.db.prepare(`
|
|
334
|
-
UPDATE projection_outbox
|
|
335
|
-
SET status='pending', attempts=attempts+1, next_attempt_at=?, last_error=?, updated_at=?
|
|
336
|
-
WHERE id=?
|
|
337
|
-
`).run(nextAttemptAt.toISOString(), error, new Date().toISOString(), id);
|
|
338
|
-
}
|
|
339
|
-
async deadLetterProjection(id, error) {
|
|
340
|
-
this.db.prepare(`
|
|
341
|
-
UPDATE projection_outbox
|
|
342
|
-
SET status='dead', attempts=attempts+1, next_attempt_at=NULL, last_error=?, updated_at=?
|
|
343
|
-
WHERE id=?
|
|
344
|
-
`).run(error, new Date().toISOString(), id);
|
|
345
|
-
}
|
|
346
|
-
async pendingProjectionCount() {
|
|
347
|
-
const row = this.db.prepare("SELECT COUNT(*) AS count FROM projection_outbox WHERE status IN ('pending','processing')").get();
|
|
348
|
-
return Number(row?.count ?? 0);
|
|
349
|
-
}
|
|
350
|
-
async terminalProjectionCount() {
|
|
351
|
-
const row = this.db.prepare("SELECT COUNT(*) AS count FROM projection_outbox WHERE status='dead'").get();
|
|
352
|
-
return Number(row?.count ?? 0);
|
|
217
|
+
return this.outbox.claimDue(limit, now);
|
|
353
218
|
}
|
|
219
|
+
async completeProjection(id) { this.outbox.complete(id); }
|
|
220
|
+
async retryProjection(id, error, nextAttemptAt) { this.outbox.retry(id, error, nextAttemptAt); }
|
|
221
|
+
async deadLetterProjection(id, error) { this.outbox.deadLetter(id, error); }
|
|
222
|
+
async recordProjectionConflict(id, diagnostic) { this.outbox.recordConflict(id, diagnostic); }
|
|
223
|
+
async listProjectionConflicts() { return this.outbox.listConflicts(); }
|
|
224
|
+
async resolveProjectionConflict(id, resolution) { return this.outbox.resolveConflict(id, resolution); }
|
|
225
|
+
async conflictProjectionCount() { return this.outbox.conflictCount(); }
|
|
226
|
+
async pendingProjectionCount() { return this.outbox.pendingCount(); }
|
|
227
|
+
async terminalProjectionCount() { return this.outbox.terminalCount(); }
|
|
354
228
|
close() { this.db.close(); }
|
|
355
229
|
}
|
|
230
|
+
/** Canonical on-disk Relay Store location for one managed GitHub pull request. */
|
|
231
|
+
export function relayStorePath(directory, repository, prNumber) {
|
|
232
|
+
return directory + '/' + `${repository.replace('/', '_')}-${prNumber}.db`;
|
|
233
|
+
}
|
|
356
234
|
export async function discoverLocalRelayStoreSnapshots(directory) {
|
|
357
235
|
let names;
|
|
358
236
|
try {
|
package/dist/relayd.js
CHANGED
|
@@ -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',
|
|
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
|
|
180
|
-
|
|
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-
|
|
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
|
+
}
|
package/dist/store-github.js
CHANGED
|
@@ -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 ||
|
|
326
|
-
throw new
|
|
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
|
-
|
|
361
|
-
|
|
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)
|
|
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 &&
|
|
98
|
+
if (current && samePlanDefinition(current, plan))
|
|
99
99
|
return;
|
|
100
|
-
throw new
|
|
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
|
|
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
|
@@ -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
|