agents-relay 2.0.168 → 2.0.170

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 (73) hide show
  1. package/README.md +13 -19
  2. package/dist/adapters.js +3 -9
  3. package/dist/blockers.js +3 -2
  4. package/dist/cli-help.js +3 -3
  5. package/dist/cli-job-create.js +2 -4
  6. package/dist/cli-support.js +5 -16
  7. package/dist/cli.js +9 -11
  8. package/dist/dashboard-html.js +5 -9
  9. package/dist/dashboard-style.js +0 -1
  10. package/dist/execution-recovery.js +1 -1
  11. package/dist/force-restate-validation.js +11 -1
  12. package/dist/interactive-resume.js +1 -1
  13. package/dist/job-actions.js +1 -1
  14. package/dist/job-pause.js +1 -1
  15. package/dist/legacy-blocked-task-migration.js +1 -1
  16. package/dist/local-authority-store.js +0 -57
  17. package/dist/local-git-merge.js +1 -2
  18. package/dist/local-merge-delivery.js +1 -1
  19. package/dist/markers.js +5 -6
  20. package/dist/planned-review-head.js +2 -2
  21. package/dist/planner-runtime.js +3 -2
  22. package/dist/planner.js +2 -7
  23. package/dist/pool.js +7 -36
  24. package/dist/reconciler-review.js +4 -5
  25. package/dist/reconciler.js +11 -36
  26. package/dist/relay-create.js +3 -3
  27. package/dist/relay-job-snapshot-reads.js +6 -2
  28. package/dist/relay-store.js +2 -2
  29. package/dist/relay-workflow-operations.js +2 -2
  30. package/dist/relayd-control.js +9 -7
  31. package/dist/relayd-utilities.js +0 -4
  32. package/dist/relayd.js +7 -24
  33. package/dist/runtime-status.js +0 -19
  34. package/dist/single-job-task-control.js +1 -1
  35. package/dist/store.js +1 -1
  36. package/dist/task-actions.js +3 -3
  37. package/dist/task-recovery.js +2 -2
  38. package/dist/task-transitions.js +61 -0
  39. package/dist/troubleshooter-fallback.js +1 -1
  40. package/dist/troubleshooter.js +1 -4
  41. package/dist/types.js +1 -2
  42. package/dist/unblock.js +5 -9
  43. package/dist/worker-rejection.js +1 -1
  44. package/dist/workflow/authoritative-conditions.js +1 -1
  45. package/dist/workflow/blocker-resolution-v2.js +2 -4
  46. package/dist/workflow/contracts.js +0 -1
  47. package/dist/workflow/definitions-v2.js +3 -6
  48. package/dist/workflow/durable-transition.js +1 -1
  49. package/dist/workflow/force-restate-validation-v2.js +2 -1
  50. package/dist/workflow/orchestrator-rescue.js +1 -1
  51. package/dist/workflow/planner-orchestration-v2.js +2 -5
  52. package/dist/workflow/planner-trigger-v2.js +1 -2
  53. package/dist/workflow/review-v2.js +0 -1
  54. package/dist/workflow/task-reconcile.js +1 -7
  55. package/dist/workflow-effect-failures.js +1 -1
  56. package/dist/workspace.js +1 -1
  57. package/package.json +3 -4
  58. package/skills/agents-relay/SKILL.md +6 -6
  59. package/skills/agents-relay/agents/planner.agent.md +3 -3
  60. package/skills/agents-relay/contracts/orchestrator-workflows.md +2 -2
  61. package/skills/agents-relay/contracts/task-job-contract.md +5 -5
  62. package/workflow/blocker-resolution.mmd +1 -7
  63. package/workflow/force-restate-validation.mmd +7 -2
  64. package/workflow/planner-orchestration.mmd +2 -5
  65. package/workflow/planner-trigger.mmd +2 -9
  66. package/workflow/review-lifecycle.mmd +2 -8
  67. package/dist/e2e-injection.js +0 -76
  68. package/dist/scheduled-jobs.js +0 -67
  69. package/dist/scheduler.js +0 -214
  70. package/dist/workflow/schedule-cycle-v2.js +0 -25
  71. package/dist/workflow/scheduled-run-v2.js +0 -30
  72. package/workflow/schedule-cycle.mmd +0 -23
  73. package/workflow/scheduled-run.mmd +0 -59
package/README.md CHANGED
@@ -2,7 +2,7 @@
2
2
 
3
3
  Agents Relay is a TypeScript runtime for durable asynchronous agent Jobs driven locally by SQLite and canonical workflow Graphs. The EventBus wakes reconciliation; an EventBus message or GitHub PR comment is never the lifecycle authority. Job, Plan, Task, Review, Execution, Lease and Recovery state survive daemon restarts in SQLite.
4
4
 
5
- V2 uses a read-only local SQLite Job Registry for discovery. The immutable jobId selects the Job Store even when prNumber is zero; scheduled slot claims are atomic across processes. GitHub is **audit-only**: its PR/head/publish events are persisted for reporting, never consulted to start, stop, or complete local Workflows. Local Git delivery and its confirmed merge receipt—not a remote PR MERGED fact—gate Graph completion. Job creation and local Task operations require no GitHub authentication or PR.
5
+ V2 uses a read-only local SQLite Job Registry for discovery. The immutable jobId selects the Job Store even when prNumber is zero; lifecycle transitions are atomic across processes. GitHub is **audit-only**: its PR/head/publish events are persisted for reporting, never consulted to start, stop, or complete local Workflows. Local Git delivery and its confirmed merge receipt—not a remote PR MERGED fact—gate Graph completion. Job creation and local Task operations require no GitHub authentication or PR.
6
6
 
7
7
 
8
8
  The canonical V2 architecture contract is
@@ -23,7 +23,7 @@ flowchart LR
23
23
  Coordinator --> Graphs[Canonical Mermaid Graph Engine]
24
24
  Graphs --> Effects[Stateless actions / effects]
25
25
  Effects --> Store
26
- Store --> Views[Dashboard / Scheduler / Recovery]
26
+ Store --> Views[Dashboard / Recovery]
27
27
  Effects --> Outbox[Durable external-action outbox]
28
28
  Outbox --> GH[GitHub Commit / PR / Review / Merge API]
29
29
  GH --> Facts[Confirmed external facts]
@@ -46,8 +46,6 @@ it cannot replace the durable SQLite record.
46
46
  | Planner orchestration | [`workflow/planner-orchestration.mmd`](workflow/planner-orchestration.mmd) |
47
47
  | Blocker resolution | [`workflow/blocker-resolution.mmd`](workflow/blocker-resolution.mmd) |
48
48
  | Authoritative unblock | [`workflow/authoritative-unblock.mmd`](workflow/authoritative-unblock.mmd) |
49
- | Scheduled run | [`workflow/scheduled-run.mmd`](workflow/scheduled-run.mmd) |
50
- | Schedule cycle | [`workflow/schedule-cycle.mmd`](workflow/schedule-cycle.mmd) |
51
49
  | Troubleshooter | [`workflow/troubleshooter.mmd`](workflow/troubleshooter.mmd) |
52
50
  | Escalation | [`workflow/escalation.mmd`](workflow/escalation.mmd) |
53
51
  | Related-task action policy | [`workflow/task-related-action.mmd`](workflow/task-related-action.mmd) |
@@ -70,7 +68,7 @@ Standalone Job context. Put a Mermaid block first when a workflow/state/architec
70
68
  EOF
71
69
 
72
70
  npx agents-relay job create --repo OWNER/REPO \
73
- --id job-1 --title "Objective" --objective-file job.md --mode fixed
71
+ --id job-1 --title "Objective" --objective-file job.md
74
72
 
