agents-relay 2.0.80 → 2.0.82

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.
Files changed (41) hide show
  1. package/README.md +3 -3
  2. package/dist/cli-help.js +5 -8
  3. package/dist/cli-review.js +1 -1
  4. package/dist/cli-support.js +2 -22
  5. package/dist/cli-worker-task.js +7 -6
  6. package/dist/cli.js +22 -59
  7. package/dist/dashboard-actions.js +2 -1
  8. package/dist/dashboard-html.js +5 -5
  9. package/dist/dashboard.js +0 -4
  10. package/dist/github-binding-order.js +1 -1
  11. package/dist/job-pr-number-migration.js +24 -0
  12. package/dist/job-task-id-sequence.js +16 -9
  13. package/dist/legacy-orchestrator-repair.js +51 -0
  14. package/dist/local-job-registry.js +2 -3
  15. package/dist/local-merge-delivery.js +1 -1
  16. package/dist/managed-worker-input.js +3 -4
  17. package/dist/planner.js +14 -8
  18. package/dist/pool.js +3 -1
  19. package/dist/reconciler-review.js +7 -4
  20. package/dist/reconciler-runtime.js +7 -0
  21. package/dist/reconciler.js +2 -8
  22. package/dist/relay-store.js +6 -4
  23. package/dist/relayd-control.js +10 -0
  24. package/dist/relayd-dashboard-actions.js +3 -33
  25. package/dist/relayd.js +17 -20
  26. package/dist/review-generation-fence.js +8 -0
  27. package/dist/review-terminal-normalization.js +27 -0
  28. package/dist/reviewer-thread.js +1 -1
  29. package/dist/runtime-status.js +2 -1
  30. package/dist/task-actions.js +47 -42
  31. package/dist/task-title.js +5 -0
  32. package/dist/troubleshooter.js +2 -2
  33. package/dist/worker-routing.js +1 -1
  34. package/dist/workflow/entity-creation.js +11 -1
  35. package/dist/workflow/orchestrator-rescue.js +62 -18
  36. package/dist/workflow/troubleshooter-v2.js +1 -1
  37. package/dist/workspace.js +5 -4
  38. package/package.json +1 -1
  39. package/skills/agents-relay/contracts/orchestrator-workflows.md +13 -12
  40. package/workflow/orchestrator-rescue.mmd +16 -1
  41. package/workflow/troubleshooter.mmd +4 -4
package/README.md CHANGED
@@ -266,11 +266,11 @@ npx agents-relay task create --repo OWNER/REPO --pr 5 --id job-1 \
266
266
 
267
267
  GitHub PR state is authoritative for terminal lifecycle: a merged PR reconciles its managed job to `COMPLETED`; a closed, unmerged PR reconciles to `CANCELLED` and cannot launch queued work. The dashboard exposes `/api/jobs` and shows PR state, durable Job lifecycle, and derived runtime status separately. A merged PR reconciles lifecycle to `COMPLETED`; a closed, unmerged PR reconciles lifecycle to `CANCELLED`.
268
268
 
269
- `adapter: orchestrator` is reserved for synchronous self-work by the exact orchestrator turn that creates the Task. Relay deliberately does not auto-start these Tasks during reconciliation. The creator must perform the work before ending the turn and terminalize the same Task with `npx agents-relay task complete --task-id <id> --summary <summary> ...`. If work should continue asynchronously or in a later turn, use a real worker adapter instead.
269
+ Relay has no orchestrator worker adapter for new work. Orchestrators are external clients that create/plan durable Tasks; Execution Router selects a real worker route (`codex` or `chatgpt`). The CLI and dashboard do not expose manual orchestrator-only `task start`, `task complete`, or synthetic `record` paths.
270
270
 
271
- As a narrow recovery exception, reconciliation converts a `QUEUED` work Task that still has `adapter: orchestrator`, no execution identity, and is at least 30 minutes past `createdAt` into a normal ChatGPT-routable Task. This preserves the Task identity, intent, dependencies, output, lineage, priority, and story size while clearing stale routing state. `RUNNING` and terminal Tasks are never converted; the 30-minute rescue does not weaken the synchronous self-task contract for newly created work.
271
+ Historical stores may still contain `adapter: orchestrator`. Reconciliation treats that value only as legacy migration evidence: QUEUED work that never started can be converted in place to normal worker requirements; RUNNING work with an unknown outcome is quarantined as BLOCKED instead of replayed; only explicit durable `externalCompletionVerified` evidence can complete legacy work without execution. New Task creation and Task update reject that adapter.
272
272
 
273
- For work that was completed outside the relay but must be represented truthfully in durable history, use `npx agents-relay record --task-id <id> --summary <summary> --commit <sha>`. Recorded tasks use the `orchestrator` adapter and terminal `SUCCEEDED` state; they do not pretend a shell/model worker executed the work. This is a repair/backfill mechanism—normal work should be created as a durable Task before execution. For managed jobs, the GitHub PR body is the canonical Job surface: hidden Job metadata marker plus the visible Markdown objective.
273
+ For managed jobs, the GitHub PR body is the canonical collaboration surface while SQLite remains lifecycle authority.
274
274
 
275
275
  ### Automatic task troubleshooting
276
276
 
package/dist/cli-help.js CHANGED
@@ -116,7 +116,7 @@ Actions:
116
116
  'task create': `Create one durable Task under an existing Job.
117
117
 
118
118
  Required: normal Job storage options plus --input-file PATH|- (preferred) or --input MARKDOWN, and --output task-pr|file. --input and --input-file are mutually exclusive.
119
- Common: --task-id ID (optional; Relay auto-assigns repository-scoped task number), --idempotency-key KEY, --priority P0|P1|P2|P3, --story-size S|M|L, --agent NAME, --agent-graph-file PATH|-, --deps a,b, --parent ID, --draft. Normal model-backed Tasks omit adapter/provider/model so Relay runs one Execution Router decision over complete worker+model routes. Use --adapter orchestrator only for synchronous self-work; explicit adapter/provider/model remain compatibility/debug constraints.\nTasks with dependencies start DRAFT; tasks without dependencies start QUEUED unless --draft is given.
119
+ Common: --task-id ID (optional; Relay auto-assigns a global increasing integer Task ID), --title TITLE (optional; otherwise derived from the first non-empty input line), --idempotency-key KEY, --priority P0|P1|P2|P3, --story-size S|M|L, --agent NAME, --agent-graph-file PATH|-, --deps a,b, --parent ID, --draft. Normal model-backed Tasks omit adapter/provider/model so Relay runs one Execution Router decision over complete worker+model routes. New Tasks are worker-routed; legacy adapter=orchestrator records are recovery-only and cannot be created or manually executed.\nTasks with dependencies start DRAFT; tasks without dependencies start QUEUED unless --draft is given.
120
120
  Directly-created Tasks are not wrapped in a synthetic Plan. Planner-generated Tasks are created internally by Relay from planner output.
121
121
 
122
122
  Example:
@@ -124,7 +124,7 @@ Example:
124
124
  'task update': `Update mutable durable Task fields in place.
125
125
 
126
126
  Required: --task-id ID and the normal Job storage options.
127
- Mutable while not RUNNING/SUCCEEDED/CANCELLED: --adapter, --priority, --story-size, --input-file PATH|- or --input MARKDOWN, --project, --agent, --agent-graph-file PATH|-, --parent, --deps, --capabilities, --timeout, --max-attempts, --provider, --model, --profile, --reasoning, --cwd, --chatgpt-project, --chatgpt-tab-close-policy, --draft, --queue.
127
+ Mutable while not RUNNING/SUCCEEDED/CANCELLED: --title, --adapter, --priority, --story-size, --input-file PATH|- or --input MARKDOWN, --project, --agent, --agent-graph-file PATH|-, --parent, --deps, --capabilities, --timeout, --max-attempts, --provider, --model, --profile, --reasoning, --cwd, --chatgpt-project, --chatgpt-tab-close-policy, --draft, --queue.
128
128
  Use --clear-routing to remove routing metadata.
129
129
 
130
130
  Example:
@@ -135,12 +135,6 @@ Required: --task-id ID and the normal Job storage options.`,
135
135
  'task list': `List durable Tasks for one Job.
136
136
 
