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 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. Free-text routing guidance and task/resource/capability signals 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).
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);
@@ -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">Free-text soft policy loaded by the Execution Router for every worker+model routing decision. Impossible routes remain excluded by code; Codex remains the deterministic fallback if decision fails.</p><textarea id="rules-text" class="rules-textarea" spellcheck="true" aria-label="Execution router rules" placeholder="Example: Prefer ZAI glm-5.3-flash high while quota is available. Use ChatGPT for research when appropriate. Codex is fallback only."></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>
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.';}
@@ -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: 'staged_route_decision',
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 models = await discoverModelCandidates(worker, registered, options.usage, undefined, options.rulesPath);
157
- for (const model of models) {
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 baseFacts = JSON.parse(JSON.stringify({
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: userInputRules,
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
- routing_instruction: 'Interpret user_input_rules directly. Choose among the runtime-provided legal candidates at each stage. Relay does not parse user_input_rules into model or reasoning choices.',
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 providerKeys = [...new Set(routes.map(route => route.worker.workerId + '::' + route.model.provider))];
206
- const providerOptions = providerKeys.map(key => {
207
- const splitAt = key.lastIndexOf('::');
208
- const worker = key.slice(0, splitAt);
209
- const provider = key.slice(splitAt + 2);
210
- const providerRoutes = routes.filter(route => route.worker.workerId === worker && route.model.provider === provider);
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: providerStage.result.choice + '/' + selectedModel.model + '@' + selectedReasoning.reasoning,
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,
@@ -1,5 +1,6 @@
1
1
  import { registerBlockerDependency, resolveBlockerDependencies } from './blockers.js';
2
- import { sameTaskDefinition } from './store-task-definition.js';
2
+ import { DurableDefinitionConflictError, isDurableDefinitionConflict, sameTaskDefinition } from './store-task-definition.js';
3
+ import { decideProjectionFailure } from './workflow/projection-conflict-v2.js';
3
4
  function taskKey(task) { return `task:${task.jobId ?? ''}:${task.id}`; }
4
5
  function planKey(plan) { return `plan:${plan.jobId}:${plan.id}`; }
5
6
  function retryHeader(error, name) {
@@ -27,17 +28,60 @@ export function githubProjectionRetryAt(error, attempt, now = new Date()) {
27
28
  const backoffMs = Math.min(300_000, 1_000 * (2 ** Math.min(8, Math.max(0, attempt))));
28
29
  return new Date(now.getTime() + backoffMs);
29
30
  }
31
+ const DEFINITION_CONFLICT_MESSAGE = /already exists with a different definition$/;
32
+ /**
33
+ * Core deterministic-conflict classifier.
34
+ *
35
+ * A structured {@link DurableDefinitionConflictError} is authoritative; a plain error
36
+ * whose message matches the canonical GitHub marker conflict text is classified the
37
+ * same way so remote stores and legacy callers converge on one behavior.
38
+ */
30
39
  export function isTerminalProjectionFailure(item, error) {
40
+ if (item.operation !== 'append_task' && item.operation !== 'append_plan')
41
+ return false;
31
42
  const message = error instanceof Error ? error.message : String(error);
32
- return (item.operation === 'append_task' || item.operation === 'append_plan')
33
- && /already exists with a different definition$/.test(message);
43
+ return isDurableDefinitionConflict(error) || DEFINITION_CONFLICT_MESSAGE.test(message);
44
+ }
45
+ /** Builds durable diagnostic evidence even when a plain error carries no structured fields. */
46
+ function conflictDiagnostic(item, error, detectedAt) {
47
+ const message = error instanceof Error ? error.message : String(error);
48
+ if (error instanceof DurableDefinitionConflictError) {
49
+ return {
50
+ kind: error.kind,
51
+ jobId: error.jobId,
52
+ subjectId: error.subjectId,
53
+ operation: item.operation,
54
+ operationKey: item.operationKey,
55
+ localDefinition: error.local,
56
+ remoteDefinition: error.remote,
57
+ divergingFields: [...error.divergingFields],
58
+ detectedAt,
59
+ message,
60
+ };
61
+ }
62
+ const payload = item.payload;
63
+ const kind = item.operation === 'append_plan' ? 'plan' : 'task';
64
+ return {
65
+ kind,
66
+ jobId: typeof payload?.value?.jobId === 'string' ? payload.value.jobId : '',
67
+ subjectId: typeof payload?.value?.id === 'string' ? payload.value.id : '',
68
+ operation: item.operation,
69
+ operationKey: item.operationKey,
70
+ localDefinition: payload?.value ?? item.payload ?? null,
71
+ remoteDefinition: null,
72
+ divergingFields: [],
73
+ detectedAt,
74
+ message,
75
+ };
34
76
  }
35
77
  export class GitHubProjectionWriter {
36
78
  relay;
37
79
  github;
38
- constructor(relay, github) {
80
+ onConflict;
81
+ constructor(relay, github, onConflict) {
39
82
  this.relay = relay;
40
83
  this.github = github;
84
+ this.onConflict = onConflict;
41
85
  }
42
86
  async flushOnce(limit = 50, now = new Date()) {
43
87
  const items = await this.relay.claimDueProjections(limit, now);
@@ -51,8 +95,13 @@ export class GitHubProjectionWriter {
51
95
  }
52
96
  catch (error) {
53
97
  const message = error instanceof Error ? error.message : String(error);
54
- if (isTerminalProjectionFailure(item, error))
55
- await this.relay.deadLetterProjection(item.id, message);
98
+ const deterministic = isTerminalProjectionFailure(item, error);
99
+ const decision = decideProjectionFailure({ deterministicDefinitionConflict: deterministic });
100
+ if (decision.accepted && decision.target === 'conflict') {
101
+ const diagnostic = conflictDiagnostic(item, error, now.toISOString());
102
+ await this.relay.recordProjectionConflict(item.id, diagnostic);
103
+ await this.notifyConflict(diagnostic, message);
104
+ }
56
105
  else {
57
106
  await this.relay.retryProjection(item.id, message, githubProjectionRetryAt(error, item.attempts, now));
58
107
  deferred += 1;
@@ -61,6 +110,16 @@ export class GitHubProjectionWriter {
61
110
  }
62
111
  return { projected, deferred };
63
112
  }
113
+ async notifyConflict(diagnostic, message) {
114
+ if (!this.onConflict)
115
+ return;
116
+ try {
117
+ await this.onConflict({ type: 'projection.conflict', status: 'conflict', visibility: 'orchestrator', message, data: diagnostic });
118
+ }
119
+ catch (error) {
120
+ process.stderr.write(`warning: projection conflict event unavailable: ${error instanceof Error ? error.message : String(error)}\n`);
121
+ }
122
+ }
64
123
  async apply(item) {
65
124
  const payload = item.payload;
66
125
  switch (payload.operation) {
@@ -106,11 +165,11 @@ export class ProjectedDurableStore {
106
165
  relatedStore;
107
166
  projection;
108
167
  seededFacts;
109
- constructor(relay, github, seed = null, relatedStore) {
168
+ constructor(relay, github, seed = null, relatedStore, onConflict) {
110
169
  this.relay = relay;
111
170
  this.github = github;
112
171
  this.relatedStore = relatedStore;
113
- this.projection = new GitHubProjectionWriter(relay, github);
172
+ this.projection = new GitHubProjectionWriter(relay, github, onConflict);
114
173
  this.seededFacts = pullRequestFactsFromSeed(seed);
115
174
  if (this.seededFacts)
116
175
  void this.relay.savePullRequestFacts(this.seededFacts).catch(() => { });