75
73
  # The orchestrator decides the initial batch separately
76
74
  cat > task.md <<'EOF'
@@ -102,24 +100,23 @@ npx agents-relay task update --repo OWNER/REPO --pr 12 --id job-1 --task-id chil
102
100
  npx agents-relay task update --repo OWNER/REPO --pr 12 --id job-1 --task-id child --queue
103
101
  ~~~
104
102
 
105
- Jobs separate durable lifecycle from derived runtime status. Durable lifecycle is `DRAFT | ACTIVE | COMPLETED | CANCELLED`: a newly created unscheduled Job is DRAFT, creating executable work activates it, and scheduled Jobs are ACTIVE because the scheduler is allowed to advance them. Runtime status (`IDLE`, `WAITING`, `QUEUED`, `RUNNING`, `BLOCKED`, `FAILED`, `REVIEWING`, `MERGE_PENDING`, `MERGED`, or `CANCELLED`) is computed from Tasks, Review, PR, and schedule facts and is never persisted as lifecycle truth.
103
+ Jobs separate durable lifecycle from derived runtime status. Durable lifecycle is `DRAFT | ACTIVE | COMPLETED | CANCELLED`: a newly created Job is DRAFT, and creating executable work or starting the Planner activates it. Runtime status (`IDLE`, `WAITING`, `QUEUED`, `RUNNING`, `BLOCKED`, `FAILED`, `REVIEWING`, `MERGE_PENDING`, `MERGED`, or `CANCELLED`) is computed from Tasks, Review, and locally confirmed merge facts and is never persisted as lifecycle truth.
106
104
 
