agents-relay 2.0.32 → 2.0.33
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/README.md +2 -0
- 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 +13 -3
- package/dist/store-github.js +6 -5
- package/dist/store-task-definition.js +63 -0
- package/dist/store.js +4 -4
- package/dist/workflow/projection-conflict-v2.js +26 -0
- package/package.json +1 -1
- package/workflow/projection-conflict.mmd +11 -0
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/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
|
@@ -10,10 +10,11 @@ import { RepositoryWorkerPool, aggregateManagedGitHubJobs } from './pool.js';
|
|
|
10
10
|
import { Reconciler } from './reconciler.js';
|
|
11
11
|
import { dashboardManagedJobs, GitHubStore, InMemoryStore, loadManagedGitHubPullRequestSnapshotGraphql, withGitHubRequestContext } from './store.js';
|
|
12
12
|
import { ProjectedDurableStore } from './projected-store.js';
|
|
13
|
-
import { discoverLocalRelayStoreSnapshots, SQLiteRelayStore } from './relay-store.js';
|
|
13
|
+
import { discoverLocalRelayStoreSnapshots, relayStorePath, SQLiteRelayStore } from './relay-store.js';
|
|
14
14
|
import { githubAuthContext } from './github-auth.js';
|
|
15
15
|
import { dashboardJobAction, isEntrypoint, runtimePlanner, runtimeReviewer, startService, SERVICE_DEFAULTS } from './cli.js';
|
|
16
16
|
import { eventBusResolutionMessage, publishOrchestratorTaskCompletion, resolveEventBus } from './cli-support.js';
|
|
17
|
+
import { eventFor } from './events.js';
|
|
17
18
|
import { applyJobActivateAction } from './job-actions.js';
|
|
18
19
|
import { applyOrchestratorCompleteAction } from './task-actions.js';
|
|
19
20
|
import { dashboardJobSetState, dashboardTaskAction, dashboardTaskSetState } from './dashboard-actions.js';
|
|
@@ -147,11 +148,17 @@ export async function runDaemon(argv) {
|
|
|
147
148
|
const key = `${repository}#${prNumber}`;
|
|
148
149
|
let relay = relayStores.get(key);
|
|
149
150
|
if (!relay) {
|
|
150
|
-
relay = new SQLiteRelayStore(join(homedir(), '.agents-relay', 'stores',
|
|
151
|
+
relay = new SQLiteRelayStore(relayStorePath(join(homedir(), '.agents-relay', 'stores'), repository, prNumber));
|
|
151
152
|
relayStores.set(key, relay);
|
|
152
153
|
}
|
|
153
154
|
const github = new GitHubStore(client, repository, prNumber, trusted, seed);
|
|
154
|
-
const projected = new ProjectedDurableStore(relay, github, seed ?? null, (relatedRepository, relatedPrNumber) => runtimeStore(relatedRepository, relatedPrNumber)
|
|
155
|
+
const projected = new ProjectedDurableStore(relay, github, seed ?? null, (relatedRepository, relatedPrNumber) => runtimeStore(relatedRepository, relatedPrNumber), conflict => {
|
|
156
|
+
process.stderr.write(`projection conflict for ${repository}#${prNumber}: ${conflict.message} (fields: ${conflict.data.divergingFields.join(', ') || 'unknown'}; repair with agents-relay projection resolve)\n`);
|
|
157
|
+
if (bus) {
|
|
158
|
+
const jobId = conflict.data.jobId || conflictJobId(repository, prNumber);
|
|
159
|
+
void Promise.resolve().then(() => bus.publish(eventFor(jobId, 'projection', null, 'projection.conflict', 'blocked', conflict.message, 'orchestrator', { ...conflict.data, repository, prNumber }))).catch(() => undefined);
|
|
160
|
+
}
|
|
161
|
+
});
|
|
155
162
|
projectionWriters.set(key, projected.projection);
|
|
156
163
|
return projected;
|
|
157
164
|
};
|
|
@@ -483,3 +490,6 @@ export async function runDaemon(argv) {
|
|
|
483
490
|
}
|
|
484
491
|
if (isEntrypoint(import.meta.url))
|
|
485
492
|
runDaemon(process.argv.slice(2)).catch(error => { console.error(error instanceof Error ? error.message : String(error)); process.exitCode = 1; });
|
|
493
|
+
function conflictJobId(repository, prNumber) {
|
|
494
|
+
return `${repository}#${prNumber}`;
|
|
495
|
+
}
|
package/dist/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 => {
|
|
@@ -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
|