137
137
  Required: the normal Job storage options.`,
138
- 'task start': `Mark one orchestrator-owned self Task RUNNING when the creating orchestrator actually begins the work.
139
-
140
- Required: --task-id ID and the normal Job storage options. The Task must use adapter=orchestrator and be QUEUED. Start must occur before task complete.`,
141
- 'task complete': `Complete one orchestrator-owned Task after the creating orchestrator has finished the work.
142
-
143
- Required: --task-id ID, --summary TEXT, and the normal Job storage options. The Task must use adapter=orchestrator and already be RUNNING. This command is for the same orchestrator turn that created the self-task; it is not a deferred handoff mechanism.`,
144
138
  'task retry': `Retry one durable Task.
145
139
 
146
140
  Required: --task-id ID and the normal Job storage options.`,
@@ -150,6 +144,9 @@ Required: --task-id ID, --recovery-task-id TROUBLESHOOTER_ID, and the normal Job
150
144
  'task cancel': `Cancel one durable Task.
151
145
 
152
146
  Required: --task-id ID and the normal Job storage options.`,
147
+ 'task supersede': `Mark an already-CANCELLED durable Task as superseded by later corrected work through the Task Resolution Graph.
148
+
149
+ Required: --task-id ID, --reason TEXT, and the normal managed Job storage options. This does not change Task state and cannot supersede FAILED/BLOCKED/runnable work.`,
153
150
  plan: `Invoke the Planner against the current durable Job state.
154
151
 
155
152
  Required: --repo OWNER/REPO, --pr NUMBER, and --id JOB_ID.
@@ -14,5 +14,5 @@ export async function runReviewCommand(action, reviewArgs) {
14
14
  throw new Error('review request requires --repo and --id');
15
15
  const threadId = reviewRequestThreadOverride(reviewArgs);
16
16
  const job = await relayControlCommand(reviewArgs, { command: 'review.request', jobId, ...(threadId !== undefined ? { threadId } : {}) });
17
- console.log(JSON.stringify({ jobId: job.id, pr: job.externalPrNumber ?? job.prNumber ?? 0, reviewerThreadId: job.reviewerThreadId ?? null, status: 'requested', note: 'Relay graph will create the review Task once eligible; this is not a delivery receipt.' }, null, 2));
17
+ console.log(JSON.stringify({ jobId: job.id, pr: job.prNumber ?? 0, reviewerThreadId: job.reviewerThreadId ?? null, status: 'requested', note: 'Relay graph will create the review Task once eligible; this is not a delivery receipt.' }, null, 2));
18
18
  }
@@ -77,9 +77,9 @@ export async function storeFor(args) {
77
77
  if (!binding || binding.job.repository !== repo)
78
78
  throw new Error('Immutable local Job not found for repository: ' + jobId);
79
79
  const suppliedPr = arg(args, '--pr');
80
- const boundPr = binding.job.externalPrNumber ?? (binding.job.prNumber > 0 ? binding.job.prNumber : 0);
80
+ const boundPr = binding.job.prNumber > 0 ? binding.job.prNumber : 0;
81
81
  if (suppliedPr && boundPr !== Number(suppliedPr))
82
- throw new Error('Local Job external PR binding mismatch');
82
+ throw new Error('Local Job/PR binding mismatch');
83
83
  return { store: binding.store, job: await binding.store.load(jobId), localFile: null };
84
84
  }