107
105
  Lifecycle transition authority lives in the directly renderable canonical flowchart sources linked in [Workflow graph navigation](#workflow-graph-navigation). Git history owns prior graph versions; the working tree contains only the current graph set.
108
106
 
109
- The canonical Mermaid Graph Engine is the authoritative durable workflow decision path. Job/Task lifecycle transitions plus Review, Planner, Blocker/Unblock, Schedule, and Troubleshooter orchestration branches are defined by the canonical workflow graphs and executed through the shared graph runtime. TypeScript supplies facts and primitive action/effect implementations; it must not duplicate those workflow branches as a second procedural state machine.
107
+ The canonical Mermaid Graph Engine is the authoritative durable workflow decision path. Job/Task lifecycle transitions plus Review, Planner, Blocker/Unblock, and Troubleshooter orchestration branches are defined by the canonical workflow graphs and executed through the shared graph runtime. TypeScript supplies facts and primitive action/effect implementations; it must not duplicate those workflow branches as a second procedural state machine.
110
108
 
111
109
  Job objectives and Task inputs are Markdown. Prefer `--objective-file` / `--input-file` (use `-` for stdin) for durable long-form context; inline `--objective` / `--input` remain for short/simple text and are mutually exclusive with the file variants. When a diagram materially clarifies the work, put Mermaid first and then Markdown detail. A newly created Job must capture enough context to finish independently of the originating chat. Its PR body contains exactly one hidden `agents-relay:job:v1` metadata marker followed by the objective Markdown; there is no separate Job comment duplicating the objective. Objective and Job metadata updates rewrite that same PR body. Legacy Job-comment markers remain readable and are migrated into the PR body on the next Job save.
112
110
 
113
- Jobs default to `fixed`. `job create` persists only the durable job and never
111
+ Every Job is objective-driven. `job create` persists only the durable Job and never
114
112
  creates executable tasks. The caller can either create work directly with
115
113
  `task create` or invoke the Planner explicitly with `plan`. Direct
116
114
  orchestrator-created tasks are persisted directly and are not wrapped in a
117
115
  synthetic Plan. `agents-relay plan` works immediately on a zero-task DRAFT Job,
118
116
  activates it, persists a real `plan-N` planning round, and materializes validated
119
- `next_tasks`. Autonomous reconciliation still does not invent the first round
120
- for an untouched zero-task Job; after explicit work begins, later autonomous
121
- planning rounds reuse the same planning implementation. Fixed jobs never grow
122
- another planning round automatically.
117
+ `next_tasks`. Reconciliation does not invent the first round for an untouched
118
+ zero-task DRAFT Job; once work starts, every Job automatically creates later
119
+ Planner rounds until its latest Plan is satisfied.
123
120
 
124
121
  Manual planning syntax:
125
122
 
@@ -130,8 +127,7 @@ npx agents-relay plan --repo OWNER/REPO --pr 12 --id job-1 --prompt-file guidanc
130
127
 
131
128
  The optional prompt is additional round guidance; it does not replace the durable Job objective. New planning rounds use `plan-N`; historical `planner-N` records remain readable.
132
129
 
133
- The durable status JSON and dashboard show the mode and planning provenance. An
134
- autonomous runtime uses the bundled `skills/agents-relay/agents/planner.agent.md` by default; `--planner-command` is an optional override
130
+ The durable status JSON and dashboard show planning provenance. The runtime uses the bundled `skills/agents-relay/agents/planner.agent.md` by default; `--planner-command` is an optional override
135
131
  snapshot as JSON on stdin and must return `{ "objective_status": "in_progress"|"satisfied", "assessment": "...", "next_tasks": [...] }`.
136
132
  Planner-created tasks use stable IDs and the existing parent/subtask tree, so
137
133
  retries and daemon restarts do not create duplicate work.
@@ -140,9 +136,7 @@ Task producers must follow `skills/agents-relay/contracts/task-job-contract.md`;
140
136
  Execution routing is code-driven: Relay builds complete executable worker+model routes from authoritative runtime/model/resource facts, merges profile defaults with the user's explicit `[choices]`, deduplicates them, removes only impossible routes via hard gates, then calls the generic typed `decide(request, contract)` boundary exactly once with one merged choice list and the `[routing-instruction]` text forwarded verbatim. Task/resource/capability signals and the routing instruction are soft decision facts; decision failure falls back deterministically to a usable Codex default route. See [Execution Router and typed decisions](docs/worker-model-routers.md).
141
137
  All task producers use the same story-size contract: `S`≈5 minutes, `M`≈15 minutes, and `L`≈45 minutes of inactivity, with `M` as the default. `high` reasoning doubles that inactivity window. Accepted `task.started` and newer `task.progress` events from the exact current execution reset it. ChatGPT Browser Worker start-ack timeout is derived as one third of the effective inactivity timeout, clamped between 1 and 5 minutes. Orchestrators and planners should choose `storySize`, not raw timeout milliseconds; `--timeout` remains only as an explicit compatibility/debug override.
142
138
 
143
- Autonomous jobs can also be scheduled. The Job marker stores a five-field cron expression, IANA timezone, next run, and missed-run/concurrency policy. The daemon evaluates the SQLite-backed managed Job view and validates durable schedule facts before claiming a due slot. A repository heartbeat invokes `tick` at 10-minute resolution. See
144
- [Scheduled autonomous jobs](docs/scheduled-autonomous-jobs.md) for the exact
145
- operational contract.
139
+ There is no Job scheduling or cron support. Reconciliation resumes objective planning from durable state, including after a missed wake-up or restart. See [objective-driven Jobs](docs/objective-driven-jobs.md).
146
140
 
147
141
  GitHub projection creates or reuses a display PR after a meaningful source-branch commit; `job adopt` is reserved for existing legacy PR bindings. Re-running it is idempotent: it reuses the PR and maintains exactly one trusted agents-relay:job:v1 marker. Job creation is always create-only and always enables auto-merge; `--no-auto-merge` is not a `job create` option. Existing Jobs can still change that setting with `job update`. Task execution options belong to `task create`, and the orchestrator may create zero, one, or multiple initial Tasks according to user intent.
148
142
 
@@ -294,14 +288,14 @@ flowchart LR
294
288
  Unblock --> Repair
295
289
  ```
296
290
 
297
- The escalation graph is the authority for what happens after Neo returns: `resolved` selects resume recovery, `failed` selects technical-failure handling, and only `needs_user` selects the block-for-user action. Notification mechanics stay caller-described through `--on-needs-user`; Relay does not bind WeChat, Discord, iMessage, or another channel. Only after the failure cause is repaired does Troubleshooter retry the original FAILED task and verify durable progress. Historical failures from older scheduled autonomous runs do not spawn recovery work, and deterministic reconciliation does not duplicate a Troubleshooter task for the same failed attempt.
291
+ The escalation graph is the authority for what happens after Neo returns: `resolved` selects resume recovery, `failed` selects technical-failure handling, and only `needs_user` selects the block-for-user action. Notification mechanics stay caller-described through `--on-needs-user`; Relay does not bind WeChat, Discord, iMessage, or another channel. Only after the failure cause is repaired does Troubleshooter retry the original FAILED task and verify durable progress. Historical resolved failures do not spawn duplicate recovery work, and deterministic reconciliation does not duplicate a Troubleshooter task for the same failed attempt.
298
292
 
299
293
 
300
294
  ### Authoritative unblock and Planner history
301
295
 
302
296
  Dependency release and runtime unblock are separate concepts. Dependency completion simply releases DAG work. A runtime/external blocker remains `BLOCKED` until `task unblock`, `job unblock`, or blocker-Job resolution records the blocked baseline/current evidence and emits `task.unblocked`, transitioning the Task to `UNBLOCKED`.
303
297
 
304
- For autonomous Jobs, `UNBLOCKED` is historical: Relay does not retry that old Task. The unblock evidence becomes the trigger/context of the next Planner round. Every Planner round is a first-class `agents-relay:plan:v1` PR comment. Relay creates that Plan comment before generated Task comments, then updates the same comment with the Planner assessment and generated Task IDs, preserving causal order in the PR timeline. Fixed Jobs may transition `UNBLOCKED -> QUEUED` through retry after the authoritative unblock record exists. Legacy Job `changeCycle` data is accepted only as compatibility input and is not written by the current model.
298
+ For every Job, unblock evidence becomes durable recovery context. Relay retries the same authorized Troubleshooter, while ordinary work uses Planner-owned recovery when necessary. Every Planner round is a first-class `agents-relay:plan:v1` record. It is persisted before generated Tasks, then updated with the Planner assessment and generated Task IDs. Legacy Job `changeCycle` data is accepted only as compatibility input and is not written by the current model.
305
299
 
306
300
 
307
301
  ### Browser Workspace product boundary
package/dist/adapters.js CHANGED
@@ -3,8 +3,6 @@ import { join, relative, resolve, sep, basename } from 'node:path';
3
3
  import { DEFAULT_PROMPT_REFERENCE_DIRECTORY } from './agent-prompt-builder.js';
4
4
  import { pathToFileURL } from 'node:url';
5
5
  import { createHash, randomUUID } from 'node:crypto';
6
- import { AsyncLocalStorage } from 'node:async_hooks';
7
- import { withTestInjection } from './e2e-injection.js';
8
6
  import { taskReferencePrompt } from './task-context.js';
9
7
  import { spawnOwnedProcess, terminateOwnedProcess, ownedProcessTerminationReason } from './owned-process.js';
10
8
  import { PreSubmissionRejection } from './worker-rejection.js';
@@ -62,8 +60,6 @@ export class CodexAdapter {
62
60
  this.command = command;
63
61
  }
64
62
  launch(task, signal, context = {}) {
65
- if (context.testInjection?.method === 'worker.codex.launch')
66
- return withTestInjection('worker.codex.launch', context.testInjection, () => this.launch(task, signal, { ...context, testInjection: undefined }));
67
63
  if (!task.routing)
68
64
  throw new Error(`Task ${task.id} requires routing metadata before Codex launch`);
69
65
  const child = spawnOwnedProcess(this.command, buildCodexArgs(task.routing, task.input), {
@@ -174,9 +170,8 @@ export function parseDriverJson(output) {
174
170
  function browserWorkspaceCli(override = process.env.AGENTS_RELAY_WEBHELM_COMMAND) {
175
171
  return override ? { command: override, prefix: [] } : { command: 'uvx', prefix: ['--from', 'browser-workspace', 'webhelm'] };
176
172
  }
177
- const browserWorkspaceInjection = new AsyncLocalStorage();
178
173
  function browserWorkspaceCommand(cli, args, stdin, signal) {
179
- return Promise.resolve(withTestInjection('browserWorkspace.command', browserWorkspaceInjection.getStore(), () => rawBrowserWorkspaceCommand(cli, args, stdin, signal)));
174
+ return rawBrowserWorkspaceCommand(cli, args, stdin, signal);
180
175
  }
181
176
  function rawBrowserWorkspaceCommand(cli, args, stdin, signal) {
182
177
  return new Promise((resolve, reject) => {
@@ -291,7 +286,7 @@ async function runBrowserWorker(cli, configFile, signal, action = 'new-thread')
291
286
  if (typeof sessionId !== 'string' || !sessionId)
292
287
  throw new Error('webhelm session start returned no session_id');
293
288
  try {
294
- return await browserWorkspaceCommand(cli, ['platform', 'run', 'chatgpt', action, ...(action === 'new-turn' ? ['--mode', 'sync'] : []), '--session-id', sessionId, '--config', configFile], undefined, signal);
289
+ return await browserWorkspaceCommand(cli, ['platform', 'run', 'chatgpt', action, ...(action === 'new-turn' ? ['--mode', 'async'] : []), '--session-id', sessionId, '--config', configFile], undefined, signal);
295
290
  }
296
291
  finally {
297
292
  await browserWorkspaceCommand(cli, ['session', 'stop', sessionId]).catch(() => { });
@@ -409,8 +404,7 @@ export class ChatGptAdapter {
409
404
  tabClosePolicy: policy,
410
405
  }, route, controller.signal);
411
406
  };
412
- const run = await (context.testInjection?.method === 'browserWorkspace.command'
413
- ? browserWorkspaceInjection.run(context.testInjection, submit) : submit());
407
+ const run = await submit();
414
408
  try {
415
409
  // Start acknowledgement may expire while browser submission is still pending.
416
410
  // Race its rejection with delivery so a hung browser cannot hold a QUEUED
package/dist/blockers.js CHANGED
@@ -103,7 +103,6 @@ export async function resolveBlockerDependencies(options) {
103
103
  const decision = await dispatchBlockerResolution({
104
104
  resolutionAllowed: !(mode === 'verified' && options.resolution !== 'verified'),
105
105
  targetMatches,
106
- autonomousJob: targetJob?.executionMode === 'autonomous',
107
106
  effectivelyBlocked: !!task && isEffectivelyBlocked(task),
108
107
  alreadyUnblocked: Boolean(task?.unblock && ['DRAFT', 'QUEUED'].includes(task.state)),
109
108
  isDraft: task?.state === 'DRAFT', isBlocked: Boolean(task && isEffectivelyBlocked(task)),
@@ -139,7 +138,9 @@ export async function resolveBlockerDependencies(options) {
139
138
  relation.resolution = options.resolution;
140
139
  relation.note = note;
141
140
  unblocked.push({ repository: relation.repository, prNumber: relation.prNumber, jobId: relation.jobId, taskId: relation.taskId });
142
- resumed += 1;
141
+ // A durable unblock may have committed before this relation was acknowledged.
142
+ if (!(task?.unblock && ['DRAFT', 'QUEUED'].includes(task.state)))
143
+ resumed += 1;
143
144
  }
144
145
  const remaining = relations.filter(relation => relation.state === 'waiting');
145
146
  const consumed = remaining.length !== relations.length;
package/dist/cli-help.js CHANGED
@@ -62,7 +62,7 @@ Optional: --thread-id THREAD_ID persists a reviewer override; omitted uses Job r
62
62
  'job create': `Create a SQLite-authoritative Job (objective-only).
63
63
 
64
64
  Required: --repo OWNER/REPO and a standalone Job objective. Job ID defaults to an auto-incrementing store-global number; --id may only reference an existing numeric Job. Existing PR adoption is an explicit separate command: job adopt.
65
- Common: --id JOB_ID (optional), --title TEXT, --objective-file PATH|- (preferred), --objective MARKDOWN (short/simple only), --project-path RELATIVE_PATH, --priority P0|P1|P2|P3, --mode fixed|autonomous, --initial-plan, --no-review, --tag JSON, --reviewer-thread-id ID, --creator-thread-id ID, --implementation-thread-id ID. --objective and --objective-file are mutually exclusive.
65
+ Common: --id JOB_ID (optional), --title TEXT, --objective-file PATH|- (preferred), --objective MARKDOWN (short/simple only), --project-path RELATIVE_PATH, --priority P0|P1|P2|P3, --initial-plan, --no-review, --tag JSON, --reviewer-thread-id ID, --creator-thread-id ID, --implementation-thread-id ID. --objective and --objective-file are mutually exclusive.
66
66
  Job creation always enables auto-merge. By default it is create-only; --initial-plan validates the first real Planner round before atomically creating the durable Job and Plan, then publishes the normal wake so Relay can continue without a second orchestrator command. Re-running create against an already healthy Job is idempotent and does not mutate its metadata.
67
67
 
68
68
  Example:
@@ -73,7 +73,7 @@ Required: --repo OWNER/REPO and --pr NUMBER. The Job ID is allocated atomically
73
73
  'job update': `Partially update mutable metadata on a healthy existing Job.
74
74
 
75
75
  Required: --repo OWNER/REPO, --pr NUMBER, and --id JOB_ID.
76
- Mutable: --title, --objective-file PATH|- (preferred) or --objective MARKDOWN, --project-path RELATIVE_PATH, --priority, --mode fixed|autonomous, --schedule, --tag JSON, --auto-merge|--no-auto-merge, --review|--no-review, --reviewer-thread-id ID, --creator-thread-id ID, --implementation-thread-id ID, and continuation options. The objective updates local SQLite; PR body changes are independent external actions.
76
+ Mutable: --title, --objective-file PATH|- (preferred) or --objective MARKDOWN, --project-path RELATIVE_PATH, --priority, --tag JSON, --auto-merge|--no-auto-merge, --review|--no-review, --reviewer-thread-id ID, --creator-thread-id ID, --implementation-thread-id ID, and continuation options. The objective updates local SQLite; PR body changes are independent external actions.
77
77
  Unspecified fields are preserved. Local Job/PR identity mismatch is rejected.
78
78
 
79
79
  Example:
@@ -226,7 +226,7 @@ Example:
226
226
  npx agents-relay reconcile --repo OWNER/REPO --pr 12 --id 101`,
227
227
  init: `Initialize a new job. GitHub mode requires --repo and --pr; local mode requires explicit --file.
228
228
 
229
- Common: --id ID, --title TEXT, --objective TEXT, --mode fixed|autonomous, --priority P0|P1|P2|P3.
229
+ Common: --id ID, --title TEXT, --objective TEXT, --priority P0|P1|P2|P3. All Jobs replan to an explicit satisfied objective; execution modes and schedules are unsupported.
230
230
 
231
231
  Example:
232
232
  npx agents-relay init --repo OWNER/REPO --pr 12 --id 101 --title "Objective"`,
@@ -56,7 +56,6 @@ export async function ensureManagedGitHubJob(_client, repositoryName, _trustedAu
56
56
  projectPath: options.projectPath,
57
57
  description: pr.body || undefined,
58
58
  priority: options.priority ?? 'P2',
59
- executionMode: options.executionMode ?? 'fixed',
60
59
  tag: options.tag,
61
60
  autoMerge: true,
62
61
  ...(localMergeSource ? { localMerge: { sourceRef: localMergeSource, targetRef: 'refs/heads/main', status: 'pending' } } : {}),
@@ -76,8 +75,7 @@ export async function ensureManagedGitHubJob(_client, repositoryName, _trustedAu
76
75
  createdAt: now,
77
76
  updatedAt: now,
78
77
  tasks: [],
79
- ...(options.schedule ? { schedule: options.schedule } : {}),
80
- }, { activate: Boolean(options.schedule) });
78
+ });
81
79
  if (options.persist !== false) {
82
80
  if (local.store.createJobIfAbsent)
83
81
  await local.store.createJobIfAbsent(job);
@@ -117,7 +115,7 @@ export async function adoptExistingPullRequestJob(_client, repositoryName, _trus
117
115
  return { job: existing, pr: { number: existing.prNumber, title: existing.title, body: existing.objective ?? '', state: 'OPEN', head: '', base: 'main' } };
118
116
  const now = new Date().toISOString();
119
117
  const job = createJobEntity({ id, title: options.title ?? id, objective: options.objective, projectPath: options.projectPath,
120
- description: options.objective, priority: 'P2', executionMode: 'fixed', autoMerge: true, reviewEnabled: true,
118
+ description: options.objective, priority: 'P2', autoMerge: true, reviewEnabled: true,
121
119
  prNumber: options.prNumber, repository: repositoryName, continuation: null, createdAt: now, updatedAt: now, tasks: [] });
122
120
  const created = local.store.createJobIfAbsent ? await local.store.createJobIfAbsent(job) : (await local.store.saveJob(job), job);
123
121
  return { job: created, pr: { number: options.prNumber, title: created.title, body: created.objective ?? '', state: 'OPEN', head: '', base: 'main' } };
@@ -6,7 +6,6 @@ import { basename } from 'node:path';
6
6
  import { createJobIdRegistryResolver } from './local-job-registry.js';
7
7
  import { relayStoreDirectory } from './relay-store.js';
8
8
  import { NatsEventBus, eventFor } from './events.js';
9
- import { parseJobSchedule } from './scheduler.js';
10
9
  import { parseJobTagJson } from './task-tag.js';
11
10
  import { resolveEventsBusRuntime } from './events-bus-runtime.js';
12
11
  export const SERVICE_DEFAULTS = { port: 8787, watchdogMs: 300000, webhookWatchdogMs: 300000, dashboardRefreshMs: 300000, projectionFlushMs: 1000 };
@@ -277,27 +276,17 @@ export function priority(args, fallback = 'P2') { const value = arg(args, '--pri
277
276
  throw new Error('--priority must be P0, P1, P2, or P3'); return value; }
278
277
  export function storySize(args, fallback = 'M') { const value = arg(args, '--story-size', fallback).toUpperCase(); if (!['S', 'M', 'L'].includes(value))
279
278
  throw new Error('--story-size must be S, M, or L'); return value; }
280
- export function executionMode(args, fallback = 'fixed') { const value = arg(args, '--mode', arg(args, '--execution-mode', fallback)); if (value !== 'fixed' && value !== 'autonomous')
281
- throw new Error('--mode must be fixed or autonomous'); return value; }
282
279
  export function chatGptTabClosePolicy(args) {
283
280
  const value = arg(args, '--chatgpt-tab-close-policy', process.env.AGENTS_RELAY_CHATGPT_TAB_CLOSE_POLICY || 'after-start');
284
281
  if (!['after-start', 'never', 'after-terminal'].includes(value))
285
282
  throw new Error('--chatgpt-tab-close-policy must be after-start, never, or after-terminal');
286
283
  return value;
287
284
  }
288
- export function requestedExecutionMode(args) { const value = arg(args, '--mode', arg(args, '--execution-mode')); return value ? executionMode(args) : undefined; }
289
285
  export function jobTagFromArgs(args) { return parseJobTagJson(arg(args, '--tag')); }
290
286
  export function continuation(args) { const kind = arg(args, '--continue-kind'); const target = arg(args, '--continue-target'); return kind && target ? { kind: kind, target, input: arg(args, '--continue-input') || undefined } : null; }
291
- export function scheduleFromArgs(args) {
292
- const cron = arg(args, '--schedule');
293
- if (!cron)
294
- return undefined;
295
- const nextRunAt = arg(args, '--schedule-next-run') || undefined;
296
- return parseJobSchedule({
297
- cron,
298
- timezone: arg(args, '--schedule-timezone', 'UTC'),
299
- nextRunAt,
300
- missedRunPolicy: arg(args, '--missed-run-policy', 'skip'),
301
- concurrencyPolicy: arg(args, '--schedule-concurrency', 'skip'),
302
- });
287
+ /** Removed Job options fail closed rather than silently accepting obsolete scheduling. */
288
+ export function rejectRemovedJobOptions(args) {
289
+ const obsolete = ['--mode', '--execution-mode', '--schedule', '--schedule-next-run', '--schedule-timezone', '--missed-run-policy', '--schedule-concurrency', '--cron', '--schedule-cron'].find(option => hasArg(args, option));
290
+ if (obsolete)
291
+ throw new Error(`Unsupported Job option ${obsolete}: every Job replans to objective satisfaction and scheduling is unavailable`);
303
292
  }
package/dist/cli.js CHANGED
@@ -41,8 +41,8 @@ export { helpText } from './cli-help.js';
41
41
  import { runReviewCommand } from './cli-review.js';
42
42
  import { normalizeThreadId, runtimeReviewer } from './reviewer-thread.js';
43
43
  export { runtimeReviewer } from './reviewer-thread.js';
44
- import { SERVICE_DEFAULTS, ServiceEventBus, arg, hasArg, textOption, integerArg, repository, storeFor, saveLocal, eventBus, publishWake, priority, storySize, executionMode, chatGptTabClosePolicy, requestedExecutionMode, continuation, scheduleFromArgs, jobTagFromArgs, requestManagedReconciliation, relayControlJob, relayControlCommand, relayJobList, relayJobStateCommand, relayTaskCommand, withEventBus } from './cli-support.js';
45
- export { SERVICE_DEFAULTS, eventBus, executionMode, chatGptTabClosePolicy } from './cli-support.js';
44
+ import { SERVICE_DEFAULTS, ServiceEventBus, arg, hasArg, textOption, integerArg, repository, storeFor, saveLocal, eventBus, publishWake, priority, storySize, chatGptTabClosePolicy, continuation, rejectRemovedJobOptions, jobTagFromArgs, requestManagedReconciliation, relayControlJob, relayControlCommand, relayJobList, relayJobStateCommand, relayTaskCommand, withEventBus } from './cli-support.js';
45
+ export { SERVICE_DEFAULTS, eventBus, chatGptTabClosePolicy } from './cli-support.js';
46
46
  import { recoveryDiagnosisFromArgs, workerTaskFromArgs } from './cli-worker-task.js';
47
47
  import { taskExecutionContext } from './task-context.js';
48
48
  import { requestTroubleshooterEscalation, resumeTroubleshooter } from './task-recovery.js';
@@ -470,12 +470,13 @@ async function runJobCommand(action, args) {
470
470
  if (prValue && (!Number.isInteger(prNumber) || (prNumber ?? 0) <= 0))
471
471
  throw new Error('--pr must be a positive integer');
472
472
  if (action === 'create') {
473
+ rejectRemovedJobOptions(args);
473
474
  const objective = createObjective ?? '';
474
475
  if (hasArg(args, '--local-merge-source') !== hasArg(args, '--local-merge-target'))
475
476
  throw new Error('Supply both --local-merge-source and --local-merge-target');
476
477
  const result = await relayControlCommand(args, { command: 'job.create', repository: repo, initialPlan: hasArg(args, '--initial-plan'), options: {
477
478
  id: id || undefined, idempotencyKey: arg(args, '--idempotency-key') || undefined, title: arg(args, '--title') || undefined, objective: objective || undefined, projectPath: arg(args, '--project-path') || undefined,
478
- priority: priority(args), executionMode: requestedExecutionMode(args), schedule: scheduleFromArgs(args), tag: jobTagFromArgs(args), reviewEnabled: !hasArg(args, '--no-review'), continuation: continuation(args),
479
+ priority: priority(args), tag: jobTagFromArgs(args), reviewEnabled: !hasArg(args, '--no-review'), continuation: continuation(args),
479
480
  reviewerThreadId: hasArg(args, '--reviewer-thread-id') ? arg(args, '--reviewer-thread-id') : undefined, creatorThreadId: hasArg(args, '--creator-thread-id') ? arg(args, '--creator-thread-id') : undefined, implementationThreadId: hasArg(args, '--implementation-thread-id') ? arg(args, '--implementation-thread-id') : undefined,
480
481
  } });
481
482
  console.log(JSON.stringify(result, null, 2));
@@ -520,10 +521,7 @@ async function runJobCommand(action, args) {
520
521
  configureLocalGitMerge(job, arg(args, '--local-merge-source'), arg(args, '--local-merge-target'), noGitHubMode(args));
521
522
  if (hasArg(args, '--priority'))
522
523
  job.priority = priority(args, job.priority ?? 'P2');
523
- if (hasArg(args, '--mode') || hasArg(args, '--execution-mode'))
524
- job.executionMode = executionMode(args, job.executionMode ?? 'fixed');
525
- if (hasArg(args, '--schedule'))
526
- job.schedule = scheduleFromArgs(args);
524
+ rejectRemovedJobOptions(args);
527
525
  if (hasArg(args, '--tag'))
528
526
  job.tag = jobTagFromArgs(args);
529
527
  if (hasArg(args, '--auto-merge') && hasArg(args, '--no-auto-merge'))
@@ -551,7 +549,7 @@ async function runJobCommand(action, args) {
551
549
  job.continuation = next;
552
550
  }
553
551
  const patch = {};
554
- for (const key of ['title', 'objective', 'projectPath', 'priority', 'executionMode', 'schedule', 'tag', 'autoMerge', 'reviewEnabled', 'reviewerThreadId', 'creatorThreadId', 'implementationThreadId', 'continuation', 'localMerge'])
552
+ for (const key of ['title', 'objective', 'projectPath', 'priority', 'tag', 'autoMerge', 'reviewEnabled', 'reviewerThreadId', 'creatorThreadId', 'implementationThreadId', 'continuation', 'localMerge'])
555
553
  patch[key] = job[key];
556
554
  console.log(JSON.stringify(await relayControlCommand(args, { command: 'job.update', jobId: id, patch }), null, 2));
557
555
  }
@@ -771,16 +769,16 @@ async function main() {
771
769
  }
772
770
  if (command === 'init') {
773
771
  const file = arg(args, '--file');
772
+ rejectRemovedJobOptions(args);
774
773
  const now = new Date().toISOString();
775
- const initialSchedule = scheduleFromArgs(args);
776
774
  const id = arg(args, '--id', randomUUID());
777
775
  if (!file) {
778
776
  const repo = repository(args);
779
- const result = await relayControlCommand(args, { command: 'job.create', repository: repo, options: { id, title: arg(args, '--title', 'Agents Relay job'), objective: arg(args, '--objective') || undefined, priority: priority(args), executionMode: executionMode(args), schedule: initialSchedule, reviewEnabled: !hasArg(args, '--no-review'), continuation: continuation(args), prNumber: Number(arg(args, '--pr', '0')) || 0 } });
777
+ const result = await relayControlCommand(args, { command: 'job.create', repository: repo, options: { id, title: arg(args, '--title', 'Agents Relay job'), objective: arg(args, '--objective') || undefined, priority: priority(args), reviewEnabled: !hasArg(args, '--no-review'), continuation: continuation(args), prNumber: Number(arg(args, '--pr', '0')) || 0 } });
780
778
  console.log(JSON.stringify(result, null, 2));
781
779
  return;
782
780
  }
783
- const job = createJobEntity({ id, title: arg(args, '--title', 'Agents Relay job'), objective: arg(args, '--objective') || undefined, priority: priority(args), executionMode: executionMode(args), schedule: initialSchedule, autoMerge: !hasArg(args, '--no-auto-merge'), reviewEnabled: !hasArg(args, '--no-review'), prNumber: Number(arg(args, '--pr', '0')), repository: arg(args, '--repo'), continuation: continuation(args), createdAt: now, updatedAt: now, tasks: [] }, { activate: Boolean(initialSchedule) });
781
+ const job = createJobEntity({ id, title: arg(args, '--title', 'Agents Relay job'), objective: arg(args, '--objective') || undefined, priority: priority(args), autoMerge: !hasArg(args, '--no-auto-merge'), reviewEnabled: !hasArg(args, '--no-review'), prNumber: Number(arg(args, '--pr', '0')), repository: arg(args, '--repo'), continuation: continuation(args), createdAt: now, updatedAt: now, tasks: [] });
784
782
  const store = new InMemoryStore(job);
785
783
  await store.saveJob(job);
786
784
  await saveLocal(file, job);
@@ -41,7 +41,7 @@ ${dashboardStyles}
41
41
  </header>
42
42
  <main>
43
43
  <section id="jobs-view" role="tabpanel" aria-labelledby="jobs-tab" class="panel jobs-panel">
44
- <div class="panel-head"><h2>Jobs</h2><div class="panel-actions"><div class="job-filters"><input id="job-search" class="job-search" type="search" placeholder="Search title or PR #" aria-label="Search jobs by title or PR number"><select id="quick-status" class="job-filter" aria-label="Filter jobs by derived status"><option value="">STATUS</option></select><button id="quick-autonomous" class="quick-filter" type="button" aria-pressed="false">AUTONOMOUS</button><button id="quick-draft" class="quick-filter" type="button" aria-pressed="false">DRAFT</button><select id="repo-filter" class="job-filter" aria-label="Filter jobs by repository"><option value="">All repos</option></select></div><button id="jobs-refresh" class="refresh-button" type="button">Refresh</button></div></div>
44
+ <div class="panel-head"><h2>Jobs</h2><div class="panel-actions"><div class="job-filters"><input id="job-search" class="job-search" type="search" placeholder="Search title or PR #" aria-label="Search jobs by title or PR number"><select id="quick-status" class="job-filter" aria-label="Filter jobs by derived status"><option value="">STATUS</option></select><button id="quick-draft" class="quick-filter" type="button" aria-pressed="false">DRAFT</button><select id="repo-filter" class="job-filter" aria-label="Filter jobs by repository"><option value="">All repos</option></select></div><button id="jobs-refresh" class="refresh-button" type="button">Refresh</button></div></div>
45
45
  <div class="panel-body" aria-live="polite"><ul id="job-list" class="job-list"><li class="skeleton" aria-hidden="true"></li></ul></div>
46
46
  </section>
47
47
  <div class="workspace" id="jobs-workspace">
@@ -144,7 +144,6 @@ const jobsRefresh=document.querySelector('#jobs-refresh');
144
144
  const jobSearch=document.querySelector('#job-search');
145
145
  const repoFilter=document.querySelector('#repo-filter');
146
146
  const quickStatus=document.querySelector('#quick-status');
147
- const quickAutonomous=document.querySelector('#quick-autonomous');
148
147
  const quickDraft=document.querySelector('#quick-draft');
149
148
  const selectedJobRefresh=document.querySelector('#selected-job-refresh');
150
149
  const mobileJobsBack=document.querySelector('#mobile-jobs-back');
@@ -203,7 +202,6 @@ function selected(){return jobForSelection(selectedJob)||jobs[0]||null;}
203
202
  function counts(tasks){const out={};for(const task of Array.isArray(tasks)?tasks:[])out[task.state]=(out[task.state]||0)+1;return out;}
204
203
  function compactCounts(input){const order=['RUNNING','BLOCKED','FAILED','QUEUED','DRAFT','SUCCEEDED','CANCELLED'];return order.filter(state=>input[state]).map(state=>input[state]+' '+state).join(', ')||'No tasks';}
205
204
  function relativeTime(timestamp){if(!timestamp)return '—';const parsed=Date.parse(timestamp);if(!Number.isFinite(parsed))return value(timestamp);const seconds=Math.max(0,Math.round((Date.now()-parsed)/1000));if(seconds<60)return 'just now';if(seconds<3600)return Math.floor(seconds/60)+'m ago';if(seconds<86400)return Math.floor(seconds/3600)+'h ago';return Math.floor(seconds/86400)+'d ago';}
206
- function cadence(job){const schedule=job?.schedule;if(!schedule?.cron)return '—';const cron=String(schedule.cron).trim();const timezone=value(schedule.timezone||'UTC');const fields=cron.split(/\\s+/);let label=cron;if(fields.length===5){const [minute,hour,day,month,weekday]=fields;if(day==='*'&&month==='*'&&weekday==='*'){const everyMinute=minute.match(/^\\*\\/(\\d+)$/);if(everyMinute&&hour==='*')label='Every '+everyMinute[1]+' min';else if(minute==='0'&&hour==='*')label='Hourly';else if(/^\\d+$/.test(minute)&&/^\\d+$/.test(hour)){const hh=String(Number(hour)).padStart(2,'0');const mm=String(Number(minute)).padStart(2,'0');label='Daily '+hh+':'+mm;}}}return label+' · '+cron+' · '+timezone;}
207
205
  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;}
208
206
  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;}
209
207
  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;}
@@ -216,7 +214,7 @@ function clearEvents(){displayedEventIds.clear();eventFeed.replaceChildren(eleme
216
214
  async function loadEventHistory(){const job=selected();if(!job){clearEvents();return;}const params=new URLSearchParams({job:job.id,limit:String(MAX_EVENTS)});if(eventTaskId)params.set('task',eventTaskId);try{const history=await loadJSON('/api/events?'+params.toString());clearEvents();for(const event of Array.isArray(history)?history.slice().reverse():[])addEvent(event);}catch{clearEvents();}}
217
215
  function copySelectedJobIdentity(job){if(!job||!navigator.clipboard?.writeText)return;void navigator.clipboard.writeText(repoName(job.repository)+'/'+value(job.id)).catch(()=>{});}
218
216
  function selectJob(job){selectedJob=jobSelectionKey(job);selectedTaskId=null;eventTaskId=null;rememberSelection();clearEvents();location.hash=selectedJob?'#/jobs/'+encodeURIComponent(selectedJob):'#/jobs';copySelectedJobIdentity(job);renderAll();void loadEventHistory();}
219
- function matchesQuickFilter(job){if(quickMode==='autonomous')return job.executionMode==='autonomous';if(quickMode==='draft')return job.draft||job.state==='DRAFT';if(quickMode==='status')return !!selectedRuntimeStatus&&(job.runtimeStatus||'IDLE')===selectedRuntimeStatus;return true;}
217
+ function matchesQuickFilter(job){if(quickMode==='draft')return job.draft||job.state==='DRAFT';if(quickMode==='status')return !!selectedRuntimeStatus&&(job.runtimeStatus||'IDLE')===selectedRuntimeStatus;return true;}
220
218
  function matchesSearch(job){const query=searchQuery.trim().toLowerCase();if(!query)return true;const pr=String(job.prNumber||'');const normalized=query.replace(/^pr\s*#?/,'').replace(/^#/,'');return String(job.title||job.id||'').toLowerCase().includes(query)||String(job.id||'').toLowerCase().includes(query)||shortManagedId(job.id,job.repository,'j').toLowerCase().includes(query)||pr.includes(normalized);}
221
219
  function filteredJobs(){return jobs.filter(job=>(!selectedRepo||job.repository===selectedRepo)&&matchesQuickFilter(job)&&matchesSearch(job));}
222
220
  function setQuickMode(mode,status=''){quickMode=mode;selectedRuntimeStatus=mode==='status'?status:'';sessionStorage.setItem('agents-relay:quick-filter',quickMode);if(selectedRuntimeStatus)sessionStorage.setItem('agents-relay:runtime-status-filter',selectedRuntimeStatus);else sessionStorage.removeItem('agents-relay:runtime-status-filter');syncFilters();renderJobList();}
@@ -233,7 +231,6 @@ function syncFilters(){
233
231
  selectedRuntimeStatus=statuses.includes(previousStatus)?previousStatus:'';
234
232
  if(quickMode==='status'&&!selectedRuntimeStatus)quickMode='jobs';
235
233
  quickStatus.value=quickMode==='status'?selectedRuntimeStatus:'';
236
- quickAutonomous.setAttribute('aria-pressed',String(quickMode==='autonomous'));
237
234
  quickDraft.setAttribute('aria-pressed',String(quickMode==='draft'));
238
235
  jobSearch.value=searchQuery;
239
236
  }
@@ -250,7 +247,7 @@ function jobChatRef(job){const pr=Number(job?.prNumber||0);return repoName(job?.
250
247
  function chatShortcutUrl(prompt){return 'https://chatgpt.com/?prompt='+encodeURIComponent(prompt);}
251
248
  const CHAT_SHORTCUT_TARGET='agents-relay-shortcut';
252
249
  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;}
253
- 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)+'/'+value(job.id)+(Number(job.prNumber||0)>0?'/PR#'+value(job.prNumber):'')+': '+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(job.localMerge?.status==='failed')states.append(element('span','badge FAILED',text('MERGE FAILED: '+value(job.localMerge.error||'Unknown error'))));else 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));}}
250
+ 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)+'/'+value(job.id)+(Number(job.prNumber||0)>0?'/PR#'+value(job.prNumber):'')+': '+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.state==='ACTIVE'&&job.reviewEnabled!==false)states.append(element('span','badge flag-review',text('REVIEW REQUIRED')));if(job.localMerge?.status==='failed')states.append(element('span','badge FAILED',text('MERGE FAILED: '+value(job.localMerge.error||'Unknown error'))));else 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));}}
254
251
  function taskTagDisplay(job,task){const display=task.tag&&job?.tag?.values?.[task.tag];return typeof display==='string'&&display?display:null;}
