agents-relay 2.0.31 → 2.0.33
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/README.md +4 -2
- package/dist/cli-help.js +15 -1
- package/dist/cli-projection.js +61 -0
- package/dist/cli.js +6 -0
- package/dist/dashboard-html.js +2 -2
- package/dist/decision-audit.js +1 -1
- package/dist/execution-routing.js +117 -88
- package/dist/projected-store.js +67 -8
- package/dist/projection-outbox.js +268 -0
- package/dist/relay-store.js +26 -148
- package/dist/relayd.js +23 -3
- package/dist/router-evals.js +2 -2
- package/dist/router-rules.js +101 -0
- package/dist/routing-policy.js +4 -3
- package/dist/store-github.js +6 -5
- package/dist/store-task-definition.js +63 -0
- package/dist/store.js +4 -4
- package/dist/workflow/projection-conflict-v2.js +26 -0
- package/package.json +1 -1
- package/workflow/projection-conflict.mmd +11 -0
package/README.md
CHANGED
|
@@ -121,7 +121,7 @@ Planner-created tasks use stable IDs and the existing parent/subtask tree, so
|
|
|
121
121
|
retries and daemon restarts do not create duplicate work.
|
|
122
122
|
Task producers must follow `skills/agents-relay/contracts/task-job-contract.md`; that shared skill contract defines story size, lifecycle events, review, and auto-merge semantics.
|
|
123
123
|
|
|
124
|
-
Execution routing is code-driven: Relay builds complete executable worker+model routes from authoritative runtime/model/resource facts, removes only impossible routes, then calls the generic typed `decide(request, contract)` boundary once
|
|
124
|
+
Execution routing is code-driven: Relay builds complete executable worker+model routes from authoritative runtime/model/resource facts, merges profile defaults with the user's explicit `[choices]`, deduplicates them, removes only impossible routes via hard gates, then calls the generic typed `decide(request, contract)` boundary exactly once with one merged choice list and the `[routing-instruction]` text forwarded verbatim. Task/resource/capability signals and the routing instruction are soft decision facts; decision failure falls back deterministically to a usable Codex default route. See [Execution Router and typed decisions](docs/worker-model-routers.md).
|
|
125
125
|
All task producers use the same story-size contract: `S`≈5 minutes, `M`≈15 minutes, and `L`≈45 minutes of normal-reasoning execution, with `M` as the default. `high` reasoning doubles that overall window. ChatGPT Browser Worker start-ack timeout is derived as one third of the effective overall timeout, clamped between 1 and 5 minutes. Orchestrators and planners should choose `storySize`, not raw timeout milliseconds; `--timeout` remains only as an explicit compatibility/debug override.
|
|
126
126
|
|
|
127
127
|
Autonomous jobs can also be scheduled. The Job marker stores a five-field cron expression, IANA timezone, next run, and missed-run/concurrency policy. The daemon evaluates the SQLite-backed managed Job view and validates durable schedule facts before claiming a due slot. A repository heartbeat invokes `tick` at 10-minute resolution. See
|
|
@@ -189,11 +189,13 @@ 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.
|
|
195
197
|
|
|
196
|
-
Router Rules are stored at `~/.agents-relay/execution-router-rules.md` by default. `agents-relayd --execution-router-rules PATH` selects a different file for that daemon process; the explicit CLI path takes precedence over `AGENTS_RELAY_EXECUTION_ROUTER_RULES`, which remains supported for compatibility.
|
|
198
|
+
Router Rules are stored at `~/.agents-relay/execution-router-rules.md` by default and use `[choices]` plus `[routing-instruction]` sections (see the Execution Router doc). `agents-relayd --execution-router-rules PATH` selects a different file for that daemon process; the explicit CLI path takes precedence over `AGENTS_RELAY_EXECUTION_ROUTER_RULES`, which remains supported for compatibility.
|
|
197
199
|
|
|
198
200
|
`agents-relayd` without `--pr`/`--id` runs in workspace mode by default: it discovers nested Git repositories under `~/Workspace`, keeps only GitHub `origin` remotes, and aggregates their managed PR jobs into one dashboard and worker pool. Pass `--workspace PATH` to use another root. `--repo OWNER/REPO` remains the single-repository pool mode.
|
|
199
201
|
|
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/dashboard-html.js
CHANGED
|
@@ -235,7 +235,7 @@ export const dashboardHtml = `<!doctype html>
|
|
|
235
235
|
</div>
|
|
236
236
|
</div>
|
|
237
237
|
</section>
|
|
238
|
-
<section id="rules-view" role="tabpanel" aria-labelledby="rules-tab" hidden><div class="panel"><div class="panel-head"><h2>Router Rules</h2><span id="rules-meta" class="subtle"></span></div><div class="panel-body"><div class="router-rules-workspace"><div class="router-rules-pane rules-editor"><h3>Rules Editor</h3><p class="rules-help">
|
|
238
|
+
<section id="rules-view" role="tabpanel" aria-labelledby="rules-tab" hidden><div class="panel"><div class="panel-head"><h2>Router Rules</h2><span id="rules-meta" class="subtle"></span></div><div class="panel-body"><div class="router-rules-workspace"><div class="router-rules-pane rules-editor"><h3>Rules Editor</h3><p class="rules-help">Execution Router rules with an explicit <code>[choices]</code> section (provider: model [reasoning] entries, additive to profile defaults) and a <code>[routing-instruction]</code> section forwarded verbatim to Laya. Relay deduplicates, applies hard gates, and sends one merged choice list; impossible routes remain excluded by code and Codex stays the deterministic fallback.</p><textarea id="rules-text" class="rules-textarea" spellcheck="true" aria-label="Execution router rules" placeholder="[choices]\nopenai: sol medium, luna high\nzai: glm-5.3-flash high\n\n[routing-instruction]\nPrefer zai until its quota resets; otherwise use the openai default."></textarea><div class="rules-footer"><span id="rules-status" class="subtle" aria-live="polite"></span><div><button id="rules-test" class="rules-save" type="button">Test Router</button> <button id="rules-save" class="rules-save" type="button">Save rules</button></div></div></div><div class="router-rules-pane"><div class="panel-head"><h3>Audit Log</h3><button id="audit-refresh" type="button">Refresh</button></div><div id="audit-log" class="audit-log" aria-live="polite"><p class="empty">Loading latest routing decisions…</p></div></div></div><div class="router-test-panel"><h3>Test Router Result</h3><div id="router-test-results" class="router-test-results" aria-live="polite"><p class="empty">Select a Job and Task, then run Test Router.</p></div></div></div></div></section>
|
|
239
239
|
<section id="shortcuts-view" role="tabpanel" aria-labelledby="shortcuts-tab" hidden>
|
|
240
240
|
<div class="panel">
|
|
241
241
|
<div class="panel-head"><h2>ChatGPT Shortcuts</h2><span class="subtle">One per line · %1 = current repo/pr</span></div>
|
|
@@ -453,7 +453,7 @@ function routeWorker(record){const id=record?.selected?.worker?.id||record?.work
|
|
|
453
453
|
function renderAudit(records){auditLog.replaceChildren();if(!Array.isArray(records)||!records.length){auditLog.append(element('p','empty',text('No schema v5 routing decisions yet.')));return;}for(const record of records){const card=element('article','audit-entry');const head=element('div','audit-entry-head');head.append(element('strong','',text(routeWorker(record)+' · '+value(record?.selected?.model?.model))),element('span','subtle',text(new Date(record.timestamp).toLocaleString())));card.append(head);const correlation=record.correlation||{};card.append(element('div','subtle',text([correlation.job_id,correlation.task_id].filter(Boolean).join(' / ')||'uncorrelated')));const probabilities=record.decision_evidence?.probabilities||{};const gates=(record.gate_results||[]).filter(item=>!item.eligible).map(item=>item.candidate_id+': '+item.rejection_reasons.join(', '));const nl=String.fromCharCode(10);const details=document.createElement('pre');details.textContent='fallback: '+String(Boolean(record.decision_evidence?.fallback))+(Object.keys(probabilities).length?nl+'probabilities: '+JSON.stringify(probabilities,null,2):'')+(gates.length?nl+'rejected: '+gates.join(nl):'');card.append(details);auditLog.append(card);}}
|
|
454
454
|
async function drawAudit(){auditRefresh.disabled=true;try{renderAudit(await loadJSON('/api/decision-audit'));}catch(error){auditLog.replaceChildren(element('p','error-state',text('Audit unavailable: '+error.message)));}finally{auditRefresh.disabled=false;}}
|
|
455
455
|
async function drawRules(){rulesStatus.textContent='';try{const rules=await loadJSON('/api/execution-router-rules');rulesText.value=typeof rules.text==='string'?rules.text:'';rulesMeta.textContent=rules.hash&&rules.hash!=='none'?'Loaded · '+rules.hash.slice(0,12):'No custom rules saved';}catch(error){rulesMeta.textContent='Unavailable';rulesStatus.textContent='Could not load rules: '+error.message;}void drawAudit();}
|
|
456
|
-
async function testRouter(){rulesTest.disabled=true;routerTestResults.replaceChildren(element('p','subtle',text('Calling router with current status…')));try{if(!selectedJob||!selectedTaskId)throw new Error('Select a Job and Task first');const response=await fetch('/api/router-test',{method:'POST',headers:{'content-type':'application/json'},body:JSON.stringify({jobId:selectedJob,taskId:selectedTaskId})});if(!response.ok)throw new Error(response.status+' '+await response.text());const result=await response.json();const selected=result.selected||{};const payload={selected,proposed_fields:result.proposedFields||{},probabilities:result.probabilities||{},fallback:Boolean(result.fallback),hard_gates:result.gates||[],laya_input:result.decisionInput||null};routerTestResults.replaceChildren(element('strong','',text((selected.worker||'—')+' · '+(selected.model||'—'))));routerTestResults.append(element('div','subtle',text('route: '+(selected.route||'—'))),element('pre','',text(yamlDump(payload))));}catch(error){routerTestResults.replaceChildren(element('p','error-state',text('Router test failed: '+error.message)));}finally{rulesTest.disabled=false;}}
|
|
456
|
+
async function testRouter(){rulesTest.disabled=true;routerTestResults.replaceChildren(element('p','subtle',text('Calling router with current status…')));try{if(!selectedJob||!selectedTaskId)throw new Error('Select a Job and Task first');const response=await fetch('/api/router-test',{method:'POST',headers:{'content-type':'application/json'},body:JSON.stringify({jobId:selectedJob,taskId:selectedTaskId})});if(!response.ok)throw new Error(response.status+' '+await response.text());const result=await response.json();const selected=result.selected||{};const payload={selected,proposed_fields:result.proposedFields||{},probabilities:result.probabilities||{},fallback:Boolean(result.fallback),effective_choices:result.effectiveChoices||[],routing_instruction:result.routingInstruction||'',merged_choices:result.mergedChoices||[],hard_gates:result.gates||[],laya_input:result.decisionInput||null};routerTestResults.replaceChildren(element('strong','',text((selected.worker||'—')+' · '+(selected.model||'—'))));routerTestResults.append(element('div','subtle',text('route: '+(selected.route||'—'))),element('pre','',text(yamlDump(payload))));}catch(error){routerTestResults.replaceChildren(element('p','error-state',text('Router test failed: '+error.message)));}finally{rulesTest.disabled=false;}}
|
|
457
457
|
async function saveRules(){rulesSave.disabled=true;rulesStatus.textContent='Saving…';try{const response=await fetch('/api/execution-router-rules',{method:'PUT',headers:{'content-type':'text/plain; charset=utf-8'},body:rulesText.value});if(!response.ok)throw new Error(response.status+' '+await response.text());const rules=await response.json();rulesMeta.textContent='Saved · '+rules.hash.slice(0,12);rulesStatus.textContent='Saved. New routing decisions will load these rules.';}catch(error){rulesStatus.textContent='Save failed: '+error.message;}finally{rulesSave.disabled=false;}}
|
|
458
458
|
async function drawShortcuts(){shortcutsStatus.textContent='';shortcutsText.value=chatShortcutText();}
|
|
459
459
|
function saveShortcuts(){const value=shortcutsText.value.trim();if(value)localStorage.setItem(CHAT_SHORTCUTS_KEY,value);else localStorage.removeItem(CHAT_SHORTCUTS_KEY);shortcutsText.value=chatShortcutText();shortcutsStatus.textContent='Saved.';}
|
package/dist/decision-audit.js
CHANGED
|
@@ -135,7 +135,7 @@ export function makeExecutionDecisionAudit(input) {
|
|
|
135
135
|
fallback: { used: input.routing.decisionEvidence?.fallback ?? false, reason: input.routing.decisionEvidence?.error },
|
|
136
136
|
decision_evidence: input.routing.decisionEvidence,
|
|
137
137
|
decision_input: {
|
|
138
|
-
type: '
|
|
138
|
+
type: 'merged_choice_decision',
|
|
139
139
|
stages: input.decisionStages.map(stage => ({
|
|
140
140
|
name: stage.name,
|
|
141
141
|
facts: stage.facts,
|
|
@@ -111,13 +111,6 @@ async function decideStage(engine, name, facts, question, choices) {
|
|
|
111
111
|
throw new Error('Execution router expected a choice result for ' + name);
|
|
112
112
|
return { name, facts, contract, result: { choice: decision.choice, probabilities: decision.probabilities } };
|
|
113
113
|
}
|
|
114
|
-
function stageFacts(baseFacts, selected, candidates) {
|
|
115
|
-
return JSON.parse(JSON.stringify({
|
|
116
|
-
...baseFacts,
|
|
117
|
-
selected,
|
|
118
|
-
...candidates,
|
|
119
|
-
}));
|
|
120
|
-
}
|
|
121
114
|
function routeId(worker, model) {
|
|
122
115
|
return worker.workerId + '::' + model.id + (model.reasoning ? '@' + model.reasoning : '');
|
|
123
116
|
}
|
|
@@ -143,9 +136,89 @@ function workerSelection(worker, selectedBy, evidence) {
|
|
|
143
136
|
decisionEvidence: evidence,
|
|
144
137
|
};
|
|
145
138
|
}
|
|
139
|
+
/**
|
|
140
|
+
* Configured profile defaults for one worker: the explicitly configured
|
|
141
|
+
* provider/model/reasoning profiles (Codex `config.toml` and `*.config.toml`),
|
|
142
|
+
* i.e. every discovered route that is not a runtime catalog expansion.
|
|
143
|
+
*/
|
|
144
|
+
function profileDefaults(candidates) {
|
|
145
|
+
return candidates.filter(candidate => !candidate.id.startsWith('catalog:'));
|
|
146
|
+
}
|
|
147
|
+
/**
|
|
148
|
+
* Resolve explicit user `[choices]` entries against discovered runtime models.
|
|
149
|
+
* A choice may reference a runtime catalog model because the user named it
|
|
150
|
+
* explicitly; Relay never expands the unreferenced catalog into choices.
|
|
151
|
+
* Unresolvable or reasoning-incompatible entries are dropped rather than
|
|
152
|
+
* fabricated into an impossible route.
|
|
153
|
+
*/
|
|
154
|
+
function resolveExplicitModel(choice, pool) {
|
|
155
|
+
const model = choice.model.toLowerCase();
|
|
156
|
+
const exact = pool.find(candidate => candidate.model.toLowerCase() === model);
|
|
157
|
+
if (exact)
|
|
158
|
+
return exact.model;
|
|
159
|
+
const suffixes = [...new Set(pool.filter(candidate => candidate.model.toLowerCase().endsWith('-' + model)).map(candidate => candidate.model))];
|
|
160
|
+
if (suffixes.length === 1)
|
|
161
|
+
return suffixes[0];
|
|
162
|
+
const hint = choice.model.match(/^(gpt-\d+(?:\.\d+)?)-[a-z0-9.-]+$/i)?.[1];
|
|
163
|
+
if (hint) {
|
|
164
|
+
const family = pool.find(candidate => candidate.model.toLowerCase() === (hint + '-' + model).toLowerCase());
|
|
165
|
+
if (family)
|
|
166
|
+
return family.model;
|
|
167
|
+
}
|
|
168
|
+
return null;
|
|
169
|
+
}
|
|
170
|
+
function resolveExplicitChoices(choices, candidates) {
|
|
171
|
+
const result = [];
|
|
172
|
+
const seen = new Set();
|
|
173
|
+
for (const choice of choices) {
|
|
174
|
+
const provider = choice.provider.toLowerCase();
|
|
175
|
+
const providerPool = candidates.filter(candidate => candidate.provider.toLowerCase() === provider);
|
|
176
|
+
const model = resolveExplicitModel(choice, providerPool);
|
|
177
|
+
if (!model)
|
|
178
|
+
continue;
|
|
179
|
+
const matches = providerPool.filter(candidate => candidate.model === model);
|
|
180
|
+
const pool = choice.reasoning
|
|
181
|
+
? matches.filter(candidate => (candidate.reasoning ?? '').toLowerCase() === choice.reasoning)
|
|
182
|
+
: matches;
|
|
183
|
+
const candidate = pool.find(item => !item.id.startsWith('catalog:')) ?? pool[0];
|
|
184
|
+
if (!candidate)
|
|
185
|
+
continue;
|
|
186
|
+
const key = candidate.id + '@' + (candidate.reasoning ?? '');
|
|
187
|
+
if (seen.has(key))
|
|
188
|
+
continue;
|
|
189
|
+
seen.add(key);
|
|
190
|
+
result.push(candidate);
|
|
191
|
+
}
|
|
192
|
+
return result;
|
|
193
|
+
}
|
|
194
|
+
function mergeCandidates(...lists) {
|
|
195
|
+
const result = [];
|
|
196
|
+
const seen = new Set();
|
|
197
|
+
for (const list of lists) {
|
|
198
|
+
for (const candidate of list) {
|
|
199
|
+
const key = candidate.id + '@' + (candidate.reasoning ?? '');
|
|
200
|
+
if (seen.has(key))
|
|
201
|
+
continue;
|
|
202
|
+
seen.add(key);
|
|
203
|
+
result.push(candidate);
|
|
204
|
+
}
|
|
205
|
+
}
|
|
206
|
+
return result;
|
|
207
|
+
}
|
|
208
|
+
export function executionChoiceLabel(route, isDefault) {
|
|
209
|
+
return 'worker=' + route.worker.workerId
|
|
210
|
+
+ ' provider=' + route.model.provider
|
|
211
|
+
+ ' model=' + route.model.model
|
|
212
|
+
+ ' reasoning=' + (route.model.reasoning ?? 'provider-default')
|
|
213
|
+
+ ' default=' + String(isDefault);
|
|
214
|
+
}
|
|
215
|
+
function isDefaultCandidate(candidate, defaults) {
|
|
216
|
+
return defaults.some(item => item.id === candidate.id && item.reasoning === candidate.reasoning);
|
|
217
|
+
}
|
|
146
218
|
export async function discoverExecutionRoutes(workers, requirements, options = {}) {
|
|
147
219
|
const registered = options.registered ?? [];
|
|
148
220
|
const policy = await loadRoutingPolicy(options.policyPath, options.rulesPath);
|
|
221
|
+
const rules = await loadExecutionRouterRules(options.rulesPath);
|
|
149
222
|
const workerGateEvaluations = workers.map(worker => {
|
|
150
223
|
const rejectionReasons = workerGateReasons(worker, requirements);
|
|
151
224
|
return { workerId: worker.workerId, eligible: rejectionReasons.length === 0, rejectionReasons };
|
|
@@ -153,28 +226,45 @@ export async function discoverExecutionRoutes(workers, requirements, options = {
|
|
|
153
226
|
const evaluations = [];
|
|
154
227
|
for (const worker of workers) {
|
|
155
228
|
const workerReasons = workerGateEvaluations.find(item => item.workerId === worker.workerId)?.rejectionReasons ?? [];
|
|
156
|
-
const
|
|
157
|
-
|
|
229
|
+
const discovered = await discoverModelCandidates(worker, registered, options.usage, undefined, options.rulesPath);
|
|
230
|
+
const defaults = profileDefaults(discovered);
|
|
231
|
+
const explicit = resolveExplicitChoices(rules.parsed.choices, discovered);
|
|
232
|
+
const merged = mergeCandidates(defaults, explicit);
|
|
233
|
+
for (const model of merged) {
|
|
158
234
|
const rejectionReasons = [...workerReasons, ...modelGateReasons(model, options.routingConstraints, options.excludedRoutes, policy)];
|
|
159
235
|
const route = { id: routeId(worker, model), worker, model };
|
|
160
236
|
evaluations.push({ route, eligible: rejectionReasons.length === 0, rejectionReasons });
|
|
161
237
|
}
|
|
162
238
|
}
|
|
163
239
|
const routes = evaluations.filter(item => item.eligible).map(item => item.route);
|
|
164
|
-
return { routes, evaluations, workerGateEvaluations, policy };
|
|
240
|
+
return { routes, evaluations, workerGateEvaluations, policy, choices: rules.parsed.choices, routingInstruction: rules.parsed.routingInstruction };
|
|
165
241
|
}
|
|
166
242
|
export async function routeExecution(workers, requirements, options = {}) {
|
|
167
|
-
const { routes, evaluations, workerGateEvaluations, policy } = await discoverExecutionRoutes(workers, requirements, options);
|
|
243
|
+
const { routes, evaluations, workerGateEvaluations, policy, routingInstruction } = await discoverExecutionRoutes(workers, requirements, options);
|
|
168
244
|
if (routes.length === 0)
|
|
169
245
|
throw new Error('No executable worker+model route satisfies authoritative constraints');
|
|
170
246
|
const fallback = fallbackRoute(routes);
|
|
171
247
|
const rules = await loadExecutionRouterRules(options.rulesPath);
|
|
172
|
-
const userInputRules = rules.text;
|
|
173
248
|
const preferredChatgptConcurrency = options.preferredChatgptConcurrency ?? DEFAULT_CHATGPT_PREFERRED_CONCURRENCY;
|
|
174
249
|
const chatgptRunning = options.chatgptRunning ?? 0;
|
|
175
250
|
const taskFacts = options.taskFacts ?? {};
|
|
176
251
|
const taskShape = executionTaskShape(taskFacts);
|
|
177
|
-
const
|
|
252
|
+
const discoveredDefaults = new Map();
|
|
253
|
+
for (const worker of workers) {
|
|
254
|
+
const discovered = await discoverModelCandidates(worker, options.registered ?? [], options.usage, undefined, options.rulesPath);
|
|
255
|
+
discoveredDefaults.set(worker.workerId, profileDefaults(discovered));
|
|
256
|
+
}
|
|
257
|
+
const effectiveChoices = routes.map(route => ({
|
|
258
|
+
worker: route.worker.workerId,
|
|
259
|
+
provider: route.model.provider,
|
|
260
|
+
model: route.model.model,
|
|
261
|
+
profile: route.model.profile ?? null,
|
|
262
|
+
reasoning: route.model.reasoning ?? null,
|
|
263
|
+
default: isDefaultCandidate(route.model, discoveredDefaults.get(route.worker.workerId) ?? []),
|
|
264
|
+
source: isDefaultCandidate(route.model, discoveredDefaults.get(route.worker.workerId) ?? []) ? 'profile-default' : 'explicit-choice',
|
|
265
|
+
}));
|
|
266
|
+
const choiceLabels = routes.map((route, index) => executionChoiceLabel(route, effectiveChoices[index].default));
|
|
267
|
+
const facts = JSON.parse(JSON.stringify({
|
|
178
268
|
context: {
|
|
179
269
|
job: taskFacts.job ?? null,
|
|
180
270
|
task: taskFacts.task ?? taskFacts,
|
|
@@ -189,95 +279,32 @@ export async function routeExecution(workers, requirements, options = {}) {
|
|
|
189
279
|
reviewer_diversity: taskFacts.reviewer_diversity,
|
|
190
280
|
} : null,
|
|
191
281
|
},
|
|
192
|
-
user_input_rules:
|
|
282
|
+
user_input_rules: rules.text,
|
|
283
|
+
routing_instruction: routingInstruction,
|
|
193
284
|
runtime: {
|
|
194
285
|
chatgpt_running: chatgptRunning,
|
|
195
286
|
preferred_chatgpt_concurrency: preferredChatgptConcurrency,
|
|
196
287
|
},
|
|
197
288
|
worker_candidates: workerProviderFacts(routes),
|
|
198
|
-
|
|
289
|
+
effective_choices: effectiveChoices,
|
|
199
290
|
}));
|
|
291
|
+
const question = 'Choose one complete route from the merged choice list. Relay has already merged profile defaults with the user\'s explicit [choices] and applied every authoritative hard gate, so only these routes are legal. Follow [routing_instruction] verbatim; do not invent, add, or substitute routes that are not listed.';
|
|
200
292
|
let selected = fallback;
|
|
201
293
|
let evidence = { mode: options.decisionMode ?? 'off', fallback: true };
|
|
202
294
|
const decisionStages = [];
|
|
203
295
|
if ((options.decisionMode ?? 'off') !== 'off' && options.decisionEngine) {
|
|
204
296
|
try {
|
|
205
|
-
const
|
|
206
|
-
|
|
207
|
-
|
|
208
|
-
|
|
209
|
-
|
|
210
|
-
|
|
211
|
-
const configured = providerRoutes.find(route => !route.model.id.startsWith('catalog:')) ?? providerRoutes[0];
|
|
212
|
-
return {
|
|
213
|
-
key,
|
|
214
|
-
worker,
|
|
215
|
-
provider,
|
|
216
|
-
default_model: configured.model.model,
|
|
217
|
-
label: 'worker=' + worker + ' provider=' + provider + ' default_model=' + configured.model.model,
|
|
218
|
-
};
|
|
219
|
-
});
|
|
220
|
-
const providerFacts = stageFacts(baseFacts, {}, {
|
|
221
|
-
provider_candidates: providerOptions.map(({ label: _label, key: _key, ...option }) => option),
|
|
222
|
-
});
|
|
223
|
-
const providerStage = await decideStage(options.decisionEngine, 'provider', providerFacts, 'Choose the best worker/provider pair. Interpret user_input_rules directly together with task context, runtime load, provider defaults, and code-plan quota. When user_input_rules explicitly names providers as routing options, prefer those named providers when applicable; treat unmentioned providers as fallbacks unless other facts clearly require them.', providerOptions.map(option => option.label));
|
|
224
|
-
decisionStages.push(providerStage);
|
|
225
|
-
const selectedProvider = providerOptions.find(option => option.label === providerStage.result.choice);
|
|
226
|
-
if (!selectedProvider)
|
|
227
|
-
throw new Error('Laya selected an unknown worker/provider option');
|
|
228
|
-
const workerId = selectedProvider.worker;
|
|
229
|
-
const provider = selectedProvider.provider;
|
|
230
|
-
const providerRoutes = routes.filter(route => route.worker.workerId === workerId && route.model.provider === provider);
|
|
231
|
-
if (providerRoutes.length === 0)
|
|
232
|
-
throw new Error('Laya selected a worker/provider pair with no eligible routes');
|
|
233
|
-
const modelGroups = new Map();
|
|
234
|
-
for (const route of providerRoutes) {
|
|
235
|
-
const group = modelGroups.get(route.model.model) ?? [];
|
|
236
|
-
group.push(route);
|
|
237
|
-
modelGroups.set(route.model.model, group);
|
|
238
|
-
}
|
|
239
|
-
const modelChoices = [...modelGroups.keys()];
|
|
240
|
-
const modelOptions = modelChoices.map(model => {
|
|
241
|
-
const group = modelGroups.get(model) ?? [];
|
|
242
|
-
const configured = group.some(route => !route.model.id.startsWith('catalog:'));
|
|
243
|
-
return {
|
|
244
|
-
model,
|
|
245
|
-
configured_default: configured,
|
|
246
|
-
supported_reasoning: [...new Set(group.map(route => route.model.reasoning).filter((value) => Boolean(value)))],
|
|
247
|
-
label: 'model=' + model + ' configured_default=' + String(configured),
|
|
248
|
-
};
|
|
249
|
-
});
|
|
250
|
-
const modelFacts = stageFacts(baseFacts, { worker: workerId, provider }, {
|
|
251
|
-
model_candidates: modelOptions.map(({ label: _label, ...option }) => option),
|
|
252
|
-
});
|
|
253
|
-
const modelStage = await decideStage(options.decisionEngine, 'model', modelFacts, 'Choose the best model for the selected worker/provider. Interpret user_input_rules directly. Candidate model names come from current runtime/configuration and are not derived from the free-text rules. If user_input_rules explicitly maps a model to the current task type, size, complexity, or other current condition, follow that mapping instead of the provider default.', modelOptions.map(option => option.label));
|
|
254
|
-
decisionStages.push(modelStage);
|
|
255
|
-
const selectedModel = modelOptions.find(option => option.label === modelStage.result.choice);
|
|
256
|
-
if (!selectedModel)
|
|
257
|
-
throw new Error('Laya selected an unknown model option');
|
|
258
|
-
const modelRoutes = modelGroups.get(selectedModel.model) ?? [];
|
|
259
|
-
const reasoningChoices = [...new Set(modelRoutes.map(route => route.model.reasoning ?? 'provider-default'))];
|
|
260
|
-
const reasoningOptions = reasoningChoices.map(reasoning => ({
|
|
261
|
-
reasoning,
|
|
262
|
-
label: 'reasoning=' + reasoning,
|
|
263
|
-
}));
|
|
264
|
-
const reasoningFacts = stageFacts(baseFacts, { worker: workerId, provider, model: selectedModel.model }, {
|
|
265
|
-
reasoning_candidates: reasoningChoices,
|
|
266
|
-
});
|
|
267
|
-
const reasoningStage = await decideStage(options.decisionEngine, 'reasoning', reasoningFacts, 'Choose the reasoning level for the selected model. Interpret user_input_rules directly. If user_input_rules explicitly assigns a reasoning level to the selected model for the current conditions, follow that assignment. Use provider-default only when the model does not expose a specific reasoning level.', reasoningOptions.map(option => option.label));
|
|
268
|
-
decisionStages.push(reasoningStage);
|
|
269
|
-
const selectedReasoning = reasoningOptions.find(option => option.label === reasoningStage.result.choice);
|
|
270
|
-
if (!selectedReasoning)
|
|
271
|
-
throw new Error('Laya selected an unknown reasoning option');
|
|
272
|
-
const reasoning = selectedReasoning.reasoning === 'provider-default' ? null : selectedReasoning.reasoning;
|
|
273
|
-
const proposed = modelRoutes.find(route => (route.model.reasoning ?? null) === reasoning);
|
|
274
|
-
if (!proposed)
|
|
275
|
-
throw new Error('Laya selected a model/reasoning combination with no eligible route');
|
|
276
|
-
selected = proposed;
|
|
297
|
+
const stage = await decideStage(options.decisionEngine, 'route', facts, question, choiceLabels);
|
|
298
|
+
decisionStages.push(stage);
|
|
299
|
+
const index = stage.result.choice ? choiceLabels.indexOf(stage.result.choice) : -1;
|
|
300
|
+
if (index < 0)
|
|
301
|
+
throw new Error('Laya selected a route outside the merged choice list');
|
|
302
|
+
selected = routes[index];
|
|
277
303
|
evidence = {
|
|
278
304
|
mode: options.decisionMode ?? 'active',
|
|
279
305
|
engine: options.decisionEngine.name,
|
|
280
|
-
proposed:
|
|
306
|
+
proposed: stage.result.choice,
|
|
307
|
+
probabilities: stage.result.probabilities,
|
|
281
308
|
proposedFields: {
|
|
282
309
|
worker: selected.worker.workerId,
|
|
283
310
|
provider: selected.model.provider,
|
|
@@ -320,6 +347,8 @@ export async function routeExecution(workers, requirements, options = {}) {
|
|
|
320
347
|
constraints: options.routingConstraints ?? {},
|
|
321
348
|
excludedRoutes: options.excludedRoutes ?? [],
|
|
322
349
|
candidateRoutes: routes.map(route => ({ id: route.id, workerId: route.worker.workerId, adapter: route.worker.adapter })),
|
|
350
|
+
effectiveChoices,
|
|
351
|
+
routingInstruction,
|
|
323
352
|
},
|
|
324
353
|
policy,
|
|
325
354
|
routing,
|
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(() => { });
|