85
85
  export async function saveLocal(file, job) { if (file)
@@ -247,26 +247,6 @@ export async function publishTaskUnblocked(bus, job, taskId, reason) { if (!bus)
247
247
  catch (error) {
248
248
  process.stderr.write(`warning: unblock event unavailable: ${error instanceof Error ? error.message : String(error)}\n`);
249
249
  } }
250
- export async function publishOrchestratorTaskStarted(bus, job, task) {
251
- if (!bus)
252
- return;
253
- try {
254
- await bus.publish(eventFor(job.id, task.id, task.parentTaskId, 'task.started', 'running', `Task ${task.id} started by orchestrator`, 'orchestrator', { adapter: 'orchestrator' }, { agent: 'relay', component: 'agents-relay' }));
255
- }
256
- catch (error) {
257
- process.stderr.write(`warning: orchestrator task start event unavailable for ${job.id}/${task.id}: ${error instanceof Error ? error.message : String(error)}\n`);
258
- }
259
- }
260
- export async function publishOrchestratorTaskCompletion(bus, job, task, summary) {
261
- if (!bus)
262
- return;
263
- try {
264
- await bus.publish(eventFor(job.id, task.id, task.parentTaskId, 'task.completed', 'succeeded', `Task ${task.id} completed by orchestrator`, 'orchestrator', { adapter: 'orchestrator', summary }, { agent: 'relay', component: 'agents-relay' }));
265
- }
266
- catch (error) {
267
- process.stderr.write(`warning: orchestrator task completion event unavailable for ${job.id}/${task.id}: ${error instanceof Error ? error.message : String(error)}\n`);
268
- }
269
- }
270
250
  export async function publishDependentUnblocked(bus, targets, reason) { if (!bus)
271
251
  return; for (const target of targets) {
272
252
  try {
@@ -14,23 +14,24 @@ export async function workerTaskFromArgs(job, args, now) {
14
14
  parseAgentGraph(agentGraph);
15
15
  if (!input.trim())
16
16
  throw new Error('task create requires --input-file PATH|- or --input MARKDOWN');
17
- if (!['codex', 'chatgpt', 'orchestrator'].includes(adapter))
18
- throw new Error('task create --adapter must be codex, chatgpt, or orchestrator');
17
+ if (!['codex', 'chatgpt'].includes(adapter))
18
+ throw new Error('task create --adapter must be codex or chatgpt; orchestrator is reserved for legacy recovery only');
19
19
  if (outputKind !== 'task-pr' && outputKind !== 'file')
20
20
  throw new Error('task create requires --output task-pr|file');
21
21
  if (outputKind === 'file' && !outputPath)
22
22
  throw new Error('--output file requires --output-path');
23
- if (adapter !== 'orchestrator' && ['--provider', '--profile', '--reasoning', '--cwd', '--chatgpt-project'].some(flag => hasArg(args, flag)) && !model)
23
+ if (['--provider', '--profile', '--reasoning', '--cwd', '--chatgpt-project'].some(flag => hasArg(args, flag)) && !model)
24
24
  throw new Error('task create explicit routing requires --model');
25
- const routing = adapter === 'orchestrator' || !model ? undefined : { provider: arg(args, '--provider', 'openai'), model, profile: arg(args, '--profile') || undefined, reasoning: arg(args, '--reasoning') || undefined, cwd: arg(args, '--cwd') || undefined, projectId: arg(args, '--chatgpt-project') || undefined, decidedBy: 'cli', decidedAt: now };
26
- const workerRequirements = adapter === 'orchestrator' || routing ? undefined : { capabilities: [], runtime: adapterValue ? adapter : undefined };
25
+ const routing = !model ? undefined : { provider: arg(args, '--provider', 'openai'), model, profile: arg(args, '--profile') || undefined, reasoning: arg(args, '--reasoning') || undefined, cwd: arg(args, '--cwd') || undefined, projectId: arg(args, '--chatgpt-project') || undefined, decidedBy: 'cli', decidedAt: now };
26
+ const workerRequirements = routing ? undefined : { capabilities: [], runtime: adapterValue ? adapter : undefined };
27
27
  const closePolicy = hasArg(args, '--chatgpt-tab-close-policy') ? chatGptTabClosePolicy(args) : undefined;
28
28
  const size = storySize(args);
29
29
  const dependencies = arg(args, '--deps').split(',').filter(Boolean);
30
- const timeoutMs = adapter === 'orchestrator' ? 0 : hasArg(args, '--timeout') ? integerArg(args, '--timeout', taskOverallTimeoutMs(size, routing?.reasoning)) : routing ? taskOverallTimeoutMs(size, routing.reasoning) : 0;
30
+ const timeoutMs = hasArg(args, '--timeout') ? integerArg(args, '--timeout', taskOverallTimeoutMs(size, routing?.reasoning)) : routing ? taskOverallTimeoutMs(size, routing.reasoning) : 0;
31
31
  return createTaskEntity(job, {
32
32
  jobId: job.id,
33
33
  id: arg(args, '--task-id', 'initial'),
34
+ title: arg(args, '--title') || undefined,
34
35
  priority: priority(args, job.priority ?? 'P2'),
35
36
  storySize: size,
36
37
  projectName: arg(args, '--project') || undefined,
package/dist/cli.js CHANGED
@@ -18,7 +18,7 @@ import { codexAndZaiUsageRegistry } from './usage.js';
18
18
  import { decisionMode, executionDecisionEngine } from './decision.js';
19
19
  import { discoverAgents } from './registry.js';
20
20
  import { AgentObjectivePlanner, CommandObjectivePlanner } from './planner.js';
21
- import { applyOrchestratorStartAction, applyOrchestratorCompleteAction, applyTaskHoldAction, applyTaskQueueAction, dispatchTaskAction } from './task-actions.js';
21
+ import { applyTaskHoldAction, applyTaskQueueAction, dispatchTaskAction } from './task-actions.js';
22
22
  import { applyJobActivateAction, applyJobCancelAction } from './job-actions.js';
23
23
  import { resolveExecutionContext } from './execution-context.js';
24
24
  import { startServiceExternalActionFlusher } from './service-external-actions.js';
@@ -59,12 +59,6 @@ async function runManagedTaskCommand(action, args) {
59
59
  if (!id)
60
60
  id = result.createdTaskId ?? '';
61
61
  }
62
- else if (action === 'complete') {
63
- const summary = arg(args, '--summary');
64
- if (!summary)
65
- throw new Error('task complete requires --summary');
66
- result = await relayTaskCommand(args, { jobId, taskId: id, action, summary });
67
- }
68
62
  else
69
63
  result = await relayTaskCommand(args, { jobId, taskId: id, action });
70
64
  const task = result.tasks.find(item => item.id === id);
@@ -73,7 +67,7 @@ async function runManagedTaskCommand(action, args) {
73
67
  console.log(JSON.stringify(task, null, 2));
74
68
  }
75
69
  async function runTaskCommand(action, args) {
76
- if (!['create', 'update', 'show', 'list', 'start', 'complete', 'retry', 'recover', 'unblock', 'cancel'].includes(action))
70
+ if (!['create', 'update', 'show', 'list', 'retry', 'recover', 'unblock', 'cancel', 'supersede'].includes(action))
77
71
  throw new Error(usage);
78
72
  const managed = !arg(args, '--file') && Boolean(arg(args, '--repo') && arg(args, '--id'));
79
73
  if (managed) {
@@ -94,10 +88,20 @@ async function runTaskCommand(action, args) {
94
88
  console.log(JSON.stringify(task, null, 2));
95
89
  return;
96
90
  }
97
- if (['create', 'start', 'complete', 'retry', 'cancel'].includes(action)) {
91
+ if (['create', 'retry', 'cancel'].includes(action)) {
98
92
  await runManagedTaskCommand(action, args);
99
93
  return;
100
94
  }
95
+ if (action === 'supersede') {
96
+ if (!taskId)
97
+ throw new Error('task supersede requires --task-id');
98
+ const reason = arg(args, '--reason');
99
+ if (!reason.trim())
100
+ throw new Error('task supersede requires --reason');
101
+ const job = await relayControlCommand(args, { command: 'task.supersede', jobId, taskId, reason });
102
+ console.log(JSON.stringify(job.tasks.find(item => item.id === taskId), null, 2));
103
+ return;
104
+ }
101
105
  if (action === 'recover') {
102
106
  if (!taskId)
103
107
  throw new Error('task recover requires --task-id');
@@ -126,10 +130,12 @@ async function runTaskCommand(action, args) {
126
130
  const expectedUpdatedAt = task.updatedAt;
127
131
  if (['RUNNING', 'UNBLOCKED', 'SUCCEEDED', 'CANCELLED'].includes(task.state))
128
132
  throw new Error(`Task ${taskId} is ${task.state}; durable execution fields are immutable in this state`);
133
+ if (hasArg(args, '--title'))
134
+ task.title = arg(args, '--title');
129
135
  if (hasArg(args, '--adapter')) {
130
136
  const adapter = arg(args, '--adapter');
131
- if (!['codex', 'chatgpt', 'orchestrator'].includes(adapter))
132
- throw new Error('--adapter must be codex, chatgpt, or orchestrator');
137
+ if (!['codex', 'chatgpt'].includes(adapter))
138
+ throw new Error('--adapter must be codex or chatgpt');
133
139
  task.adapter = adapter;
134
140
  }
135
141
  if (hasArg(args, '--priority'))
@@ -203,23 +209,6 @@ async function runTaskCommand(action, args) {
203
209
  await runTaskUnblockCommand(args, store, job, task, localFile);
204
210
  return;
205
211
  }
206
- if (action === 'start') {
207
- await applyOrchestratorStartAction(task);
208
- await store.saveTask(task);
209
- await saveLocal(localFile, await store.load(job.id));
210
- console.log(JSON.stringify(task, null, 2));
211
- return;
212
- }
213
- if (action === 'complete') {
214
- const summary = arg(args, '--summary');
215
- if (!summary)
216
- throw new Error('task complete requires --summary');
217
- await applyOrchestratorCompleteAction(task, summary);
218
- await store.saveTask(task);
219
- await saveLocal(localFile, await store.load(job.id));
220
- console.log(JSON.stringify(task, null, 2));
221
- return;
222
- }
223
212
  if (action === 'retry' || action === 'recover' || action === 'cancel') {
224
213
  let outputJob = job;
225
214
  let outputTask = task;
@@ -250,8 +239,8 @@ async function runTaskCommand(action, args) {
250
239
  throw new Error(`Task ${id} is ${task.state}; durable execution fields are immutable in this state`);
251
240
  if (hasArg(args, '--adapter')) {
252
241
  const adapter = arg(args, '--adapter');
253
- if (!['codex', 'chatgpt', 'orchestrator'].includes(adapter))
254
- throw new Error('--adapter must be codex, chatgpt, or orchestrator');
242
+ if (!['codex', 'chatgpt'].includes(adapter))
243
+ throw new Error('--adapter must be codex or chatgpt');
255
244
  task.adapter = adapter;
256
245
  }
257
246
  if (hasArg(args, '--priority'))
@@ -402,7 +391,7 @@ async function runJobCommand(action, args) {
402
391
  console.log(JSON.stringify(job, null, 2));
403
392
  return;
404
393
  }
405
- const boundPrNumber = job.externalPrNumber ?? (job.prNumber > 0 ? job.prNumber : 0);
394
+ const boundPrNumber = job.prNumber > 0 ? job.prNumber : 0;
406
395
  if (job.repository !== repo || (prValue && boundPrNumber !== prNumber)) {
407
396
  throw new Error('Local Job/PR binding mismatch');
408
397
  }
@@ -504,7 +493,7 @@ export async function createService(args) {
504
493
  const webhook = !noGitHubMode(args) && repo && webhookSecret ? {
505
494
  secret: webhookSecret,
506
495
  repositories: new Set([repo]),
507
- pullRequest: loaded.job.externalPrNumber ?? loaded.job.prNumber,
496
+ pullRequest: loaded.job.prNumber,
508
497
  jobId: loaded.job.id,
509
498
  wake: async (event) => {
510
499
  if (event.pullRequestFacts && loaded.store.applyPullRequestFacts) {
@@ -655,7 +644,7 @@ async function main() {
655
644
  console.log(JSON.stringify(await response.json(), null, 2));
656
645
  return;
657
646
  }
658
- if (!arg(args, '--file') && ['plan', 'reconcile', 'record', 'agent-register', 'agent-discover'].includes(command)) {
647
+ if (!arg(args, '--file') && ['plan', 'reconcile', 'agent-register', 'agent-discover'].includes(command)) {
659
648
  const jobId = arg(args, '--id');
660
649
  if (!jobId)
661
650
  throw new Error(`${command} requires --repo and --id`);
@@ -668,18 +657,6 @@ async function main() {
668
657
  console.log(JSON.stringify(await relayControlCommand(args, { command: 'job.reconcile', jobId }), null, 2));
669
658
  return;
670
659
  }
671
- if (command === 'record') {
672
- const now = new Date().toISOString();
673
- const id = arg(args, '--task-id', randomUUID());
674
- const summary = arg(args, '--summary');
675
- if (!summary)
676
- throw new Error('record requires --summary');
677
- const commit = arg(args, '--commit');
678
- const task = { jobId, id, projectName: arg(args, '--project') || undefined, agentName: arg(args, '--agent', 'orchestrator'), parentTaskId: arg(args, '--parent') || null, dependencies: arg(args, '--deps').split(',').filter(Boolean), capabilities: [], adapter: 'orchestrator', input: arg(args, '--input', summary), continuation: null, continuationDeliveredAt: null, state: 'SUCCEEDED', attempt: 1, maxAttempts: 1, leaseOwner: null, leaseExpiresAt: null, executionId: commit || null, threadId: null, result: { summary, data: commit ? { commit } : undefined }, error: null, timeoutMs: 0, createdAt: now, updatedAt: now };
679
- const updated = await relayControlCommand(args, { command: 'record', jobId, task });
680
- console.log(JSON.stringify(updated.tasks.find(item => item.id === id), null, 2));
681
- return;
682
- }
683
660
  if (command === 'agent-register') {
684
661
  const now = new Date().toISOString();
685
662
  const agent = { id: arg(args, '--agent-id', randomUUID()), name: arg(args, '--name'), responsibility: arg(args, '--responsibility'), role: arg(args, '--role'), capabilities: arg(args, '--capabilities').split(',').filter(Boolean), boundaries: arg(args, '--boundaries').split('|').filter(Boolean), endpoint: { kind: arg(args, '--endpoint-kind', 'adapter'), target: arg(args, '--endpoint') }, runtime: { adapter: arg(args, '--runtime', arg(args, '--adapter', 'shell')), provider: arg(args, '--provider') || undefined, model: arg(args, '--model') || undefined }, availability: arg(args, '--availability', 'available'), routing: { labels: arg(args, '--labels').split(',').filter(Boolean).reduce((out, item) => { const [key, value] = item.split('=', 2); if (key && value)
@@ -729,20 +706,6 @@ async function main() {
729
706
  console.log(JSON.stringify(candidates, null, 2));
730
707
  return;
731
708
  }
732
- if (command === 'record') {
733
- const now = new Date().toISOString();
734
- const id = arg(args, '--task-id', randomUUID());
735
- const summary = arg(args, '--summary');
736
- if (!summary)
737
- throw new Error('record requires --summary');
738
- const commit = arg(args, '--commit');
739
- const task = { jobId: job.id, id, projectName: arg(args, '--project') || undefined, agentName: arg(args, '--agent', 'orchestrator'), parentTaskId: arg(args, '--parent') || null, dependencies: arg(args, '--deps').split(',').filter(Boolean), capabilities: [], adapter: 'orchestrator', input: arg(args, '--input', summary), continuation: null, continuationDeliveredAt: null, state: 'SUCCEEDED', attempt: 1, maxAttempts: 1, leaseOwner: null, leaseExpiresAt: null, executionId: commit || null, threadId: null, result: { summary, data: commit ? { commit } : undefined }, error: null, timeoutMs: 0, createdAt: now, updatedAt: now };
740
- await store.appendTask(task);
741
- job.tasks.push(task);
742
- await saveLocal(localFile, job);
743
- console.log(`Recorded ${id}`);
744
- return;
745
- }
746
709
  if (command === 'serve') {
747
710
  await startService(args);
748
711
  return;
@@ -24,7 +24,8 @@ export function dashboardJobSetState(store) {
24
24
  if (state === 'CANCELLED') {
25
25
  if (job.state !== 'CANCELLED')
26
26
  await applyJobCancelAction(store, job);
27
- await store.closePullRequest?.();
27
+ if (!job.localMerge?.sourceRef)
28
+ await store.closePullRequest?.();
28
29
  return await store.load(job.id);
29
30
  }
30
31
  if (job.state === state)
@@ -345,7 +345,7 @@ function cadence(job){const schedule=job?.schedule;if(!schedule?.cron)return '
345
345
  function safePrUrl(repository,prNumber){if(typeof repository!=='string'||!Number.isInteger(prNumber)||prNumber<=0)return null;const parts=repository.split('/');if(parts.length!==2)return null;const [owner,repo]=parts;if(!owner||!repo||owner==='.'||owner==='..'||repo==='.'||repo==='..')return null;if(!/^[A-Za-z0-9-]+$/.test(owner)||!/^[A-Za-z0-9._-]+$/.test(repo))return null;return 'https://github.com/'+owner+'/'+repo+'/pull/'+prNumber;}
346
346
  function safeCommitUrl(repository,sha){if(typeof repository!=='string'||typeof sha!=='string'||!/^[0-9a-f]{7,64}$/i.test(sha))return null;const parts=repository.split('/');if(parts.length!==2||!parts[0]||!parts[1])return null;return 'https://github.com/'+parts[0]+'/'+parts[1]+'/commit/'+sha;}
347
347
  function commitField(label,job,sha){const box=element('div','detail-group');box.append(element('p','detail-label',text(label)));const line=element('p','detail-value');const url=safeCommitUrl(job?.repository,sha);if(url){const link=document.createElement('a');link.href=url;link.target='_blank';link.rel='noopener noreferrer';link.textContent=sha.slice(0,7);line.append(link);}else line.append(text(sha||'—'));box.append(line);return box;}
348
- function prField(job){const box=element('div','detail-group');box.append(element('p','detail-label',text('GitHub PR')));const line=element('p','detail-value');const prNumber=Number(job.externalPrNumber||job.prNumber||0);const url=safePrUrl(job.repository,prNumber);if(url){const link=document.createElement('a');link.href=url;link.target='_blank';link.rel='noopener noreferrer';link.textContent='PR #'+prNumber;line.append(link);}else line.append(text('unavailable'));box.append(line);return box;}
348
+ function prField(job){const box=element('div','detail-group');box.append(element('p','detail-label',text('GitHub PR')));const line=element('p','detail-value');const prNumber=Number(job.prNumber||0);const url=safePrUrl(job.repository,prNumber);if(url){const link=document.createElement('a');link.href=url;link.target='_blank';link.rel='noopener noreferrer';link.textContent='PR #'+prNumber;line.append(link);}else line.append(text('unavailable'));box.append(line);return box;}
349
349
  function chatField(task){const box=element('div','detail-group');box.append(element('p','detail-label',text('Chat')));const line=element('p','detail-value');if(task.adapter==='chatgpt'&&typeof task.threadId==='string'&&task.threadId){const link=document.createElement('a');link.href='https://chatgpt.com/c/'+encodeURIComponent(task.threadId);link.target='_blank';link.rel='noopener noreferrer';link.textContent='Open chat';line.append(link);}else line.append(text('—'));box.append(line);return box;}
350
350
  function taskCommentField(task){const box=element('div','detail-group');box.append(element('p','detail-label',text('PR comment')));const line=element('p','detail-value');if(typeof task.taskUrl==='string'&&task.taskUrl.startsWith('https://github.com/')){const link=document.createElement('a');link.className='task-link';link.href=task.taskUrl;link.target='_blank';link.rel='noopener noreferrer';link.textContent='Open task comment';line.append(link);}else line.append(text('—'));box.append(line);return box;}
351
351
  function parseAgentGraphSource(source){if(typeof source!=='string'||!source.trim())return null;const agents=new Map(),edges=[];for(const raw of source.split(/\\r?\\n/)){const line=raw.trim();if(!line||line.startsWith('%%')||line.startsWith('flowchart '))continue;let match=line.match(/^([A-Za-z_][A-Za-z0-9_.-]*)(?:\\[agent:([A-Za-z_][A-Za-z0-9_.-]*)\\])?\\s*-->\\s*([A-Za-z_][A-Za-z0-9_.-]*)(?:\\[agent:([A-Za-z_][A-Za-z0-9_.-]*)\\])?$/);if(match){agents.set(match[1],match[2]||agents.get(match[1])||match[1]);agents.set(match[3],match[4]||agents.get(match[3])||match[3]);edges.push([match[1],match[3]]);continue;}match=line.match(/^([A-Za-z_][A-Za-z0-9_.-]*)\\[agent:([A-Za-z_][A-Za-z0-9_.-]*)\\]$/);if(match){agents.set(match[1],match[2]);continue;}return null;}const incoming=new Map([...agents.keys()].map(id=>[id,0]));for(const [,to] of edges)incoming.set(to,(incoming.get(to)||0)+1);const remaining=new Map(incoming),waves=[];let ready=[...remaining].filter(([,degree])=>degree===0).map(([id])=>id).sort();while(ready.length){waves.push(ready);const next=[];for(const id of ready)for(const [from,to] of edges)if(from===id){const degree=(remaining.get(to)||0)-1;remaining.set(to,degree);if(degree===0)next.push(to);}ready=[...new Set(next)].sort();}return {agents,waves};}
@@ -384,14 +384,14 @@ const DEFAULT_CHAT_SHORTCUTS=[
384
384
  ].join('\\n');
385
385
  function chatShortcutText(){return localStorage.getItem(CHAT_SHORTCUTS_KEY)||DEFAULT_CHAT_SHORTCUTS;}
386
386
  function parseChatShortcuts(source){return String(source||'').split(/\\r?\\n/).map(line=>line.trim()).filter(Boolean).map(line=>{const split=line.indexOf(':');return split>0?{name:line.slice(0,split).trim(),prompt:line.slice(split+1).trim()}:null;}).filter(item=>item&&item.name&&item.prompt);}
387
- function jobChatRef(job){const pr=Number(job?.externalPrNumber||job?.prNumber||0);return repoName(job?.repository)+"/job:"+value(job?.id)+(pr>0?"/pr#"+pr:"")+": "+value(job?.title||job?.id);}
387
+ function jobChatRef(job){const pr=Number(job?.prNumber||0);return repoName(job?.repository)+"/job:"+value(job?.id)+(pr>0?"/pr#"+pr:"")+": "+value(job?.title||job?.id);}
388
388
  function chatShortcutUrl(prompt){return 'https://chatgpt.com/?prompt='+encodeURIComponent(prompt);}
389
389
  const CHAT_SHORTCUT_TARGET='agents-relay-shortcut';
390
390
  function chatShortcutField(job){const box=element('div','detail-group');box.append(element('p','detail-label',text('ChatGPT shortcut')));const select=document.createElement('select');select.className='job-filter job-chat-shortcut-select';select.setAttribute('aria-label','ChatGPT shortcut');select.append(new Option('',''));for(const item of parseChatShortcuts(chatShortcutText()))select.append(new Option(item.name,item.prompt));select.value='';select.addEventListener('change',()=>{if(!select.value)return;const prompt=select.value.replaceAll('%1',jobChatRef(job));window.open(chatShortcutUrl(prompt),CHAT_SHORTCUT_TARGET,'noopener');select.value='';});box.append(select);return box;}
391
- function renderJobList(){jobList.replaceChildren();const visible=filteredJobs();if(!jobs.length){jobList.append(element('li','empty',text('No durable jobs discovered.')));return;}if(!visible.length){jobList.append(element('li','empty',text('No jobs match the selected repo.')));return;}for(const job of visible){const row=element('div','job-row'+(isCompleteUnmerged(job)?' complete-unmerged':''));row.setAttribute('aria-current',String(jobSelectionKey(job)===selectedJob));row.addEventListener('click',()=>selectJob(job));const content=element('div','job-content');const select=document.createElement('button');select.type='button';select.className='job-button';select.setAttribute('aria-current',String(jobSelectionKey(job)===selectedJob));select.title=job.title||job.id;select.addEventListener('click',event=>{event.stopPropagation();selectJob(job);});select.append(element('span','job-title',text(repoName(job.repository)+'/'+(Number(job.externalPrNumber||job.prNumber||0)>0?'PR#'+value(job.externalPrNumber||job.prNumber):'Job '+shortManagedId(job.id,job.repository,'j'))+': '+value(job.title||job.id))));const states=element('span','job-status-line');states.append(element('span','badge '+value(job.state),text(value(job.state))),element('span','badge '+value(job.runtimeStatus||'IDLE'),text(value(job.runtimeStatus||'IDLE'))));if(job.publicationFailure)states.append(element('span','badge FAILED',text('PUBLISH FAILED: '+value(job.publicationFailure.stage))));else if(job.publication?.version)states.append(element('span','badge SUCCEEDED',text('v'+job.publication.version)));else if(Number(job.externalPrNumber||0)>0&&job.githubState==='MERGED')states.append(element('span','badge MERGED',text('PR Merged')));else if(job.state==='COMPLETED'&&job.githubState==='CLOSED')states.append(element('span','badge SUCCEEDED',text('PR Archived')));if(job.executionMode==='autonomous')states.append(element('span','badge flag-autonomous',text('AUTONOMOUS')));if(job.state==='ACTIVE'&&job.reviewEnabled!==false)states.append(element('span','badge flag-review',text('REVIEW REQUIRED')));if(isCompleteUnmerged(job))states.append(element('span','badge MERGE_PENDING',text('MERGE PENDING')));const primary=element('div','job-primary');primary.append(states);const lastLine=element('div','job-last-line');lastLine.append(element('span','subtle',text(compactCounts(counts(job.tasks)))),element('span','subtle',text(relativeTime(job.updatedAt))));content.append(select,primary,lastLine);row.append(content);jobList.append(element('li','',row));}}
391
+ function renderJobList(){jobList.replaceChildren();const visible=filteredJobs();if(!jobs.length){jobList.append(element('li','empty',text('No durable jobs discovered.')));return;}if(!visible.length){jobList.append(element('li','empty',text('No jobs match the selected repo.')));return;}for(const job of visible){const row=element('div','job-row'+(isCompleteUnmerged(job)?' complete-unmerged':''));row.setAttribute('aria-current',String(jobSelectionKey(job)===selectedJob));row.addEventListener('click',()=>selectJob(job));const content=element('div','job-content');const select=document.createElement('button');select.type='button';select.className='job-button';select.setAttribute('aria-current',String(jobSelectionKey(job)===selectedJob));select.title=job.title||job.id;select.addEventListener('click',event=>{event.stopPropagation();selectJob(job);});select.append(element('span','job-title',text(repoName(job.repository)+'/'+(Number(job.prNumber||0)>0?'PR#'+value(job.prNumber):'Job '+shortManagedId(job.id,job.repository,'j'))+': '+value(job.title||job.id))));const states=element('span','job-status-line');states.append(element('span','badge '+value(job.state),text(value(job.state))),element('span','badge '+value(job.runtimeStatus||'IDLE'),text(value(job.runtimeStatus||'IDLE'))));if(job.publicationFailure)states.append(element('span','badge FAILED',text('PUBLISH FAILED: '+value(job.publicationFailure.stage))));else if(job.publication?.version)states.append(element('span','badge SUCCEEDED',text('v'+job.publication.version)));else if(Number(job.prNumber||0)>0&&job.githubState==='MERGED')states.append(element('span','badge MERGED',text('PR Merged')));else if(job.state==='COMPLETED'&&job.githubState==='CLOSED')states.append(element('span','badge SUCCEEDED',text('PR Archived')));if(job.executionMode==='autonomous')states.append(element('span','badge flag-autonomous',text('AUTONOMOUS')));if(job.state==='ACTIVE'&&job.reviewEnabled!==false)states.append(element('span','badge flag-review',text('REVIEW REQUIRED')));if(isCompleteUnmerged(job))states.append(element('span','badge MERGE_PENDING',text('MERGE PENDING')));const primary=element('div','job-primary');primary.append(states);const lastLine=element('div','job-last-line');lastLine.append(element('span','subtle',text(compactCounts(counts(job.tasks)))),element('span','subtle',text(relativeTime(job.updatedAt))));content.append(select,primary,lastLine);row.append(content);jobList.append(element('li','',row));}}
392
392
  function taskTagDisplay(job,task){const display=task.tag&&job?.tag?.values?.[task.tag];return typeof display==='string'&&display?display:null;}
393
393
  function taskSubtreeComplete(task,children){return ['SUCCEEDED','CANCELLED'].includes(task.state)&&(children.get(task.id)||[]).every(child=>taskSubtreeComplete(child,children));}
394
- function taskNode(task,children,depth){const li=element('li');const nested=children.get(task.id)||[];const line=element('div','task-line');if(nested.length){const key=(selectedJob||'')+':'+task.id;const collapsed=collapsedTasks.has(key)||(!expandedTasks.has(key)&&taskSubtreeComplete(task,children));const fold=document.createElement('button');fold.type='button';fold.className='task-fold';fold.setAttribute('aria-expanded',String(!collapsed));fold.setAttribute('aria-label',(collapsed?'Expand ':'Collapse ')+task.id);fold.textContent=collapsed?'▸':'▾';line.append(fold);const ul=element('ul','tree');ul.hidden=collapsed;for(const child of nested)ul.append(taskNode(child,children,depth+1));fold.addEventListener('click',()=>{const next=!ul.hidden;ul.hidden=next;fold.setAttribute('aria-expanded',String(!next));fold.setAttribute('aria-label',(next?'Expand ':'Collapse ')+task.id);fold.textContent=next?'▸':'▾';if(next){collapsedTasks.add(key);expandedTasks.delete(key);}else{collapsedTasks.delete(key);expandedTasks.add(key);}});li.append(line,ul);}else li.append(line);const row=document.createElement('button');row.type='button';row.className='task-row';row.setAttribute('aria-current',String(task.id===selectedTaskId));row.title=task.id;row.addEventListener('click',()=>{const deselect=selectedTaskId===task.id;selectedTaskId=deselect?null:task.id;eventTaskId=deselect?null:task.id;rememberSelection();clearEvents();renderAll();void loadEventHistory();});const id=element('span','task-id',text(shortManagedId(task.id,selected()?.repository,'t')));id.style.paddingLeft=Math.min(depth,4)*6+'px';row.append(id,element('span','badge '+value(task.state),text(task.state)));const tag=taskTagDisplay(selected(),task);if(tag){const badge=element('span','task-tag',text(tag));badge.title=(selected()?.tag?.description||'Task tag')+': '+task.tag;row.append(badge);}line.append(row);return li;}
394
+ function taskNode(task,children,depth){const li=element('li');const nested=children.get(task.id)||[];const line=element('div','task-line');if(nested.length){const key=(selectedJob||'')+':'+task.id;const collapsed=collapsedTasks.has(key)||(!expandedTasks.has(key)&&taskSubtreeComplete(task,children));const fold=document.createElement('button');fold.type='button';fold.className='task-fold';fold.setAttribute('aria-expanded',String(!collapsed));fold.setAttribute('aria-label',(collapsed?'Expand ':'Collapse ')+task.id);fold.textContent=collapsed?'▸':'▾';line.append(fold);const ul=element('ul','tree');ul.hidden=collapsed;for(const child of nested)ul.append(taskNode(child,children,depth+1));fold.addEventListener('click',()=>{const next=!ul.hidden;ul.hidden=next;fold.setAttribute('aria-expanded',String(!next));fold.setAttribute('aria-label',(next?'Expand ':'Collapse ')+task.id);fold.textContent=next?'▸':'▾';if(next){collapsedTasks.add(key);expandedTasks.delete(key);}else{collapsedTasks.delete(key);expandedTasks.add(key);}});li.append(line,ul);}else li.append(line);const row=document.createElement('button');row.type='button';row.className='task-row';row.setAttribute('aria-current',String(task.id===selectedTaskId));row.title=task.id;row.addEventListener('click',()=>{const deselect=selectedTaskId===task.id;selectedTaskId=deselect?null:task.id;eventTaskId=deselect?null:task.id;rememberSelection();clearEvents();renderAll();void loadEventHistory();});const label=element('span','task-id',text(task.title||task.input?.split(/\\r?\\n/).find(line=>line.trim())?.trim()||shortManagedId(task.id,selected()?.repository,'t')));label.style.paddingLeft=Math.min(depth,4)*6+'px';row.append(label,element('span','badge '+value(task.state),text(task.state)));const tag=taskTagDisplay(selected(),task);if(tag){const badge=element('span','task-tag',text(tag));badge.title=(selected()?.tag?.description||'Task tag')+': '+task.tag;row.append(badge);}line.append(row);return li;}
395
395
  function buildTree(tasks){taskTree.replaceChildren();if(!Array.isArray(tasks)||!tasks.length){taskTree.append(element('li','empty',text('No tasks in durable state.')));return;}const work=tasks.filter(task=>task.kind!=='planner');if(!work.length){taskTree.append(element('li','empty',text('No work tasks in durable state.')));return;}const known=new Set(work.map(task=>task.id));const children=new Map();const roots=[];for(const task of work){if(!task.parentTaskId||!known.has(task.parentTaskId))roots.push(task);else{const siblings=children.get(task.parentTaskId)||[];siblings.push(task);children.set(task.parentTaskId,siblings);}}for(const task of roots)taskTree.append(taskNode(task,children,0));}
396
396
  function attemptBudget(task){const attempt=Number(task?.attempt)||0;const max=Number(task?.maxAttempts)||0;if(max>=3&&max%3===0){const effectiveMax=Math.max(max,Math.ceil(attempt/3)*3);return attempt+' / 3×'+(effectiveMax/3);}return attempt+' / '+max;}
397
397
  const JOB_STATES=['DRAFT','ACTIVE','COMPLETED','CANCELLED'];
@@ -401,7 +401,7 @@ const TASK_STATES=['DRAFT','QUEUED','RUNNING','BLOCKED','UNBLOCKED','SUCCEEDED',
401
401
  async function setTaskState(task,state,select){const job=selected();if(!job||state===task.state)return;select.disabled=true;try{const params=new URLSearchParams({id:job.id,task:task.id,state,repo:job.repository,pr:String(job.prNumber)});const response=await fetch('/api/task-state?'+params.toString(),{method:'POST'});if(!response.ok)throw new Error(response.status+' '+response.statusText);const updated=await response.json();const index=jobs.findIndex(candidate=>jobSelectionKey(candidate)===jobSelectionKey(job));if(index>=0)jobs[index]=updated;else jobs.push(updated);renderAll();void loadEventHistory();}catch(error){select.title='Set task state: '+error.message;select.value=task.state;}finally{select.disabled=false;}}
402
402
  function taskStateField(task){const box=element('div','detail-group');box.append(element('p','detail-label',text('State')));const select=document.createElement('select');select.className='job-filter task-state-select';select.setAttribute('aria-label','Task state');for(const state of TASK_STATES){const option=new Option(state,state);if(state==='UNBLOCKED'&&task.state!=='UNBLOCKED')option.disabled=true;select.append(option);}select.value=task.state;select.addEventListener('change',()=>void setTaskState(task,select.value,select));box.append(select);return box;}
403
403
  function manualOverrideField(task){const rows=Array.isArray(task.manualStateOverrides)?task.manualStateOverrides:[];const last=rows.at(-1);return field('Manual override',last?last.from+' → '+last.to+' · '+relativeTime(last.at):'—');}
404
- function renderTaskDetail(task){taskDetailRoot.replaceChildren();if(!task){taskDetailRoot.append(element('p','empty',text('Select a task to inspect execution details.')));return;}const routing=task.routing||{};const groups=element('div','detail-groups');groups.append(field('Task',task.id),field('Kind',task.kind||'work'),taskStateField(task),manualOverrideField(task),field('Planning decision',task.planningDecisionId||'—'),field('Planning source',task.planningSource||'—'),field('Planning round',task.planningRound===undefined?'—':String(task.planningRound)),field('Project / agent',value(task.projectName)+' / '+value(task.agentName)),field('Parent task',task.parentTaskId||'root'),field('Adapter',task.adapter),field('Provider / model',value(routing.provider)+' / '+value(routing.model)),field('Reasoning / profile',value(routing.reasoning||routing.profile)),field('Attempt',attemptBudget(task)),field('Dependencies',Array.isArray(task.dependencies)&&task.dependencies.length?task.dependencies.join(', '):'none'),taskCommentField(task),chatField(task));const job=selected();if(task.reviewHeadSha&&job)groups.append(commitField('HEAD commit',job,task.reviewHeadSha));const graph=agentGraphField(task);if(graph)groups.append(graph);if(task.plannerResult)groups.append(field('Objective status',task.plannerResult.objective_status),markdownField('Assessment',task.plannerResult.assessment));if(task.error)groups.append(field('Error',task.error));taskDetailRoot.append(groups);}
404
+ function renderTaskDetail(task){taskDetailRoot.replaceChildren();if(!task){taskDetailRoot.append(element('p','empty',text('Select a task to inspect execution details.')));return;}const routing=task.routing||{};const groups=element('div','detail-groups');groups.append(field('Title',task.title||'—'),field('Task ID',task.id),field('Kind',task.kind||'work'),taskStateField(task),manualOverrideField(task),field('Planning decision',task.planningDecisionId||'—'),field('Planning source',task.planningSource||'—'),field('Planning round',task.planningRound===undefined?'—':String(task.planningRound)),field('Project / agent',value(task.projectName)+' / '+value(task.agentName)),field('Parent task',task.parentTaskId||'root'),field('Adapter',task.adapter),field('Provider / model',value(routing.provider)+' / '+value(routing.model)),field('Reasoning / profile',value(routing.reasoning||routing.profile)),field('Attempt',attemptBudget(task)),field('Dependencies',Array.isArray(task.dependencies)&&task.dependencies.length?task.dependencies.join(', '):'none'),taskCommentField(task),chatField(task));const job=selected();if(task.reviewHeadSha&&job)groups.append(commitField('HEAD commit',job,task.reviewHeadSha));const graph=agentGraphField(task);if(graph)groups.append(graph);if(task.plannerResult)groups.append(field('Objective status',task.plannerResult.objective_status),markdownField('Assessment',task.plannerResult.assessment));if(task.error)groups.append(field('Error',task.error));taskDetailRoot.append(groups);}
405
405
  function reviewStatus(job){const review=job?.review;if(!review)return 'Not started';if(review.outcome==='approved'&&review.headSha===job.headSha)return 'MERGE READY · approved';const stale=Array.isArray(job?.tasks)&&job.tasks.some(task=>task.agentName==='reviewer'&&task.reviewHeadSha&&job.headSha&&task.reviewHeadSha!==job.headSha);if(stale)return 'STALE · awaiting review';return String(review.outcome||'pending').replace('_',' ').toUpperCase();}
406
406
  function renderSelectedJob(){jobDetail.replaceChildren();const job=selected();if(!job){selectedJobTitle.textContent='';jobDetail.append(element('div','empty',text('No durable jobs discovered.')));buildTree([]);renderTaskDetail(null);return;}selectedJobTitle.textContent=value(job.title||job.id);if(jobSelectionKey(job)!==selectedJob){selectedJob=jobSelectionKey(job);selectedTaskId=null;eventTaskId=null;clearEvents();rememberSelection();}const summary=element('div','job-summary');summary.append(field('PR state',job.githubState||'UNKNOWN'),jobStateField(job),field('Runtime status',job.runtimeStatus||'IDLE'),field('Execution mode',job.executionMode||'fixed'),field('Cadence',cadence(job)),field('Review',reviewStatus(job)),field('Creator thread',job.creatorThreadId||'—'),field('Implementation thread',job.implementationThreadId||'—'),field('Reviewer thread',job.reviewerThreadId||'—'),commitField('Review head SHA',job,job.review?.headSha||job.headSha||''),prField(job),field('Repository',job.repository||'—'),field('Last activity',relativeTime(job.updatedAt)),field('Task states',compactCounts(job.tasks)),chatShortcutField(job));jobDetail.append(summary);const selectedTask=(job.tasks||[]).find(task=>task.id===selectedTaskId)||null;if(selectedTaskId&&!selectedTask){selectedTaskId=null;eventTaskId=null;clearEvents();rememberSelection();}buildTree(job.tasks||[]);renderTaskDetail(selectedTask);renderEventScope();}
407
407
  function renderAll(){jobs.sort(compareJobs);syncFilters();renderJobList();renderSelectedJob();syncMobileJobsView();}
package/dist/dashboard.js CHANGED
@@ -308,14 +308,10 @@ export function serveDashboard(store, port = 8787, bus, refreshMs = 300000, jobI
308
308
  result = await resolvedActions.taskCreate(requestedJobId, body.task, body.idempotencyKey);
309
309
  else if (body.action === 'update' && isTaskId(requestedTaskId) && body.task && body.expectedUpdatedAt && resolvedActions?.taskUpdate)
310
310
  result = await resolvedActions.taskUpdate(requestedJobId, requestedTaskId, body.task, body.expectedUpdatedAt);
311
- else if (body.action === 'start' && isTaskId(requestedTaskId) && resolvedActions?.taskStart)
312
- result = await resolvedActions.taskStart(requestedJobId, requestedTaskId);
313
311
  else if (body.action === 'retry' && isTaskId(requestedTaskId) && resolvedActions?.taskRetry)
314
312
  result = await resolvedActions.taskRetry(requestedJobId, requestedTaskId);
315
313
  else if (body.action === 'cancel' && isTaskId(requestedTaskId) && resolvedActions?.taskCancel)
316
314
  result = await resolvedActions.taskCancel(requestedJobId, requestedTaskId);
317
- else if (body.action === 'complete' && isTaskId(requestedTaskId) && typeof body.summary === 'string' && body.summary.trim() && resolvedActions?.taskComplete)
318
- result = await resolvedActions.taskComplete(requestedJobId, requestedTaskId, body.summary);
319
315
  else
320
316
  throw new Error('Task command unavailable');
321
317
  if (!res.headersSent)
@@ -3,7 +3,7 @@ export function orderGitHubBindingCandidates(items) {
3
3
  return [...items].sort((a, b) => score(a.job) - score(b.job) || a.job.createdAt.localeCompare(b.job.createdAt));
4
4
  }
5
5
  function score(job) {
6
- const bound = (job.externalPrNumber ?? (job.prNumber > 0 ? job.prNumber : 0)) > 0;
6
+ const bound = (job.prNumber) > 0;
7
7
  const terminal = ['COMPLETED', 'CANCELLED'].includes(job.state);
8
8
  if (!terminal && !bound)
9
9
  return 0;
@@ -0,0 +1,24 @@
1
+ import { deriveTaskTitle } from './task-title.js';
2
+ export function normalizeLegacyJobPrNumber(input) {
3
+ const record = input;
4
+ const legacyKey = 'external' + 'PrNumber';
5
+ const legacy = record[legacyKey];
6
+ if ((!Number.isInteger(input.prNumber) || input.prNumber <= 0) && Number.isInteger(legacy) && Number(legacy) > 0)
7
+ input.prNumber = Number(legacy);
8
+ delete record[legacyKey];
9
+ for (const task of input.tasks ?? []) {
10
+ if (!task.title?.trim())
11
+ task.title = deriveTaskTitle(task.input, task.agentName ? `${task.agentName} task` : 'Task');
12
+ }
13
+ return input;
14
+ }
15
+ export function migrateLegacyJobPrNumbers(db) {
16
+ const rows = db.prepare('SELECT job_id,json FROM job_snapshot').all();
17
+ const update = db.prepare('UPDATE job_snapshot SET json=? WHERE job_id=?');
18
+ for (const row of rows) {
19
+ const normalized = normalizeLegacyJobPrNumber(JSON.parse(row.json));
20
+ const json = JSON.stringify(normalized);
21
+ if (json !== row.json)
22
+ update.run(json, row.job_id);
23
+ }
24
+ }
@@ -8,12 +8,20 @@ export function generatedEntityPrefix(repository, kind) {
8
8
  return `${kind === 'job' ? 'j' : 't'}-${owner.length}-${owner}-${repo}-`;
9
9
  }
10
10
  export function isGeneratedEntityId(id, repository, kind) {
11
+ if (kind === 'task') {
12
+ if (/^[1-9][0-9]*$/.test(id))
13
+ return true;
14
+ const legacyPrefix = generatedEntityPrefix(repository, kind);
15
+ return id.startsWith(legacyPrefix) && /^[0-9]+$/.test(id.slice(legacyPrefix.length));
16
+ }
11
17
  const prefix = generatedEntityPrefix(repository, kind);
12
18
  return id.startsWith(prefix) && /^[0-9]+$/.test(id.slice(prefix.length));
13
19
  }
14
- /** Repository-scoped, monotonically increasing IDs. Allocation is serialized by SQLite. */
20
+ /** Jobs remain repository-scoped. Task IDs are one Relay-global monotonically increasing integer sequence. Allocation is serialized by SQLite. */
15
21
  export function allocateEntityId(directory, repository, kind, requestKey, occupied = new Set()) {
16
- const prefix = generatedEntityPrefix(repository, kind);
22
+ const prefix = kind === 'job' ? generatedEntityPrefix(repository, kind) : '';
23
+ const scope = kind === 'task' ? '*' : repository;
24
+ const scopedRequestKey = requestKey && kind === 'task' ? `${repository}:${requestKey}` : requestKey;
17
25
  if (requestKey !== undefined && (!requestKey || requestKey.length > 512))
18
26
  throw new Error('Invalid idempotency key');
19
27
  mkdirSync(directory, { recursive: true });
@@ -23,22 +31,21 @@ export function allocateEntityId(directory, repository, kind, requestKey, occupi
23
31
  db.exec('CREATE TABLE IF NOT EXISTS sequences (repository TEXT NOT NULL, kind TEXT NOT NULL, value INTEGER NOT NULL, PRIMARY KEY(repository,kind)); CREATE TABLE IF NOT EXISTS idempotency (repository TEXT NOT NULL, kind TEXT NOT NULL, request_key TEXT NOT NULL, entity_id TEXT NOT NULL, PRIMARY KEY(repository,kind,request_key));');
24
32
  db.exec('BEGIN IMMEDIATE');
25
33
  try {
26
- if (requestKey) {
27
- const row = db.prepare('SELECT entity_id FROM idempotency WHERE repository=? AND kind=? AND request_key=?').get(repository, kind, requestKey);
34
+ if (scopedRequestKey) {
35
+ const row = db.prepare('SELECT entity_id FROM idempotency WHERE repository=? AND kind=? AND request_key=?').get(scope, kind, scopedRequestKey);
28
36
  if (row) {
29
37
  db.exec('COMMIT');
30
38
  return row.entity_id;
31
39
  }
32
40
  }
33
- const old = db.prepare('SELECT value FROM sequences WHERE repository=? AND kind=?').get(repository, kind);
41
+ const old = db.prepare('SELECT value FROM sequences WHERE repository=? AND kind=?').get(scope, kind);
34
42
  let next = (old?.value ?? 0) + 1;
35
43
  while (occupied.has(prefix + next))
36
44
  next++;
37
- db.prepare('INSERT INTO sequences VALUES (?,?,?) ON CONFLICT(repository,kind) DO UPDATE SET value=excluded.value').run(repository, kind, next);
38
- // Length prefix keeps repository names unambiguous while satisfying dashboard ID validation.
45
+ db.prepare('INSERT INTO sequences VALUES (?,?,?) ON CONFLICT(repository,kind) DO UPDATE SET value=excluded.value').run(scope, kind, next);
39
46
  const id = prefix + next;
40
- if (requestKey)
41
- db.prepare('INSERT INTO idempotency VALUES (?,?,?,?)').run(repository, kind, requestKey, id);
47
+ if (scopedRequestKey)
48
+ db.prepare('INSERT INTO idempotency VALUES (?,?,?,?)').run(scope, kind, scopedRequestKey, id);
42
49
  db.exec('COMMIT');
43
50
  return id;
44
51
  }
@@ -0,0 +1,51 @@
1
+ import { eventFor } from './events.js';
2
+ import { orchestratorRescueFacts, prepareOrchestratorRepair } from './workflow/orchestrator-rescue.js';
3
+ import { reclaimStaleExecutionProcess } from './execution-process-tracker.js';
4
+ /** Durable, race-safe repair effect selected by workflow/orchestrator-rescue.mmd. */
5
+ export async function repairLegacyOrchestrators(jobId, now, ports) {
6
+ const { store } = ports;
7
+ const repairOne = async (taskId) => {
8
+ if (store.mutateJob) {
9
+ let outcome = { repaired: false, externallyCompleted: false, blocked: false, ageMs: 0 };
10
+ return await store.mutateJob(jobId, current => {
11
+ const task = current.tasks.find(item => item.id === taskId);
12
+ if (!task)
13
+ return current;
14
+ const repair = prepareOrchestratorRepair(task, now);
15
+ if (repair.repaired)
16
+ outcome = repair;
17
+ return current;
18
+ }).then(() => outcome);
19
+ }
20
+ const job = await store.load(jobId);
21
+ const task = job.tasks.find(item => item.id === taskId);
22
+ if (!task)
23
+ return null;
24
+ const repair = prepareOrchestratorRepair(task, now);
25
+ if (repair.repaired)
26
+ await store.saveTask(task);
27
+ return repair;
28
+ };
29
+ for (const stale of (await store.load(jobId)).tasks) {
30
+ const facts = orchestratorRescueFacts(stale, now);
31
+ if (facts.legacyActive !== true)
32
+ continue;
33
+ if (facts.verifiedExternalCompletion !== true && facts.unknownStartedOutcome !== true && facts.rescuable !== true)
34
+ continue;
35
+ if (stale.executionId && !await reclaimStaleExecutionProcess(store, stale, ports.owner))
36
+ continue;
37
+ const repair = await repairOne(stale.id);
38
+ if (!repair?.repaired)
39
+ continue;
40
+ const job = await store.load(jobId);
41
+ const task = job.tasks.find(item => item.id === stale.id);
42
+ const type = repair.externallyCompleted ? 'task.external_completion_applied'
43
+ : repair.blocked ? 'task.orchestrator_quarantined' : 'task.orchestrator_rescued';
44
+ const status = repair.externallyCompleted ? 'succeeded' : repair.blocked ? 'blocked' : 'queued';
45
+ await ports.emit(eventFor(job.id, task.id, task.parentTaskId, type, status, repair.externallyCompleted
46
+ ? `Applied durable external completion for legacy orchestrator task ${task.id}`
47
+ : repair.blocked
48
+ ? `Quarantined legacy RUNNING orchestrator task ${task.id}; external outcome must be verified before replacement work`
49
+ : `Recovered legacy orchestrator task ${task.id} through Execution Router`, 'orchestrator', { ageMs: repair.ageMs, adapter: repair.externallyCompleted || repair.blocked ? undefined : 'execution-router' }, { agent: 'relay', component: 'agents-relay' }));
50
+ }
51
+ }
@@ -19,10 +19,9 @@ export function createJobIdRegistryResolver(directory, openStores, options = {})
19
19
  }
20
20
  // SQLite remains lifecycle authority. A positive PR binding only enables
21
21
  // the external collaboration adapter for projection/merge side effects.
22
- const externalPrNumber = job.externalPrNumber ?? (job.prNumber > 0 ? job.prNumber : 0);
23
- const githubEnabled = !options.noGitHub && externalPrNumber > 0 && options.client;
22
+ const githubEnabled = !options.noGitHub && job.prNumber > 0 && options.client;
24
23
  const github = githubEnabled
25
- ? new GitHubStore(options.client, job.repository, externalPrNumber, options.trustedAuthors ?? new Set())
24
+ ? new GitHubStore(options.client, job.repository, job.prNumber, options.trustedAuthors ?? new Set())
26
25
  : new InMemoryStore();
27
26
  return { job, relay, store: new LocalAuthorityDurableStore(relay, github, undefined, !githubEnabled) };
28
27
  };
@@ -28,7 +28,7 @@ export async function reconcileLocalGitCompletion(store, finalState, headSha, no
28
28
  throw new Error('Local Git merge requires an explicit project path');
29
29
  const repoPath = await projectPathForJob(finalState);
30
30
  const delivered = deliverLocalMerge(repoPath, { ...activeReceipt, sourceSha: activeReceipt.sourceSha ?? headSha ?? undefined,
31
- correlationJobId: finalState.id, displayPrNumber: finalState.externalPrNumber ?? (finalState.prNumber > 0 ? finalState.prNumber : undefined) });
31
+ correlationJobId: finalState.id, displayPrNumber: finalState.prNumber > 0 ? finalState.prNumber : undefined });
32
32
  finalState.localMerge = { ...activeReceipt, ...delivered, status: 'succeeded', projectionStrategy: 'target_ref_push',
33
33
  error: undefined, nextAttemptAt: undefined, updatedAt: new Date().toISOString() };
34
34
  }