255
252
  function taskVerdict(job,task){if(task.state!=="SUCCEEDED")return null;const data=task.result?.data||{};if(task.agentName==="reviewer"){const verdict=data.verdict||((job?.review?.taskId===task.id)?job.review.outcome:null);return ({approved:"APPROVED",pass:"APPROVED","changes-required":"CHANGES REQUIRED",changes_required:"CHANGES REQUIRED"})[verdict]||null;}if(task.agentName==="troubleshooter"){const original=job?.tasks?.find(t=>t.id===task.parentTaskId);const resolution=original?.resolution;if(resolution?.state!=="resolved"||resolution.owner!=="troubleshooter")return null;return ({exact_original_retry:"RETRY VERIFIED",same_job_dependency:"DEPENDENCY REGISTERED",blocker_job_dependency:"BLOCKER REGISTERED",cancel_supersede:"SUPERSEDED"})[resolution.path]||null;}return null;}function taskSubtreeComplete(task,children){return ['SUCCEEDED','CANCELLED'].includes(task.state)&&(children.get(task.id)||[]).every(child=>taskSubtreeComplete(child,children));}
256
253
  function taskStateLabel(task){const resolution=task?.resolution?.state;return task?.state==='FAILED'&&typeof resolution==='string'&&resolution.trim()?task.state+' / '+resolution:task.state;}
