agents-relay 1.0.172 → 1.0.174

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/dist/planner.js CHANGED
@@ -5,6 +5,15 @@ import { dirname, join } from 'node:path';
5
5
  import { storySizeTimeoutMs, taskOverallTimeoutMs } from './types.js';
6
6
  import { executionContextPrompt } from './execution-context.js';
7
7
  import { parseAgentGraph } from './agent-graph.js';
8
+ /** Business-task ownership only; planner rounds remain provenance records. */
9
+ export function semanticTaskTree(tasks) {
10
+ const work = tasks.filter(task => task.kind !== 'planner');
11
+ const ids = new Set(work.map(task => task.id));
12
+ return {
13
+ roots: work.filter(task => !task.parentTaskId || !ids.has(task.parentTaskId)).map(task => task.id),
14
+ tasks: work.map(task => ({ id: task.id, parent_task_id: task.parentTaskId, state: task.state, dependencies: [...task.dependencies], planning_decision_id: task.planningDecisionId, planning_round: task.planningRound, review: task.review })),
15
+ };
16
+ }
8
17
  function plannerTrigger(job, manual = false) {
9
18
  if (manual)
10
19
  return { source: 'manual' };
@@ -329,6 +338,7 @@ export function plannerInput(context) {
329
338
  previous_planner_task_id: context.previousPlannerTaskId,
330
339
  plan_trigger: context.trigger,
331
340
  legacy_change_cycle: context.job.changeCycle,
341
+ semantic_task_tree: semanticTaskTree(context.tasks),
332
342
  project: project ? { name: project.name, path: project.path } : undefined,
333
343
  visible_agents: visibleAgents ?? [],
334
344
  tasks: context.tasks.map(task => ({
package/dist/pool.js CHANGED
@@ -176,7 +176,18 @@ export class RepositoryWorkerPool {
176
176
  capacity -= allowance;
177
177
  }
178
178
  }
179
- async reconcileKnown(discovered) { await this.reconcileDiscovered(discovered); }
179
+ async reconcileKnown(discovered) {
180
+ for (const item of discovered.filter(value => value.githubState === 'OPEN' && value.job.state === 'ACTIVE')) {
181
+ this.jobTargets.set(item.job.id, { repository: item.job.repository, prNumber: item.job.prNumber });
182
+ const store = new GitHubStore(this.client, item.job.repository, item.job.prNumber, this.trustedAuthors);
183
+ const reconciler = this.makeReconciler(store, 0);
184
+ if (typeof reconciler.recoverStartupOrphans === 'function')
185
+ await this.reconcileExclusive(item.job.id, async () => {
186
+ await withGitHubRequestContext(this.client, 'pool.startup-orphan-recovery', () => reconciler.recoverStartupOrphans(item.job.id));
187
+ });
188
+ }
189
+ await this.reconcileDiscovered(discovered);
190
+ }
180
191
  async reconcile() {
181
192
  if (this.isRateLimited())
182
193
  return;
@@ -214,6 +214,8 @@ export class Reconciler extends ReconcilerReviewBase {
214
214
  this.reconciling = false;
215
215
  }
216
216
  }
217
+ async recoverStartupOrphans(jobId) { const job = await this.store.load(jobId); if (job.state === 'ACTIVE' && (await this.store.pullRequestState?.() ?? 'OPEN') === 'OPEN')
218
+ await this.recoverOrphanedLocalExecutions(job); return this.store.load(jobId); }
217
219
  async cancelActiveTasks(jobId, reason) {
218
220
  const job = await this.store.load(jobId);
219
221
  for (const task of job.tasks.filter(item => !['SUCCEEDED', 'FAILED', 'CANCELLED'].includes(item.state))) {
@@ -262,6 +264,26 @@ export class Reconciler extends ReconcilerReviewBase {
262
264
  await this.emit(eventFor(job.id, task.id, task.parentTaskId, 'task.failed', 'failed', task.error, 'user', { reason: 'lease_expired' }));
263
265
  }
264
266
  }
267
+ async recoverOrphanedLocalExecutions(job) {
268
+ for (const stale of job.tasks.filter(t => t.state === 'RUNNING' && t.executionId && t.leaseOwner && t.leaseOwner !== this.options.owner && t.adapter !== 'chatgpt')) {
269
+ const task = (await this.store.load(job.id)).tasks.find(t => t.id === stale.id);
270
+ if (!task || task.state !== 'RUNNING' || task.executionId !== stale.executionId || task.leaseOwner !== stale.leaseOwner || task.leaseExpiresAt !== stale.leaseExpiresAt || task.adapter === 'chatgpt' || task.leaseOwner === this.options.owner)
271
+ continue;
272
+ await transitionTaskEvent(task, 'task.failed');
273
+ task.error = 'Orphaned after relayd restart';
274
+ task.leaseOwner = null;
275
+ task.leaseExpiresAt = null;
276
+ task.executionId = null;
277
+ if (task.attempt < task.maxAttempts) {
278
+ await transitionTaskEvent(task, 'retry');
279
+ await this.store.saveTask(task);
280
+ continue;
281
+ }
282
+ task.error += ' after maximum attempts';
283
+ await this.store.saveTask(task);
284
+ await this.emit(eventFor(job.id, task.id, task.parentTaskId, 'task.failed', 'failed', task.error, 'user', { reason: 'startup_orphaned' }));
285
+ }
286
+ }
265
287
  async planJob(jobId, guidance) {
266
288
  return executeManualPlan(this.store, jobId, guidance, this.options.plannerTimeoutMs ?? storySizeTimeoutMs('M'), (job, task) => this.launchPlanner(job, task));
267
289
  }
package/dist/relayd.js CHANGED
@@ -120,7 +120,7 @@ export async function runDaemon(argv) {
120
120
  cachedOverviews = parsed;
121
121
  }
122
122
  catch { /* first run or invalid cache */ }
123
- const refreshAll = async (full = false) => {
123
+ const refreshAll = async (full = false, failOnError = false) => {
124
124
  if (client.rateLimitStatus().limited)
125
125
  return cachedOverviews;
126
126
  const scanRepositories = full ? repositories : recoveryScanRepositories(repositories, cachedOverviews, dormantProbeIndex++);
@@ -139,6 +139,8 @@ export async function runDaemon(argv) {
139
139
  return cachedOverviews;
140
140
  }
141
141
  catch (error) {
142
+ if (failOnError)
143
+ throw error;
142
144
  process.stderr.write(`warning: workspace job refresh unavailable: ${error instanceof Error ? error.message : String(error)}\n`);
143
145
  return cachedOverviews;
144
146
  }
@@ -275,7 +277,6 @@ export async function runDaemon(argv) {
275
277
  taskCancel: workspaceTaskAction('cancel'),
276
278
  taskSetState: workspaceTaskSetState,
277
279
  });
278
- await once(server, 'listening');
279
280
  const reconcileSafely = async () => {
280
281
  if (client.rateLimitStatus().limited)
281
282
  return;
@@ -301,17 +302,23 @@ export async function runDaemon(argv) {
301
302
  }
302
303
  };
303
304
  const watchdogDefault = webhook ? SERVICE_DEFAULTS.webhookWatchdogMs : SERVICE_DEFAULTS.watchdogMs;
304
- const interval = setInterval(() => void reconcileSafely(), Number(value(argv, '--interval', String(watchdogDefault))));
305
- interval.unref();
306
- const scheduleInterval = setInterval(() => void scheduleHeartbeatSafely(), SCHEDULE_HEARTBEAT_MS);
307
- scheduleInterval.unref();
308
- const stop = () => { clearInterval(interval); clearInterval(scheduleInterval); void stopWatch().finally(() => server.close(() => process.exit(0))); };
305
+ let interval;
306
+ let scheduleInterval;
307
+ const stop = () => { if (interval)
308
+ clearInterval(interval); if (scheduleInterval)
309
+ clearInterval(scheduleInterval); void stopWatch().finally(() => server.close(() => process.exit(0))); };
309
310
  process.once('SIGINT', stop);
310
311
  process.once('SIGTERM', stop);
312
+ await once(server, 'listening');
313
+ const startupItems = await refreshAll(true, true); // void refreshAll(true)
314
+ await pool.reconcileKnown(startupItems);
315
+ // Startup recovery must finish before normal polling or scheduled work can run.
316
+ await scheduleHeartbeatSafely();
317
+ interval = setInterval(() => void reconcileSafely(), Number(value(argv, '--interval', String(watchdogDefault))));
318
+ interval.unref();
319
+ scheduleInterval = setInterval(() => void scheduleHeartbeatSafely(), SCHEDULE_HEARTBEAT_MS);
320
+ scheduleInterval.unref();
311
321
  console.log('Dashboard listening; repository worker pool active');
312
- void refreshAll(true)
313
- .then(async (items) => { await scheduleHeartbeatSafely(); await pool.reconcileKnown(items); })
314
- .catch(error => process.stderr.write(`warning: startup reconciliation unavailable: ${error instanceof Error ? error.message : String(error)}\n`));
315
322
  }
316
323
  if (isEntrypoint(import.meta.url))
317
324
  runDaemon(process.argv.slice(2)).catch(error => { console.error(error instanceof Error ? error.message : String(error)); process.exitCode = 1; });
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "agents-relay",
3
- "version": "1.0.172",
3
+ "version": "1.0.174",
4
4
  "description": "Durable async agent jobs coordinated through GitHub pull requests",
5
5
  "type": "module",
6
6
  "bin": {
@@ -101,7 +101,7 @@ Direct orchestrator-created Tasks do not require a Plan. The orchestrator may cr
101
101
 
102
102
  For explicit `plan` and autonomous continuation, Relay invokes the same planner agent as a pure decision function. The planner receives durable Job/Task state, previous planner rounds, and resolved execution context, then returns structured `{ objective_status, assessment, next_tasks }` output. The planner does not call Relay CLI and does not persist Tasks itself.
103
103
 
104
- Each successful planner invocation is one real `plan-N` planning round. Relay persists that planner result, then creates the returned Tasks with lineage back to that planning round. Historical `planner-N` records remain readable. Only planner execution creates planner rounds; Relay never manufactures synthetic `plan-*` records for direct Task creation. In the dashboard, planner rounds are organizational/grouping nodes for the Tasks they produced.
104
+ Each successful planner invocation is one real `plan-N` planning round. Relay persists that planner result, then creates returned work Tasks with provenance back to that planning round. `parentTaskId` expresses semantic ownership among work Tasks; it is not inherited from the Plan and must never point to a `plan-N` record. `dependencies` independently express execution prerequisites. Historical `planner-N` records remain readable. Only planner execution creates planner rounds; Relay never manufactures synthetic `plan-*` records for direct Task creation. In the dashboard, planner rounds remain provenance/grouping metadata, not semantic parents.
105
105
 
106
106
  A zero-task autonomous Job remains idle. After a direct orchestrator-created initial batch completes, Relay may start the first real planner round. Fixed Jobs do not automatically create planner rounds.
107
107
 
@@ -34,6 +34,17 @@ Keep sibling Tasks at a comparable abstraction level. Do not mix a whole product
34
34
 
35
35
  The graph is the explanation for the decomposition: every emitted Task should map to a meaningful boundary in the graph, and important graph boundaries should not be omitted accidentally.
36
36
 
37
+ ### Semantic ownership vs execution flow
38
+
39
+ The planning graph and the durable Task tree are two different views:
40
+
41
+ - The graph and `dependencies` express execution flow and prerequisites.
42
+ - `parentTaskId` expresses semantic ownership of an artifact or milestone.
43
+ - A later planning round must inspect the existing Task tree and attach work to the existing artifact it changes. Do not turn a later round into a new root merely because its work follows an earlier Task.
44
+ - `plan-N` records are planning provenance, not business milestones, and must never be used as `parentTaskId`.
45
+
46
+ For example, produce `architecture-v2` as a root milestone, then place `architecture-review`, `architecture-revision-1`, and `architecture-rereview-1` under it. The revision may depend on the review, and the re-review may depend on the revision; those dependencies do not determine their semantic parent. An independent `graph-runtime-v2` milestone remains a root sibling. A troubleshooter belongs under the failed Task (or its artifact subtree), and specialist review work stays under its review/artifact context.
47
+
37
48
  ### Examples
38
49
 
39
50
  Prefer:
@@ -69,6 +80,7 @@ Do not create separate Tasks merely for "analyze", "format", "call tool", "check
69
80
  - When a Task depends on mutable repository or product state, distinguish a frozen evidence target from the current execution head. A SHA may freeze that Task's own review/source evidence, but do not copy an upstream SHA into a dependent release, deploy, publication, or other follow-on Task unless the objective explicitly requires that same frozen target. After dependencies complete, downstream Tasks must resolve and verify the current durable Job/PR head before acting.
70
81
  - When fresh UI, browser, store, runtime, or visual evidence is material to the Task outcome, say so explicitly in the Task input and require evidence from the real current state. Existing screenshots, prepared assets, or prior observations are not substitutes for fresh evidence unless the objective explicitly allows reuse.
71
82
  - When a Job needs multi-perspective review, use the visible `review-chair` agent or an Agent Graph to select specialist roles dynamically. Keep specialist Tasks as independent siblings against the same immutable target/version, and leave worker/model/provider routing to Relay.
83
+ - Treat `parentTaskId` as semantic ownership, not execution order. Select an existing non-planner Task as the parent when a later-round revision, re-review, specialist review, or troubleshooting Task belongs to that artifact/milestone; a terminal parent remains valid historical ownership. Use `dependencies` independently for prerequisites, including cross-parent dependencies. Never use a `plan-N` Task as a parent, and never invent a synthetic parent merely to group a planning round.
72
84
  - A Task describes what an agent must accomplish; never emit a shell command as a worker Task.
73
85
  - Use dependencies only for Task IDs inside the current Job, and only when execution order actually matters. Never encode cross-Job or cross-repository dependencies in `dependencies`.
74
86
  - When the current Job is a prerequisite for another durable Job/Task, emit that dependent in top-level `unblocks`. The current blocker Job owns this authoritative reverse relation. Dependent-side `blockedBy` is optional observability metadata and is not the source of truth. Use `resolutionMode: "merge"` when blocker PR merge is sufficient; use `"verified"` when publication, deployment, runtime health, human approval, or another post-merge fact must be verified.
@@ -22,6 +22,34 @@ For a newly created PR (`job create --head`), standalone objective Markdown is r
22
22
  Any task producer — orchestrator, planner, or nested task-producing agent — must follow this contract.
23
23
  Task producers choose semantic task properties such as objective, dependencies, worker requirements, priority, and `storySize`. They do not normally choose an adapter, provider/model, or raw timeout milliseconds.
24
24
 
25
+ ### Planning graph and semantic Task tree
26
+
27
+ The planning graph expresses execution flow. The Task tree expresses semantic
28
+ ownership. `dependencies` answer which durable outcomes must succeed before a
29
+ Task can run; `parentTaskId` answers which durable artifact, milestone, or
30
+ parent outcome the Task belongs to. These are independent relationships and
31
+ must not be inferred from one another.
32
+
33
+ For a multi-round artifact workflow, keep the business tree compact and
34
+ milestone-oriented:
35
+
36
+ ```text
37
+ Job
38
+ ├── Architecture v2
39
+ │ ├── Review
40
+ │ ├── Revision 1
41
+ │ └── Re-review 1
42
+ └── Graph Runtime v2
43
+ ```
44
+
45
+ The revision may depend on Review and Re-review may depend on Revision 1, but
46
+ both remain semantically owned by Architecture v2. A later Planner round must
47
+ inspect existing Tasks and attach review, change, validation-rerun, release
48
+ correction, or recovery work to the existing artifact subtree. An independent
49
+ milestone is a root sibling. `plan-N` records are planning provenance/history,
50
+ never semantic parents. Existing terminal Tasks are historical evidence and
51
+ must not be rewritten merely to improve the tree.
52
+
25
53
  ### Task input vs result
26
54
 
27
55
  A Task input records **what is known and what has already been decided before execution**. A Task result records **what execution discovered and what was actually done**.
@@ -98,6 +126,33 @@ For the ChatGPT Browser Worker guardian, for example, the Job defines `pass ->
98
126
 
99
127
  ### Optional Review Chair Tasks
100
128
 
129
+ ### Semantic Task tree and planning provenance
130
+
131
+ The planning graph, semantic Task tree, and dependency graph are separate:
132
+
133
+ ```text
134
+ plan-2 (planning provenance only)
135
+ ├─ revision (parentTaskId: milestone, dependencies: [review])
136
+ └─ re-review (parentTaskId: milestone, dependencies: [revision])
137
+ independent-milestone (parentTaskId: null, dependencies: [revision])
138
+ ```
139
+
140
+ `parentTaskId` answers which durable artifact or milestone owns this work. It
141
+ may point to an existing terminal work Task when a later planning round
142
+ continues that artifact, or to another work Task in the same batch. It must
143
+ never point to a `plan-N`/historical `planner-N` record and must not be used
144
+ just to group a round. `dependencies` answer what must finish first and may
145
+ cross semantic subtrees. Reviews, changes-required revisions, re-reviews,
146
+ specialist reviews, and troubleshooter recovery belong under the relevant
147
+ artifact/milestone; independent milestones remain roots. Planning provenance
148
+ is retained separately in `planningDecisionId` and `planningRound`, and
149
+ historical terminal Tasks are immutable.
150
+
151
+ Incorrect: make every generated Task a child of the current `plan-N`, or use
152
+ `parentTaskId` as a substitute for a dependency. Correct: attach a re-review
153
+ to the original milestone, set its dependency to the revision Task, and leave
154
+ the original milestone's terminal record unchanged.
155
+
101
156
  Projects may use the shared `review-chair` agent as a normal Task or Agent
102
157
  Graph node. The chair discovers specialist agent roles from the resolved
103
158
  visible-agent catalog, records selection reasons, and creates sibling durable