agents-relay 1.0.103 → 1.0.105

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.
@@ -1,4 +1,12 @@
1
1
  import { chooseWithDecision } from './decision.js';
2
+ import { loadWorkerRouterRules } from './routing-policy.js';
3
+ export const DEFAULT_WORKER_ROUTER_PRINCIPLES = [
4
+ 'Worker Router chooses only the execution worker. Model Router chooses provider/model/profile after worker selection.',
5
+ 'Prefer ChatGPT when both workers can execute the task because it does not consume Codex coding-plan tokens and normally has access to a stronger ChatGPT Sol model.',
6
+ 'Prefer Codex when the task requires native/local execution or computer-use abilities that are unavailable through ChatGPT plus MacBridge.',
7
+ 'Treat coding-plan/provider quota as decision context, not worker legality. Codex may still use another provider selected later by Model Router.',
8
+ 'When Codex/OpenAI coding quota is scarce, prefer ChatGPT unless the Codex execution environment is necessary.',
9
+ ].join('\n');
2
10
  export function usageForProvider(snapshot, provider, now = Date.now()) {
3
11
  const usage = snapshot?.providers.find(item => item.id === provider)?.usage;
4
12
  if (usage?.available) {
@@ -39,15 +47,12 @@ export function workerCandidates(adapters, registered, snapshot) {
39
47
  return adapters.filter(adapter => adapter.name !== 'orchestrator').flatMap(adapter => {
40
48
  const workers = registered.filter(worker => worker.runtime.adapter === adapter.name);
41
49
  return (workers.length > 0 ? workers : [undefined]).map(worker => {
42
- const provider = worker?.runtime.provider ?? (adapter.name === 'chatgpt' ? 'chatgpt' : 'openai');
43
- const resource = usageForProvider(snapshot, provider);
50
+ const provider = worker?.runtime.provider ?? (adapter.name === 'chatgpt' ? 'chatgpt' : undefined);
51
+ const resource = provider ? usageForProvider(snapshot, provider) : {};
44
52
  return {
45
53
  workerId: worker?.id ?? adapter.id,
46
54
  adapter: adapter.name,
47
- capabilities: worker?.capabilities ?? [
48
- ...adapter.capabilities,
49
- ...(adapter.name === 'codex' ? ['local_repo', 'iterative_tools', 'long_running'] : ['web_session', 'attachments', 'interactive_ui', 'one_shot']),
50
- ],
55
+ capabilities: worker?.capabilities ?? [...adapter.capabilities],
51
56
  runtime: worker?.runtime.adapter ?? adapter.name,
52
57
  trustDomains: worker?.routing.trustDomains,
53
58
  region: worker?.routing.regions?.[0],
@@ -64,23 +69,16 @@ export function workerCandidates(adapters, registered, snapshot) {
64
69
  });
65
70
  });
66
71
  }
72
+ /**
73
+ * Worker legality is intentionally narrow. Descriptive capability labels and provider
74
+ * quota are facts for decide(), not hard gates. An explicit runtime is retained as a
75
+ * compatibility/caller-owned constraint, and an explicitly offline worker is illegal.
76
+ */
67
77
  export function satisfiesWorker(candidate, requirements) {
68
- if (candidate.availability && candidate.availability !== 'available')
69
- return false;
70
- if (candidate.resourceHealthy === false)
71
- return false;
72
- if (!requirements.capabilities.every(capability => candidate.capabilities.includes(capability)))
78
+ if (candidate.availability === 'offline')
73
79
  return false;
74
80
  if (requirements.runtime && candidate.runtime !== requirements.runtime)
75
81
  return false;
76
- if (requirements.taskKind && candidate.taskKind !== requirements.taskKind)
77
- return false;
78
- if (requirements.trustDomain && !candidate.trustDomains?.includes(requirements.trustDomain))
79
- return false;
80
- if (requirements.region && candidate.region !== requirements.region)
81
- return false;
82
- if (requirements.labels && Object.entries(requirements.labels).some(([key, value]) => candidate.labels?.[key] !== value))
83
- return false;
84
82
  return true;
85
83
  }
86
84
  export function eligibleWorkerCandidates(candidates, requirements) {
@@ -88,8 +86,8 @@ export function eligibleWorkerCandidates(candidates, requirements) {
88
86
  }
89
87
  function deterministicWorker(eligible) {
90
88
  return [...eligible].map((candidate, index) => ({ candidate, index })).sort((left, right) => {
91
- const pressure = (item) => item.resourceUsedPercent === undefined ? 1 : item.resourceUsedPercent >= 90 ? 2 : 0;
92
- return pressure(left.candidate) - pressure(right.candidate) || left.index - right.index;
89
+ const preference = (item) => item.adapter === 'chatgpt' ? 0 : 1;
90
+ return preference(left.candidate) - preference(right.candidate) || left.index - right.index;
93
91
  })[0].candidate;
94
92
  }
95
93
  function selection(selected, selectedBy, decisionEvidence) {
@@ -113,30 +111,35 @@ function selection(selected, selectedBy, decisionEvidence) {
113
111
  export function selectWorker(candidates, requirements) {
114
112
  const eligible = eligibleWorkerCandidates(candidates, requirements);
115
113
  if (eligible.length === 0)
116
- throw new Error('No eligible worker satisfies the declared requirements');
114
+ throw new Error('No eligible worker satisfies the authoritative worker constraints');
117
115
  const selected = deterministicWorker(eligible);
118
- return { candidate: selected, selection: selection(selected, 'worker-router:requirements') };
116
+ return { candidate: selected, selection: selection(selected, 'worker-router:fallback') };
119
117
  }
120
- /** Gathered facts and hard gates stay in code; Laya only judges among legal choices. */
118
+ /** Gather factual worker/task/resource context, then let decide() choose among legal workers. */
121
119
  export async function routeWorker(candidates, requirements, options = {}) {
122
120
  const eligible = eligibleWorkerCandidates(candidates, requirements);
123
121
  if (eligible.length === 0)
124
- throw new Error('No eligible worker satisfies the declared requirements');
122
+ throw new Error('No eligible worker satisfies the authoritative worker constraints');
125
123
  const deterministic = deterministicWorker(eligible);
126
124
  if (eligible.length === 1) {
127
125
  const evidence = { mode: options.decisionMode ?? 'off', fallback: true };
128
- return { candidate: deterministic, selection: selection(deterministic, 'worker-router:requirements', evidence) };
126
+ return { candidate: deterministic, selection: selection(deterministic, 'worker-router:constraint', evidence) };
129
127
  }
128
+ const rules = await loadWorkerRouterRules(options.rulesPath);
130
129
  const facts = JSON.parse(JSON.stringify({
131
130
  task: options.taskFacts ?? {},
132
- requirements,
131
+ explicit_constraints: { runtime: requirements.runtime ?? null },
132
+ routing_principles: DEFAULT_WORKER_ROUTER_PRINCIPLES,
133
+ environment_rules: rules.text,
134
+ usage_snapshot: options.usage ?? null,
133
135
  candidates: eligible.map(item => ({
134
136
  id: item.workerId,
135
137
  adapter: item.adapter,
136
- capabilities: [...item.capabilities],
137
138
  runtime: item.runtime ?? null,
138
- resourceHealthy: item.resourceHealthy ?? null,
139
+ availability: item.availability ?? null,
140
+ declaredCapabilities: [...item.capabilities],
139
141
  resourceProvider: item.resourceProvider ?? null,
142
+ resourceHealthy: item.resourceHealthy ?? null,
140
143
  resourceUsedPercent: item.resourceUsedPercent ?? null,
141
144
  resourceRemainingPercent: item.resourceRemainingPercent ?? null,
142
145
  resourceResetAt: item.resourceResetAt ?? null,
@@ -144,8 +147,12 @@ export async function routeWorker(candidates, requirements, options = {}) {
144
147
  resourceWindows: item.resourceWindows ?? null,
145
148
  })),
146
149
  }));
147
- const choice = await chooseWithDecision(options.decisionEngine, options.decisionMode ?? 'off', { facts }, { type: 'choice', question: 'Which eligible worker is best suited to execute this task?', choices: eligible.map(item => item.workerId) }, () => deterministic.workerId);
150
+ const choice = await chooseWithDecision(options.decisionEngine, options.decisionMode ?? 'off', { facts }, {
151
+ type: 'choice',
152
+ question: 'Choose the best execution worker for this task. Use the task description, current usage, routing principles, and environment rules. Prefer ChatGPT when both can do the work; choose Codex when native/local execution is necessary. Do not choose a provider or model here.',
153
+ choices: eligible.map(item => item.workerId),
154
+ }, () => deterministic.workerId);
148
155
  const selected = eligible.find(item => item.workerId === choice.choice) ?? deterministic;
149
- const selectedBy = choice.evidence.engine && !choice.evidence.fallback ? 'worker-router:laya' : 'worker-router:requirements';
156
+ const selectedBy = choice.evidence.engine && !choice.evidence.fallback ? 'worker-router:laya' : 'worker-router:fallback';
150
157
  return { candidate: selected, selection: selection(selected, selectedBy, choice.evidence) };
151
158
  }
@@ -12,9 +12,6 @@ export const WORKFLOW_EVENT_NAMES = [
12
12
  'pr.closed',
13
13
  'pr.merged',
14
14
  'reconcile',
15
- 'reconcile.blocked',
16
- 'reconcile.completed',
17
- 'reconcile.started',
18
15
  'release',
19
16
  'retry',
20
17
  'review.approved',
@@ -23,6 +20,9 @@ export const WORKFLOW_EVENT_NAMES = [
23
20
  'task.completed',
24
21
  'task.failed',
25
22
  'task.started',
23
+ 'watchdog.failed',
24
+ 'worker.delivery_failed',
25
+ 'worker.start_ack_failed',
26
26
  ];
27
27
  export const WORKFLOW_FACT_NAMES = [
28
28
  'dependenciesSatisfied',
@@ -31,6 +31,7 @@ export const WORKFLOW_FACT_NAMES = [
31
31
  'dispatchable',
32
32
  'notMerged',
33
33
  'autoMerge',
34
+ 'recoverableExecution',
34
35
  ];
35
36
  export const WORKFLOW_MUTATION_NAMES = [
36
37
  'mutation.clearError',
@@ -33,4 +33,5 @@ export const taskLifecycleWorkflow = load('task-lifecycle', {
33
33
  dependenciesUnsatisfied: booleanGuard('dependenciesUnsatisfied'),
34
34
  runnable: booleanGuard('runnable'),
35
35
  dispatchable: booleanGuard('dispatchable'),
36
+ recoverableExecution: booleanGuard('recoverableExecution'),
36
37
  });
@@ -0,0 +1,45 @@
1
+ import { tryTransitionTaskEvent } from '../scheduler.js';
2
+ /**
3
+ * Runtime primitive for applying one canonical Task-graph event to fresh durable state.
4
+ *
5
+ * This class owns serialization and persistence only. It does not decide lifecycle
6
+ * legality: the canonical Mermaid Task graph does that through tryTransitionTaskEvent.
7
+ */
8
+ export class DurableTaskTransitionRuntime {
9
+ store;
10
+ locks = new Map();
11
+ constructor(store) {
12
+ this.store = store;
13
+ }
14
+ async apply(jobId, taskId, event, options = {}) {
15
+ return this.withLock(`${jobId}/${taskId}`, async () => {
16
+ const job = await this.store.load(jobId);
17
+ const task = job.tasks.find(item => item.id === taskId);
18
+ if (!task || (options.eligible && !options.eligible(job, task)))
19
+ return null;
20
+ const from = task.state;
21
+ const decision = tryTransitionTaskEvent(task, event, options.facts?.(job, task) ?? {});
22
+ if (!decision.accepted || !decision.target)
23
+ return { job, task, from, decision };
24
+ await options.mutate?.(job, task, decision);
25
+ await this.store.saveTask(task);
26
+ return { job, task, from, decision };
27
+ });
28
+ }
29
+ async withLock(key, work) {
30
+ const previous = this.locks.get(key);
31
+ let release;
32
+ const current = new Promise(resolve => { release = resolve; });
33
+ this.locks.set(key, current);
34
+ if (previous)
35
+ await previous;
36
+ try {
37
+ return await work();
38
+ }
39
+ finally {
40
+ release();
41
+ if (this.locks.get(key) === current)
42
+ this.locks.delete(key);
43
+ }
44
+ }
45
+ }
@@ -37,3 +37,10 @@ export function applyJobWorkflowDecision(job, decision, timestamp = new Date().t
37
37
  job.state = decision.target;
38
38
  job.updatedAt = timestamp;
39
39
  }
40
+ /** Historical repair for records that stored derived runtime status as Job lifecycle. */
41
+ export function applyHistoricalJobCompatibilityRepair(job, target, timestamp = new Date().toISOString()) {
42
+ if (target !== 'COMPLETED' && target !== 'CANCELLED')
43
+ throw new Error(`Invalid historical Job repair target ${target}`);
44
+ job.state = target;
45
+ job.updatedAt = timestamp;
46
+ }
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "agents-relay",
3
- "version": "1.0.103",
3
+ "version": "1.0.105",
4
4
  "description": "Durable async agent jobs coordinated through GitHub pull requests",
5
5
  "type": "module",
6
6
  "bin": {
@@ -0,0 +1,24 @@
1
+ ---
2
+ name: implementation
3
+ description: Implement the assigned Agents Relay task in the managed checkout and verify the durable result.
4
+ capabilities: [implementation, local_repo, iterative_tools]
5
+ runtime: codex
6
+ delegation: [relay-child-task]
7
+ restrictions: [preserve-user-changes, no-unrelated-refactors]
8
+ ---
9
+
10
+ # Implementation Agent
11
+
12
+ Implement the assigned Task in the current managed checkout. Treat the durable
13
+ Job objective and Task input retrieved from GitHub as authoritative. Inspect the
14
+ existing design before editing, keep the change narrowly scoped, and preserve
15
+ unrelated user work.
16
+
17
+ Follow the shared Agents Relay and events-bus contracts. Publish the required
18
+ canonical `task.started` event before doing work, then exactly one terminal task
19
+ event after the declared output is durable. Use the exact execution source
20
+ identity supplied by Relay for every lifecycle event.
21
+
22
+ Run the most relevant tests and checks for the change. Do not claim completion
23
+ without durable implementation evidence, validation results, and a concise
24
+ summary of changed files and remaining risks.
@@ -58,6 +58,8 @@ Do not create separate Tasks merely for "analyze", "format", "call tool", "check
58
58
  ## Rules
59
59
 
60
60
  - Treat durable Job/Task state as authoritative.
61
+ - Discover visible agents from the resolved execution context before selecting an agent. Choose an existing visible agent only when it is appropriate for the Task; otherwise leave `agentName` unset. Agent discovery is runtime/context-specific, not a hardcoded global list.
62
+ - Build `agentGraph` nodes only from agents in the discovered visible-agent catalog. Every named graph agent must be visible in the resolved planning/execution context.
61
63
  - Never invent completed work or evidence.
62
64
  - First decide whether the objective is already satisfied.
63
65
  - If satisfied, return `objective_status: "satisfied"` and `next_tasks: []`.
@@ -66,8 +68,8 @@ Do not create separate Tasks merely for "analyze", "format", "call tool", "check
66
68
  - Prefer independently verifiable Tasks with stable IDs, but do not split work solely to make Tasks smaller.
67
69
  - A Task describes what an agent must accomplish; never emit a shell command as a worker Task.
68
70
  - Use dependencies only when execution order actually matters.
69
- - Every child Task must declare `workerRequirements`; worker-router selects the worker and model-router selects provider/model/reasoning afterward.
70
- - Do not choose `codex`/`chatgpt`, provider/model, or usage probes.
71
+ - Describe each child Task precisely enough for worker-router to decide from the Task itself. Do not invent Codex/ChatGPT capability labels. workerRequirements is optional and should normally be omitted; use an explicit runtime only when the objective already requires that runtime.
72
+ - Do not choose codex/chatgpt, provider/model, or usage probes.
71
73
  - Every child Task must declare exactly one output: `task_pr` or `file`.
72
74
  - Every child Task must include `storySize` (`S`, `M`, or `L`); use `M` when uncertain.
73
75
  - Do not create bookkeeping, waiting, status-checking, wrapper, or planning Tasks. Your response itself is the planning round.
@@ -84,4 +86,4 @@ For `objective_status: "in_progress"`, `assessment` must be Markdown containing:
84
86
 
85
87
  For `objective_status: "satisfied"`, `assessment` may be a concise evidence-based completion assessment without a graph.
86
88
 
87
- Each item in `next_tasks` contains `id`, `input`, `workerRequirements`, `output`, and `storySize`. It may also contain priority, projectName, agentName, dependencies, capabilities, maxAttempts, and timeoutMs. Adapter/routing fields are selected by Relay after planning; do not invent them.
89
+ Each item in next_tasks contains id, input, output, and storySize. It may also contain priority, projectName, agentName, dependencies, capabilities, workerRequirements, maxAttempts, and timeoutMs. Adapter/routing fields are selected by Relay after planning; do not invent them.
@@ -54,13 +54,14 @@ Keep these concepts distinct: observation is not root cause; hypothesis is not r
54
54
 
55
55
  Every new model-backed executable task follows this order:
56
56
 
57
- 1. The planner declares `workerRequirements` (capabilities and optional runtime, task-kind, trust-domain, region, and label constraints).
58
- 2. `worker-router` is code-driven: it gathers authoritative worker/resource facts, applies capability/availability and other hard gates, then may call the generic typed `decide(request, contract)` primitive only among eligible workers.
59
- 3. `model-router` runs after worker selection, discovers model/profile facts usable by that worker, applies model hard gates, then may use the same typed `decide()` primitive among eligible profiles. Router judgment is not a free-form agent invocation.
60
- 4. `decide()` owns no routing facts or legality policy. Its optional Laya implementation is a transient CLI subprocess per decision; unavailable/invalid decisions fall back to deterministic routing. `src/usage.ts` remains the shared resource-fact source.
61
- 5. Relay persists the selected worker and model routing on the durable task before launch.
57
+ 1. The planner describes the executable Task precisely. It does not invent a capability matrix for Codex versus ChatGPT. workerRequirements is optional compatibility metadata; an explicit runtime may be used only when the caller already knows the Task must use that runtime.
58
+ 2. worker-router gathers factual Task context, discovered worker availability, the current shared usage snapshot, built-in routing principles, and optional local free-text rules. Only authoritative legality facts such as an explicitly offline worker or an explicit caller-owned runtime constraint are hard gates. Descriptive capability labels, region/trust labels, and provider quota are decision facts rather than ordinary worker hard gates.
59
+ 3. When more than one legal model-backed worker exists, worker-router calls the generic typed decide(request, contract) primitive to choose the execution worker. Its default principles prefer ChatGPT when both workers can execute the Task, prefer Codex when native/local execution is necessary, and treat Codex/OpenAI quota pressure as a preference rather than proof that the Codex worker is unusable.
60
+ 4. model-router runs only after worker selection and independently chooses provider/model/profile for that worker. In particular, an exhausted OpenAI quota must not eliminate the Codex worker before Model Router has a chance to choose another Codex provider/profile.
61
+ 5. decide() owns no routing facts or legality policy. Its optional Laya implementation is a transient CLI subprocess per decision; unavailable/invalid decisions fall back deterministically. src/usage.ts remains the shared resource-fact source.
62
+ 6. Relay persists the selected worker and model routing on the durable task before launch.
62
63
 
63
- Planner output must not hard-code `codex`/`chatgpt`, provider preference, or usage probes. The worker-router and model-router are separate responsibilities; usage readers remain owned by `src/usage.ts` and are not duplicated in either router. Legacy task records that already contain adapter/routing metadata remain readable, but newly produced planner tasks must carry worker requirements.
64
+ Planner output must not hard-code codex/chatgpt, provider preference, inferred worker capabilities, or usage probes. Worker Router and Model Router are separate responsibilities. Legacy task records that already contain adapter/routing/worker-requirement metadata remain readable.
64
65
 
65
66
  ## Story size
66
67
 
@@ -131,7 +132,7 @@ Job runtime status is derived, not persisted. Relay computes it from lifecycle p
131
132
 
132
133
  A normal task never merges a PR. After all non-superseded work gates pass, Relay creates a SHA-bound final review task. The reviewer emits exactly one of `review.approved`, `review.changes_required`, or `review.blocked`. When `autoMerge` is enabled (default), Relay merges only after `review.approved`, pinned to the exact reviewed head SHA. A new task or new PR head makes earlier final-review evidence stale and requires a fresh review.
133
134
 
134
- Final-review routing follows the same worker-selection boundary as executable work. Capability, availability, explicit reviewer constraints, and resource health are hard gates. Among eligible workers, Relay prefers a reviewer with a different worker identity from the implementation tasks; when that is unavailable it prefers a different provider/model, then falls back to the first eligible worker. The selected worker, `selectedBy` reason, and `reviewerRouting` event data are durable evidence. No provider is a universal reviewer default.
135
+ Final-review routing follows the same factual worker-selection boundary as executable work. Explicitly offline workers and explicit reviewer runtime constraints are hard gates; provider quota and descriptive capability labels do not by themselves make a worker illegal. Among eligible workers, Relay prefers a reviewer with a different worker identity from the implementation tasks; when that is unavailable it prefers a different provider/model, then falls back to the first eligible worker. The selected worker, selectedBy reason, and reviewerRouting event data are durable evidence. No provider is a universal reviewer default.
135
136
 
136
137
  ## Job, Task, and planner ownership
137
138
 
@@ -14,20 +14,26 @@ stateDiagram-v2
14
14
  QUEUED --> BLOCKED: block
15
15
  QUEUED --> FAILED: fail
16
16
  QUEUED --> FAILED: effect.launchTask.failed
17
+ QUEUED --> FAILED: watchdog.failed
18
+ QUEUED --> FAILED: worker.delivery_failed
19
+ QUEUED --> FAILED: worker.start_ack_failed
17
20
  QUEUED --> CANCELLED: cancel
18
21
 
19
22
  RUNNING --> BLOCKED: task.blocked
20
23
  RUNNING --> SUCCEEDED: task.completed / mutation.clearError,mutation.clearFailureReason
21
24
  RUNNING --> FAILED: task.failed
25
+ RUNNING --> FAILED: watchdog.failed
22
26
  RUNNING --> CANCELLED: task.cancelled
23
27
 
24
28
  BLOCKED --> QUEUED: retry / mutation.clearError,mutation.clearFailureReason
25
29
  BLOCKED --> CANCELLED: cancel
26
30
 
27
31
  FAILED --> QUEUED: retry / mutation.clearError,mutation.clearFailureReason
28
- FAILED --> RUNNING: reconcile.started / mutation.clearError,mutation.clearFailureReason
29
- FAILED --> SUCCEEDED: reconcile.completed / mutation.clearError,mutation.clearFailureReason
30
- FAILED --> BLOCKED: reconcile.blocked
32
+ FAILED --> RUNNING: task.started [recoverableExecution] / mutation.clearError,mutation.clearFailureReason
33
+ FAILED --> SUCCEEDED: task.completed [recoverableExecution] / mutation.clearError,mutation.clearFailureReason
34
+ FAILED --> BLOCKED: task.blocked [recoverableExecution]
35
+ FAILED --> FAILED: task.failed [recoverableExecution]
36
+ FAILED --> CANCELLED: task.cancelled [recoverableExecution]
31
37
  FAILED --> CANCELLED: cancel
32
38
 
33
39
  SUCCEEDED --> [*]