@@ -265,10 +262,10 @@ async function setTaskState(task,state,select){const job=selected();if(!job||sta
265
262
  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)select.append(new Option(state,state));select.value=task.state;select.addEventListener('change',()=>void setTaskState(task,select.value,select));box.append(select);return box;}
266
263
  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):'—');}
267
264
  function showDetailsDialog(title,sections){const dialog=element('dialog','details-dialog');dialog.setAttribute('aria-label',title);const head=element('div','details-dialog-head');const name=element('strong','',text(title));const close=element('button','refresh-button',text('Close'));close.type='button';close.addEventListener('click',()=>dialog.close());head.append(name,close);dialog.append(head);for(const [label,value] of sections){if(value===undefined||value===null||value==='')continue;dialog.append(element('h3','',text(label)));const content=element('pre');content.textContent=typeof value==='string'?value:JSON.stringify(value,null,2);dialog.append(content);}dialog.addEventListener('close',()=>dialog.remove());document.body.append(dialog);dialog.showModal();}function openJobDetails(){const job=selected();if(!job)return;showDetailsDialog('Job '+job.id+' · '+value(job.title),[['Objective',job.objective],['Description',job.description],['State',job.state],['Plans',job.plans?.map(p=>({id:p.id,status:p.status,assessment:p.assessment,error:p.error,generatedTaskIds:p.generatedTaskIds}))],['Review',job.review],['Outcome',job.result],['Publication',job.publication],['Failure',job.error]]);}function openTaskDetails(task){const result=task.result?.data??task.result;showDetailsDialog('Task '+task.id+' · '+value(task.title),[['Input / task objective',task.input],['State',task.state],['Conclusion',result],['Result summary',task.result?.summary],['Planner assessment',task.plannerResult?.assessment],['Resolution',task.resolution],['Resolution classification',task.resolutionClassification],['Failure',task.failureReason||task.error],['Dependencies',task.dependencies]]);}document.querySelector('#job-content-open').addEventListener('click',openJobDetails);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),...(task.state==='FAILED'&&task.resolution?.state?[field('Resolution state',task.resolution.state),field('Resolution path',task.resolution.path||'—'),field('Resolution target',task.resolution.targetTaskId||'—')]:[]),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'),...(task.resolutionClassification?[field('Resolution classification',task.resolutionClassification.kind),field('Resolution target',task.resolutionClassification.targetTaskId),field('Resolution evidence',task.resolutionClassification.evidence),field('Resolution reason',task.resolutionClassification.reason)]:[]),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));const result=task.result?.data??task.result;if(result){groups.append(field('Conclusion',result.verdict||result.outcome?.verdict||task.result?.summary||'Available'));}taskDetailRoot.append(groups);const details=element('button','refresh-button',text('Task details / conclusion'));details.type='button';details.addEventListener('click',()=>openTaskDetails(task));taskDetailRoot.append(details);}
268
- 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();}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||'—'),threadField('Implementation thread',job.implementationThreadId),threadField('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();}function renderAll(){jobs.sort(compareJobs);syncFilters();renderJobList();renderSelectedJob();syncMobileJobsView();}
265
+ 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();}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('Review',reviewStatus(job)),field('Creator thread',job.creatorThreadId||'—'),threadField('Implementation thread',job.implementationThreadId),threadField('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();}function renderAll(){jobs.sort(compareJobs);syncFilters();renderJobList();renderSelectedJob();syncMobileJobsView();}
269
266
  function isCompleteUnmerged(job){return job.runtimeStatus==='MERGE_PENDING';}
270
267
  function isTerminalJob(job){return ['COMPLETED','CANCELLED'].includes(job.state)||['MERGED','CLOSED'].includes(job.githubState);}
271
- function jobGroup(job){if(isTerminalJob(job))return 3;if(job.executionMode==='autonomous')return 1;if(job.draft||job.state==='DRAFT')return 2;return 0;}
268
+ function jobGroup(job){if(isTerminalJob(job))return 3;if(job.draft||job.state==='DRAFT')return 2;return 0;}
272
269
  function compareJobs(a,b){const groupDelta=jobGroup(a)-jobGroup(b);if(groupDelta)return groupDelta;const mergePendingDelta=Number(isCompleteUnmerged(b))-Number(isCompleteUnmerged(a));return mergePendingDelta||String(b.updatedAt).localeCompare(String(a.updatedAt));}
273
270
  async function loadJSON(url){const response=await fetch(url);if(!response.ok)throw new Error(response.status+' '+response.statusText);return response.json();}
274
271
  async function refreshVersion(){try{const live=await loadJSON('/api/version');if(live?.version&&live.version!==DASHBOARD_VERSION)location.reload();}catch{}}
@@ -343,7 +340,6 @@ jobsRefresh.addEventListener('click',()=>void refresh(true));
343
340
  eventsAllTasks.addEventListener('click',()=>{eventTaskId=null;clearEvents();renderEventScope();void loadEventHistory();});
344
341
  repoFilter.addEventListener('change',()=>{selectedRepo=repoFilter.value;sessionStorage.setItem('agents-relay:repo-filter',selectedRepo);renderJobList();});
345
342
  jobSearch.addEventListener('input',()=>{searchQuery=jobSearch.value;sessionStorage.setItem('agents-relay:job-search',searchQuery);renderJobList();});
346
- quickAutonomous.addEventListener('click',()=>toggleQuickMode('autonomous'));
347
343
  quickDraft.addEventListener('click',()=>toggleQuickMode('draft'));
348
344
  quickStatus.addEventListener('change',()=>setQuickMode(quickStatus.value?'status':'jobs',quickStatus.value));
349
345
  selectedJobRefresh.addEventListener('click',()=>void refreshSelected());
@@ -149,7 +149,6 @@ export const dashboardStyles = ` <style>
149
149
  #jobs-view .job-filters { width:auto; }
150
150
  #jobs-view .job-search,
151
151
  #jobs-view #quick-status,
152
- #jobs-view #quick-autonomous,
153
152
  #jobs-view #quick-draft { display:none; }
154
153
  #jobs-view .panel-body { max-height:none; }
155
154
  #jobs-workspace { gap:.65rem; }
@@ -1,4 +1,4 @@
1
- import { transitionTaskEvent } from './scheduler.js';
1
+ import { transitionTaskEvent } from './task-transitions.js';
2
2
  import { reclaimStaleExecutionProcess } from './execution-process-tracker.js';
3
3
  async function currentGeneration(store, job, stale) {
4
4
  const task = (await store.load(job.id)).tasks.find(item => item.id === stale.id);
@@ -1,5 +1,5 @@
1
1
  import { classifyTaskResolution } from './workflow/task-resolution.js';
2
- import { tryTransitionTaskEvent } from './scheduler.js';
2
+ import { tryTransitionTaskEvent } from './task-transitions.js';
3
3
  import { satisfiesWorker } from './worker-routing.js';
4
4
  function dependencySatisfied(task, byId) {
5
5
  return task.dependencies.every(id => {
@@ -68,6 +68,16 @@ export function forceRestateConverged(job, planId, candidates) {
68
68
  && job.tasks.every(task => task.kind === 'planner' || !['DRAFT', 'QUEUED', 'RUNNING', 'BLOCKED'].includes(task.state));
69
69
  return completed || forceRestateRunnable(job, candidates) || forceRestateDependencyWaiting(job, candidates) || forceRestateBlocked(job, planId);
70
70
  }
71
+ /** A satisfied objective can enter Relay-owned Review before Job completion.
72
+ * Never treat completed Planner execution alone as implementation evidence. */
73
+ export function objectiveSatisfiedForReview(job) {
74
+ const latest = (job.plans ?? []).at(-1);
75
+ if (latest?.status !== 'SUCCEEDED' || latest.objectiveStatus !== 'satisfied')
76
+ return false;
77
+ const implementation = job.tasks.filter(task => task.kind !== 'planner' && task.agentName !== 'reviewer' && task.agentName !== 'troubleshooter'
78
+ && task.resolutionClassification?.kind !== 'superseded');
79
+ return implementation.length > 0 && implementation.every(task => task.state === 'SUCCEEDED');
80
+ }
71
81
  /** Apply Planner-proposed history dispositions to a private candidate snapshot.
72
82
  * The caller invokes this before the storage mutator returns the candidate. */
73
83
  export function applyPlannerHistoryCleanup(job, expectedTaskIds, cleanup, now) {
@@ -1,5 +1,5 @@
1
1
  import { shouldRecoverInteractiveDecision } from './interactive-invocation.js';
2
- import { tryTransitionTaskEvent } from './scheduler.js';
2
+ import { tryTransitionTaskEvent } from './task-transitions.js';
3
3
  import { applyTaskUnblockRetrySnapshot } from './task-actions.js';
4
4
  export async function resumeInteractiveTask(store, jobId, taskId, invocationId, revision, decision) {
5
5
  if (!store.mutateJob)
@@ -1,4 +1,4 @@
1
- import { transitionJobEvent } from './scheduler.js';
1
+ import { transitionJobEvent } from './task-transitions.js';
2
2
  import { tryApplyTaskCancelAction } from './task-actions.js';
3
3
  import { decideJobLifecycle, dispatchJobLifecycleSync } from './workflow/lifecycle.js';
4
4
  export const JOB_ACTIVATE_ACTION = 'job.activate';
package/dist/job-pause.js CHANGED
@@ -1,4 +1,4 @@
1
- import { tryTransitionTaskEvent } from './scheduler.js';
1
+ import { tryTransitionTaskEvent } from './task-transitions.js';
2
2
  export async function pauseJob(store, jobId) {
3
3
  const mutate = (current) => {
4
4
  if (['COMPLETED', 'CANCELLED'].includes(current.state))
@@ -7,7 +7,7 @@ export function migrateLegacyBlockedTasks(db) {
7
7
  for (const row of rows) {
8
8
  const job = JSON.parse(row.json);
9
9
  const before = JSON.stringify(job.tasks);
10
- job.tasks = job.tasks.map(task => normalizeLegacyTaskLifecycle(task, job.executionMode));
10
+ job.tasks = job.tasks.map(task => normalizeLegacyTaskLifecycle(task));
11
11
  if (JSON.stringify(job.tasks) !== before)
12
12
  update.run(JSON.stringify(job), row.job_id);
13
13
  }