agents-relay 1.0.177 → 2.0.1

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 (66) hide show
  1. package/README.md +4 -2
  2. package/dist/adapters.js +26 -2
  3. package/dist/blockers.js +60 -65
  4. package/dist/cli-support.js +33 -1
  5. package/dist/cli.js +72 -86
  6. package/dist/dashboard-actions.js +13 -35
  7. package/dist/dashboard-html.js +1 -1
  8. package/dist/dashboard.js +39 -0
  9. package/dist/execution-recovery.js +37 -0
  10. package/dist/graph-runtime.js +1 -0
  11. package/dist/job-actions.js +53 -0
  12. package/dist/manual-plan.js +15 -17
  13. package/dist/planner-runtime.js +135 -0
  14. package/dist/planner.js +76 -60
  15. package/dist/pool.js +54 -10
  16. package/dist/projected-store.js +314 -0
  17. package/dist/reconciler-review.js +185 -110
  18. package/dist/reconciler-runtime.js +67 -7
  19. package/dist/reconciler.js +134 -282
  20. package/dist/relay-store.js +380 -0
  21. package/dist/relayd-main.js +6 -0
  22. package/dist/relayd.js +122 -29
  23. package/dist/runtime-status.js +2 -1
  24. package/dist/scheduled-jobs.js +40 -31
  25. package/dist/scheduler.js +19 -18
  26. package/dist/store.js +1 -1
  27. package/dist/task-actions.js +139 -0
  28. package/dist/troubleshooter.js +14 -8
  29. package/dist/types.js +3 -3
  30. package/dist/unblock.js +31 -21
  31. package/dist/workflow/authoritative-unblock-v2.js +20 -0
  32. package/dist/workflow/blocker-resolution-v2.js +33 -0
  33. package/dist/workflow/contracts.js +21 -0
  34. package/dist/workflow/definitions-v2.js +75 -0
  35. package/dist/workflow/dispatcher.js +56 -0
  36. package/dist/workflow/entity-creation.js +32 -0
  37. package/dist/workflow/graph-v2.js +391 -0
  38. package/dist/workflow/job-reconcile-v2.js +19 -0
  39. package/dist/workflow/lifecycle.js +82 -4
  40. package/dist/workflow/planner-orchestration-v2.js +58 -0
  41. package/dist/workflow/planner-trigger-v2.js +24 -0
  42. package/dist/workflow/review-v2.js +42 -0
  43. package/dist/workflow/runtime.js +15 -1
  44. package/dist/workflow/schedule-cycle-v2.js +25 -0
  45. package/dist/workflow/scheduled-run-v2.js +30 -0
  46. package/dist/workflow/task-action-policy.js +19 -0
  47. package/dist/workflow/task-reconcile.js +6 -6
  48. package/dist/workflow/task-related-action-v2.js +42 -0
  49. package/dist/workflow/troubleshooter-v2.js +29 -0
  50. package/dist/workspace.js +6 -1
  51. package/package.json +3 -3
  52. package/skills/agents-relay/agents/planner.agent.md +5 -5
  53. package/skills/agents-relay/contracts/task-job-contract.md +2 -51
  54. package/workflow/authoritative-unblock-v2.mmd +15 -0
  55. package/workflow/blocker-resolution-v2.mmd +73 -0
  56. package/workflow/job-lifecycle-v2.mmd +70 -0
  57. package/workflow/job-lifecycle.mmd +2 -2
  58. package/workflow/planner-orchestration-v2.mmd +65 -0
  59. package/workflow/planner-trigger-v2.mmd +32 -0
  60. package/workflow/review-lifecycle-v2.mmd +124 -0
  61. package/workflow/schedule-cycle-v2.mmd +19 -0
  62. package/workflow/scheduled-run-v2.mmd +55 -0
  63. package/workflow/task-lifecycle-v2.mmd +201 -0
  64. package/workflow/task-lifecycle.mmd +3 -0
  65. package/workflow/task-related-action-v2.mmd +28 -0
  66. package/workflow/troubleshooter-v2.mmd +43 -0
package/README.md CHANGED
@@ -2,6 +2,8 @@
2
2
 
3
3
  Agents Relay is a generic TypeScript runtime for durable asynchronous agent jobs coordinated through GitHub pull requests. The PR body stores the durable Job marker plus human-readable Job objective, while PR comments store durable Task and other append-friendly records; correlated terminal events are authoritative for model-backed task lifecycle transitions.
4
4
 
5
+ V2 separates **operational execution state** from that collaboration record. A local RelayStore may use SQLite/WAL for runtime snapshots, event idempotency and a durable GitHub projection outbox. ProjectedDurableStore bootstraps from the existing GitHub representation, commits Job/Plan/Task runtime changes locally, and lets GitHubProjectionWriter project them asynchronously. GitHub therefore remains the human-readable durable Job/Plan/Task/Review surface without being required for every high-frequency runtime transition.
6
+
5
7
  ## Quick start
6
8
 
7
9
  For GitHub-backed work, create or adopt the PR through Agents Relay itself. This guarantees the durable job marker exists before any managed task is submitted.
@@ -48,9 +50,9 @@ npx agents-relay task update --repo OWNER/REPO --pr 12 --id job-1 --task-id chil
48
50
 
49
51
  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.
50
52
 
51
- Lifecycle transition authority lives in the directly renderable canonical sources [`workflow/job-lifecycle.mmd`](workflow/job-lifecycle.mmd) and [`workflow/task-lifecycle.mmd`](workflow/task-lifecycle.mmd); see [`docs/workflow-engine.md`](docs/workflow-engine.md) for the executable Mermaid profile and runtime boundary.
53
+ Lifecycle transition authority lives in the directly renderable v2 flowchart sources [`workflow/job-lifecycle-v2.mmd`](workflow/job-lifecycle-v2.mmd) and [`workflow/task-lifecycle-v2.mmd`](workflow/task-lifecycle-v2.mmd). The legacy `stateDiagram-v2` files remain compatibility fixtures only; see [`docs/workflow-engine.md`](docs/workflow-engine.md) for the executable Mermaid profile and runtime boundary.
52
54
 
53
- The canonical Mermaid Graph Engine is the single authoritative Job/Task lifecycle decision path. Lifecycle events, guards, deterministic mutations, and declarative effects are defined by the workflow graphs and executed through the shared graph runtime.
55
+ 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 v2 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.
54
56
 
55
57
  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.
56
58
 
package/dist/adapters.js CHANGED
@@ -26,6 +26,29 @@ function parseThreadStarted(line) {
26
26
  return undefined;
27
27
  }
28
28
  }
29
+ function signalChildTree(child, signal) {
30
+ if (child.pid && process.platform !== 'win32') {
31
+ try {
32
+ process.kill(-child.pid, signal);
33
+ return;
34
+ }
35
+ catch (error) {
36
+ if (error.code !== 'ESRCH')
37
+ throw error;
38
+ }
39
+ }
40
+ child.kill(signal);
41
+ }
42
+ function terminateChild(child, graceMs = 750) {
43
+ if (child.exitCode !== null || child.signalCode !== null)
44
+ return;
45
+ signalChildTree(child, 'SIGTERM');
46
+ const force = setTimeout(() => {
47
+ if (child.exitCode === null && child.signalCode === null)
48
+ signalChildTree(child, 'SIGKILL');
49
+ }, graceMs);
50
+ force.unref();
51
+ }
29
52
  function appendTail(current, chunk, maxBytes = 16384) {
30
53
  const combined = current + chunk;
31
54
  return Buffer.byteLength(combined) <= maxBytes
@@ -47,6 +70,7 @@ export class CodexAdapter {
47
70
  shell: false,
48
71
  cwd: task.routing.cwd,
49
72
  stdio: ['ignore', 'pipe', 'pipe'],
73
+ detached: process.platform !== 'win32',
50
74
  });
51
75
  let stdoutBuffer = '';
52
76
  let stderrTail = '';
@@ -89,9 +113,9 @@ export class CodexAdapter {
89
113
  reject(new Error(stderrTail.trim() || `Codex exited ${code}`));
90
114
  }
91
115
  });
92
- signal.addEventListener('abort', () => child.kill('SIGTERM'), { once: true });
116
+ signal.addEventListener('abort', () => terminateChild(child), { once: true });
93
117
  });
94
- execution = { id: randomUUID(), promise, launched, cancel: () => child.kill('SIGTERM') };
118
+ execution = { id: randomUUID(), promise, launched, cancel: () => terminateChild(child) };
95
119
  execution.threadId = bufferedThreadId;
96
120
  return execution;
97
121
  }
package/dist/blockers.js CHANGED
@@ -1,5 +1,6 @@
1
- import { extendRetryBudget, transitionTaskEvent } from './scheduler.js';
1
+ import { applyTaskReleaseAction, applyTaskUnblockRetryAction, dispatchTaskAction } from './task-actions.js';
2
2
  import { isEffectivelyBlocked, unblockAutonomousTasks, unblockTask } from './unblock.js';
3
+ import { dispatchBlockerResolution } from './workflow/blocker-resolution-v2.js';
3
4
  function dependentKey(value) {
4
5
  return `${value.repository}#${value.prNumber}:${value.jobId}:${value.taskId}:a${value.blockedAttempt}`;
5
6
  }
@@ -58,10 +59,6 @@ function obsoleteReason(task, relation) {
58
59
  export async function resolveBlockerDependencies(options) {
59
60
  const blocker = await options.blockerStore.load(options.blockerJobId);
60
61
  const mode = blocker.blockerResolutionMode ?? 'merge';
61
- if (mode === 'verified' && options.resolution !== 'verified') {
62
- const waiting = (blocker.unblocks ?? []).filter(item => item.state === 'waiting').length;
63
- return { resumed: 0, obsolete: 0, waiting, unblocked: [] };
64
- }
65
62
  const now = options.now ?? new Date().toISOString();
66
63
  const relations = blocker.unblocks ?? [];
67
64
  let resumed = 0;
@@ -71,80 +68,78 @@ export async function resolveBlockerDependencies(options) {
71
68
  if (relation.state !== 'waiting')
72
69
  continue;
73
70
  const store = options.dependentStore(relation.repository, relation.prNumber);
74
- let targetJob;
71
+ let targetJob = null;
72
+ let task;
73
+ let loadError = null;
75
74
  try {
76
75
  targetJob = await store.load(relation.jobId);
76
+ task = targetJob.tasks.find(item => item.id === relation.taskId);
77
77
  }
78
78
  catch (error) {
79
- relation.state = 'obsolete';
80
- relation.resolvedAt = now;
81
- relation.resolution = options.resolution;
82
- relation.note = error instanceof Error ? error.message : String(error);
83
- obsolete += 1;
84
- continue;
79
+ loadError = error instanceof Error ? error.message : String(error);
85
80
  }
86
- const task = targetJob.tasks.find(item => item.id === relation.taskId);
87
- const matches = !!task && ['DRAFT', 'FAILED', 'BLOCKED', 'UNBLOCKED'].includes(task.state) && task.attempt === relation.blockedAttempt;
88
- if (!matches) {
81
+ const targetMatches = !!task && ['DRAFT', 'FAILED', 'BLOCKED', 'UNBLOCKED'].includes(task.state) && task.attempt === relation.blockedAttempt;
82
+ let note = 'dependent task requeued';
83
+ const decision = await dispatchBlockerResolution({
84
+ resolutionAllowed: !(mode === 'verified' && options.resolution !== 'verified'),
85
+ targetMatches,
86
+ autonomousJob: targetJob?.executionMode === 'autonomous',
87
+ effectivelyBlocked: !!task && isEffectivelyBlocked(task),
88
+ alreadyUnblocked: task?.state === 'UNBLOCKED' && Boolean(task.unblock),
89
+ isDraft: task?.state === 'DRAFT', isBlocked: task?.state === 'BLOCKED',
90
+ isFailed: task?.state === 'FAILED', isUnblocked: task?.state === 'UNBLOCKED',
91
+ }, async (actionId) => {
92
+ if (!targetJob || !task)
93
+ throw new Error('Blocker graph requested action without a matching dependent task');
94
+ if (actionId === 'blocker.autonomousUnblock') {
95
+ await unblockAutonomousTasks({ store, job: targetJob, tasks: [task], trigger: { source: 'blocker_job', reason: 'Blocker Job ' + blocker.id + ' resolved (' + options.resolution + ')', blockerJobId: blocker.id, resolution: options.resolution }, now });
96
+ unblocked.push({ repository: relation.repository, prNumber: relation.prNumber, jobId: relation.jobId, taskId: relation.taskId });
97
+ note = 'dependent task unblocked for replanning';
98
+ }
99
+ else if (actionId === 'blocker.acceptExistingUnblock')
100
+ note = 'dependent Job already unblocked for replanning';
101
+ else if (actionId === 'blocker.releaseDraft') {
102
+ await applyTaskReleaseAction(task);
103
+ task.error = null;
104
+ task.updatedAt = now;
105
+ delete task.blockedBy;
106
+ await store.saveTask(task);
107
+ }
108
+ else if (actionId === 'blocker.unblockTask') {
109
+ await unblockTask({ store, job: targetJob, task, trigger: { source: 'blocker_job', reason: 'Blocker Job ' + blocker.id + ' resolved (' + options.resolution + ')', blockerJobId: blocker.id, resolution: options.resolution }, now });
110
+ unblocked.push({ repository: relation.repository, prNumber: relation.prNumber, jobId: relation.jobId, taskId: relation.taskId });
111
+ }
112
+ else if (actionId === 'blocker.retryFailed') {
113
+ const result = await dispatchTaskAction(store, { id: 'task.retry', jobId: targetJob.id, taskId: task.id });
114
+ delete result.task.blockedBy;
115
+ result.task.updatedAt = now;
116
+ await store.saveTask(result.task);
117
+ }
118
+ else if (actionId === 'blocker.retryUnblocked') {
119
+ await applyTaskUnblockRetryAction(task);
120
+ task.updatedAt = now;
121
+ await store.saveTask(task);
122
+ }
123
+ else
124
+ throw new Error('Unsupported blocker graph action ' + actionId);
125
+ return {};
126
+ });
127
+ if (!decision.accepted || decision.target === 'wait')
128
+ continue;
129
+ if (decision.target === 'obsolete') {
89
130
  relation.state = 'obsolete';
90
131
  relation.resolvedAt = now;
91
132
  relation.resolution = options.resolution;
92
- relation.note = obsoleteReason(task, relation);
133
+ relation.note = loadError ?? obsoleteReason(task, relation);
93
134
  obsolete += 1;
94
135
  continue;
95
136
  }
96
- if (targetJob.executionMode === 'autonomous') {
97
- if (isEffectivelyBlocked(task)) {
98
- await unblockAutonomousTasks({
99
- store,
100
- job: targetJob,
101
- tasks: [task],
102
- trigger: { source: 'blocker_job', reason: `Blocker Job ${blocker.id} resolved (${options.resolution})`, blockerJobId: blocker.id, resolution: options.resolution },
103
- now,
104
- });
105
- unblocked.push({ repository: relation.repository, prNumber: relation.prNumber, jobId: relation.jobId, taskId: relation.taskId });
106
- relation.state = 'resumed';
107
- relation.resolvedAt = now;
108
- relation.resolution = options.resolution;
109
- relation.note = 'dependent task unblocked for replanning';
110
- resumed += 1;
111
- continue;
112
- }
113
- if (task.state === 'UNBLOCKED' && task.unblock) {
114
- relation.state = 'resumed';
115
- relation.resolvedAt = now;
116
- relation.resolution = options.resolution;
117
- relation.note = 'dependent Job already unblocked for replanning';
118
- resumed += 1;
119
- continue;
120
- }
121
- }
122
- if (task.state === 'DRAFT') {
123
- await transitionTaskEvent(task, 'release');
124
- task.error = null;
125
- task.updatedAt = now;
126
- delete task.blockedBy;
127
- await store.saveTask(task);
128
- }
129
- else if (task.state === 'BLOCKED') {
130
- await unblockTask({ store, job: targetJob, task, trigger: { source: 'blocker_job', reason: `Blocker Job ${blocker.id} resolved (${options.resolution})`, blockerJobId: blocker.id, resolution: options.resolution }, now });
131
- unblocked.push({ repository: relation.repository, prNumber: relation.prNumber, jobId: relation.jobId, taskId: relation.taskId });
132
- }
133
- else {
134
- task.maxAttempts = extendRetryBudget(task);
135
- await transitionTaskEvent(task, 'retry');
136
- task.error = null;
137
- task.leaseOwner = null;
138
- task.leaseExpiresAt = null;
139
- task.executionId = null;
140
- delete task.blockedBy;
141
- task.updatedAt = now;
142
- await store.saveTask(task);
143
- }
137
+ if (decision.target !== 'resumed')
138
+ throw new Error('Blocker graph produced unsupported target ' + decision.target);
144
139
  relation.state = 'resumed';
145
140
  relation.resolvedAt = now;
146
141
  relation.resolution = options.resolution;
147
- relation.note = 'dependent task requeued';
142
+ relation.note = note;
148
143
  resumed += 1;
149
144
  }
150
145
  const remaining = relations.filter(relation => relation.state === 'waiting');
@@ -4,7 +4,7 @@ import { githubAuthContext } from './github-auth.js';
4
4
  import { NatsEventBus, eventFor } from './events.js';
5
5
  import { parseJobSchedule } from './scheduler.js';
6
6
  import { parseJobTagJson } from './task-tag.js';
7
- export const SERVICE_DEFAULTS = { port: 8787, watchdogMs: 300000, webhookWatchdogMs: 300000, dashboardRefreshMs: 300000 };
7
+ export const SERVICE_DEFAULTS = { port: 8787, watchdogMs: 300000, webhookWatchdogMs: 300000, dashboardRefreshMs: 300000, projectionFlushMs: 1000 };
8
8
  export class ServiceEventBus {
9
9
  upstream;
10
10
  unsubscribers = new Set();
@@ -66,6 +66,38 @@ export async function storeFor(args) { const repo = arg(args, '--repo'); const p
66
66
  throw new Error('Local mode is demo/test only; pass --file explicitly'); const loaded = await localStore(file); return { store: loaded.store, job: loaded.job, localFile: file }; }
67
67
  export async function saveLocal(file, job) { if (file)
68
68
  await writeFile(file, JSON.stringify(job, null, 2)); }
69
+ export async function relayControlJob(args, jobId) {
70
+ const host = arg(args, '--host', '127.0.0.1');
71
+ const port = Number(arg(args, '--port', String(SERVICE_DEFAULTS.port)));
72
+ if (!Number.isInteger(port) || port <= 0 || port > 65535)
73
+ throw new Error('--port must be an integer between 1 and 65535');
74
+ let response;
75
+ try {
76
+ response = await fetch(`http://${host}:${port}/api/job?id=${encodeURIComponent(jobId)}`);
77
+ }
78
+ catch (error) {
79
+ throw new Error(`Agents Relay service unavailable at ${host}:${port}: ${error instanceof Error ? error.message : String(error)}`);
80
+ }
81
+ if (!response.ok)
82
+ throw new Error(`Agents Relay job request failed: ${await response.text() || `HTTP ${response.status}`}`);
83
+ return await response.json();
84
+ }
85
+ export async function relayTaskCommand(args, body) {
86
+ const host = arg(args, '--host', '127.0.0.1');
87
+ const port = Number(arg(args, '--port', String(SERVICE_DEFAULTS.port)));
88
+ if (!Number.isInteger(port) || port <= 0 || port > 65535)
89
+ throw new Error('--port must be an integer between 1 and 65535');
90
+ let response;
91
+ try {
92
+ response = await fetch(`http://${host}:${port}/api/task-command`, { method: 'POST', headers: { 'content-type': 'application/json' }, body: JSON.stringify(body) });
93
+ }
94
+ catch (error) {
95
+ throw new Error(`Agents Relay service unavailable at ${host}:${port}: ${error instanceof Error ? error.message : String(error)}`);
96
+ }
97
+ if (!response.ok)
98
+ throw new Error(`Agents Relay task command failed: ${await response.text() || `HTTP ${response.status}`}`);
99
+ return await response.json();
100
+ }
69
101
  export function eventBus(args) { if (arg(args, '--events') !== 'nats')
70
102
  return undefined; return new NatsEventBus(arg(args, '--nats-url', 'nats://127.0.0.1:4222'), arg(args, '--subject-prefix', 'neo.events.job')); }
71
103
  export async function publishWake(bus, job, taskId, message) { if (!bus)
package/dist/cli.js CHANGED
@@ -17,15 +17,18 @@ import { codexAndZaiUsageRegistry } from './usage.js';
17
17
  import { decisionMode, LayaCliDecisionEngine } from './decision.js';
18
18
  import { discoverAgents } from './registry.js';
19
19
  import { AgentObjectivePlanner, CommandObjectivePlanner } from './planner.js';
20
- import { extendRetryBudget, transitionJobEvent, transitionTaskEvent } from './scheduler.js';
20
+ import { transitionJobEvent } from './scheduler.js';
21
+ import { applyOrchestratorCompleteAction, applyTaskHoldAction, dispatchTaskAction } from './task-actions.js';
22
+ import { applyJobActivateAction, applyJobCancelAction } from './job-actions.js';
21
23
  import { resolveExecutionContext } from './execution-context.js';
22
24
  import { defaultWorkspaceRoot, resolveManagedProjectPath, resolveRepositoryProjectPath } from './workspace.js';
23
25
  import { runBlockerCommand } from './cli-blocker.js';
24
26
  import { helpText, usage } from './cli-help.js';
25
27
  import { parseAgentGraph } from './agent-graph.js';
26
28
  import { runJobUnblockCommand, runTaskUnblockCommand } from './cli-unblock.js';
29
+ import { createJobEntity, createTaskEntity } from './workflow/entity-creation.js';
27
30
  export { helpText } from './cli-help.js';
28
- import { SERVICE_DEFAULTS, ServiceEventBus, arg, hasArg, textOption, integerArg, repository, storeFor, saveLocal, githubStore, eventBus, publishWake, publishDependentUnblocked, priority, storySize, executionMode, chatGptTabClosePolicy, requestedExecutionMode, continuation, scheduleFromArgs, jobTagFromArgs, requestManagedReconciliation, } from './cli-support.js';
31
+ import { SERVICE_DEFAULTS, ServiceEventBus, arg, hasArg, textOption, integerArg, repository, storeFor, saveLocal, githubStore, eventBus, publishWake, publishDependentUnblocked, priority, storySize, executionMode, chatGptTabClosePolicy, requestedExecutionMode, continuation, scheduleFromArgs, jobTagFromArgs, requestManagedReconciliation, relayControlJob, relayTaskCommand, } from './cli-support.js';
29
32
  export { SERVICE_DEFAULTS, eventBus, executionMode, chatGptTabClosePolicy } from './cli-support.js';
30
33
  export async function ensureManagedGitHubJob(client, repositoryName, trustedAuthors, options) {
31
34
  if (!options.id)
@@ -41,7 +44,7 @@ export async function ensureManagedGitHubJob(client, repositoryName, trustedAuth
41
44
  throw error;
42
45
  }
43
46
  const now = new Date().toISOString();
44
- const job = {
47
+ const job = createJobEntity({
45
48
  id: options.id,
46
49
  title: options.title || pr.title || 'Agents Relay job',
47
50
  objective: options.objective || pr.body || undefined,
@@ -54,14 +57,12 @@ export async function ensureManagedGitHubJob(client, repositoryName, trustedAuth
54
57
  reviewEnabled: options.reviewEnabled ?? true,
55
58
  prNumber: pr.number,
56
59
  repository: repositoryName,
57
- state: options.schedule ? 'ACTIVE' : 'DRAFT',
58
60
  continuation: options.continuation ?? null,
59
61
  createdAt: now,
60
62
  updatedAt: now,
61
- tasks: []
62
- };
63
- if (options.schedule)
64
- job.schedule = options.schedule;
63
+ tasks: [],
64
+ ...(options.schedule ? { schedule: options.schedule } : {}),
65
+ }, { activate: Boolean(options.schedule) });
65
66
  await store.saveJob(job);
66
67
  return { job: await store.load(options.id), pr };
67
68
  }
@@ -91,7 +92,7 @@ async function workerTaskFromArgs(job, args, now) {
91
92
  const size = storySize(args);
92
93
  const dependencies = arg(args, '--deps').split(',').filter(Boolean);
93
94
  const timeoutMs = adapter === 'orchestrator' ? 0 : hasArg(args, '--timeout') ? integerArg(args, '--timeout', taskOverallTimeoutMs(size, routing?.reasoning)) : routing ? taskOverallTimeoutMs(size, routing.reasoning) : 0;
94
- return {
95
+ return createTaskEntity(job, {
95
96
  jobId: job.id,
96
97
  id: arg(args, '--task-id', 'initial'),
97
98
  priority: priority(args, job.priority ?? 'P2'),
@@ -111,7 +112,6 @@ async function workerTaskFromArgs(job, args, now) {
111
112
  routing,
112
113
  continuation: continuation(args),
113
114
  continuationDeliveredAt: null,
114
- state: hasArg(args, '--draft') || dependencies.length > 0 ? 'DRAFT' : 'QUEUED',
115
115
  attempt: 0,
116
116
  maxAttempts: Number(arg(args, '--max-attempts', '3')),
117
117
  leaseOwner: null,
@@ -123,11 +123,45 @@ async function workerTaskFromArgs(job, args, now) {
123
123
  timeoutMs,
124
124
  createdAt: now,
125
125
  updatedAt: now
126
- };
126
+ }, { defer: hasArg(args, '--draft') });
127
+ }
128
+ async function runManagedTaskCommand(action, args) {
129
+ const jobId = arg(args, '--id');
130
+ if (!jobId)
131
+ throw new Error('--id is required for managed task commands');
132
+ const id = arg(args, '--task-id', action === 'create' ? randomUUID() : '');
133
+ if (!id)
134
+ throw new Error(`task ${action} requires --task-id`);
135
+ let result;
136
+ if (action === 'create') {
137
+ const job = await relayControlJob(args, jobId);
138
+ if (['COMPLETED', 'CANCELLED'].includes(job.state))
139
+ throw new Error(`Cannot create task on terminal job ${job.id} (${job.state})`);
140
+ const task = await workerTaskFromArgs(job, args, new Date().toISOString());
141
+ task.id = id;
142
+ result = await relayTaskCommand(args, { jobId, action, task });
143
+ }
144
+ else if (action === 'complete') {
145
+ const summary = arg(args, '--summary');
146
+ if (!summary)
147
+ throw new Error('task complete requires --summary');
148
+ result = await relayTaskCommand(args, { jobId, taskId: id, action, summary });
149
+ }
150
+ else
151
+ result = await relayTaskCommand(args, { jobId, taskId: id, action });
152
+ const task = result.tasks.find(item => item.id === id);
153
+ if (!task)
154
+ throw new Error(`Task ${id} missing after ${action}`);
155
+ console.log(JSON.stringify(task, null, 2));
127
156
  }
128
157
  async function runTaskCommand(action, args) {
129
158
  if (!['create', 'update', 'show', 'list', 'complete', 'retry', 'unblock', 'cancel'].includes(action))
130
159
  throw new Error(usage);
160
+ const managed = Boolean(arg(args, '--repo') && arg(args, '--pr') && !arg(args, '--file'));
161
+ if (managed && ['create', 'complete', 'retry', 'cancel'].includes(action)) {
162
+ await runManagedTaskCommand(action, args);
163
+ return;
164
+ }
131
165
  const { store, job, localFile } = await storeFor(args);
132
166
  if (action === 'list') {
133
167
  console.log(JSON.stringify(job.tasks, null, 2));
@@ -145,7 +179,7 @@ async function runTaskCommand(action, args) {
145
179
  await store.appendTask(task);
146
180
  const refreshed = await store.load(job.id);
147
181
  if (refreshed.state === 'DRAFT') {
148
- await transitionJobEvent(refreshed, 'activate');
182
+ await applyJobActivateAction(refreshed);
149
183
  await store.saveJob(refreshed);
150
184
  }
151
185
  const durableTask = (await store.load(job.id)).tasks.find(item => item.id === task.id) ?? task;
@@ -166,52 +200,35 @@ async function runTaskCommand(action, args) {
166
200
  return;
167
201
  }
168
202
  if (action === 'complete') {
169
- if (task.adapter !== 'orchestrator')
170
- throw new Error('task complete is only valid for adapter=orchestrator self-tasks');
171
- if (!['QUEUED', 'RUNNING'].includes(task.state))
172
- throw new Error(`Task ${id} is ${task.state}; only QUEUED or RUNNING orchestrator tasks can be completed`);
173
203
  const summary = arg(args, '--summary');
174
204
  if (!summary)
175
205
  throw new Error('task complete requires --summary');
176
- if (task.state === 'QUEUED')
177
- await transitionTaskEvent(task, 'task.started');
178
- task.attempt = Math.max(task.attempt, 1);
179
- task.result = { summary };
180
- task.error = null;
181
- task.leaseOwner = null;
182
- task.leaseExpiresAt = null;
183
- task.executionId = null;
184
- await transitionTaskEvent(task, 'task.completed');
206
+ await applyOrchestratorCompleteAction(task, summary);
185
207
  await store.saveTask(task);
186
208
  await saveLocal(localFile, await store.load(job.id));
187
209
  console.log(JSON.stringify(task, null, 2));
188
210
  return;
189
211
  }
190
212
  if (action === 'retry' || action === 'cancel') {
213
+ let outputJob = job;
214
+ let outputTask = task;
191
215
  if (action === 'retry') {
192
- if (task.state === 'BLOCKED')
193
- throw new Error(`Task ${id} is BLOCKED; use task unblock --reason instead of retry`);
194
- if (task.state === 'UNBLOCKED')
195
- throw new Error(`Task ${id} is historical UNBLOCKED work; only fixed-job authoritative unblock may requeue it`);
196
- task.maxAttempts = extendRetryBudget(task);
197
- if (task.kind === 'planner' && task.error)
198
- task.plannerRetryFeedback = task.error;
216
+ const result = await dispatchTaskAction(store, { id: 'task.retry', jobId: job.id, taskId: id });
217
+ if (result.job.state === 'DRAFT') {
218
+ await applyJobActivateAction(result.job);
219
+ await store.saveJob(result.job);
220
+ }
221
+ outputJob = await store.load(job.id);
222
+ outputTask = outputJob.tasks.find(item => item.id === id) ?? result.task;
199
223
  }
200
- if (action === 'cancel' && task.state === 'UNBLOCKED')
201
- throw new Error(`Task ${id} is historical UNBLOCKED work and is immutable`);
202
- await transitionTaskEvent(task, action === 'retry' ? 'retry' : task.state === 'RUNNING' ? 'task.cancelled' : 'cancel');
203
- task.error = action === 'retry' ? null : 'Cancelled by user';
204
- task.leaseOwner = null;
205
- task.leaseExpiresAt = null;
206
- task.executionId = null;
207
- await store.saveTask(task);
208
- if (action === 'retry' && job.state === 'DRAFT') {
209
- await transitionJobEvent(job, 'activate');
210
- await store.saveJob(job);
224
+ else {
225
+ const result = await dispatchTaskAction(store, { id: 'task.cancel', jobId: job.id, taskId: id });
226
+ outputJob = await store.load(job.id);
227
+ outputTask = outputJob.tasks.find(item => item.id === id) ?? result.task;
211
228
  }
212
- await saveLocal(localFile, job);
213
- await publishWake(eventBus(args), job, task.id, `${task.id} ${action} requested`);
214
- console.log(JSON.stringify(task, null, 2));
229
+ await saveLocal(localFile, outputJob);
230
+ await publishWake(eventBus(args), outputJob, outputTask.id, `${outputTask.id} ${action} requested`);
231
+ console.log(JSON.stringify(outputTask, null, 2));
215
232
  return;
216
233
  }
217
234
  if (['RUNNING', 'UNBLOCKED', 'SUCCEEDED', 'CANCELLED'].includes(task.state))
@@ -254,19 +271,11 @@ async function runTaskCommand(action, args) {
254
271
  if (hasArg(args, '--draft') && hasArg(args, '--queue'))
255
272
  throw new Error('--draft and --queue are mutually exclusive');
256
273
  if (hasArg(args, '--draft') && task.state === 'QUEUED')
257
- await transitionTaskEvent(task, 'hold');
274
+ await applyTaskHoldAction(task);
258
275
  if (hasArg(args, '--queue')) {
259
- const taskMap = new Map(job.tasks.map(item => [item.id, item]));
260
- if (!task.dependencies.every(dep => taskMap.get(dep)?.state === 'SUCCEEDED'))
261
- throw new Error('Task dependencies must succeed before queueing');
262
- if (task.state === 'DRAFT')
263
- await transitionTaskEvent(task, 'release');
264
- else if (task.state === 'FAILED')
265
- await transitionTaskEvent(task, 'retry');
266
- else if (task.state === 'BLOCKED')
267
- throw new Error(`Task ${id} is BLOCKED; use task unblock --reason instead of --queue`);
268
- else if (task.state === 'UNBLOCKED')
269
- throw new Error(`Task ${id} is historical UNBLOCKED work and cannot be queued`);
276
+ await store.saveTask(task);
277
+ const result = await dispatchTaskAction(store, { id: 'task.queue', jobId: job.id, taskId: id });
278
+ Object.assign(task, result.task);
270
279
  }
271
280
  if (hasArg(args, '--max-attempts') && task.maxAttempts < task.attempt)
272
281
  throw new Error('--max-attempts cannot be less than the current attempt count');
@@ -391,19 +400,8 @@ async function runJobCommand(action, args) {
391
400
  return;
392
401
  }
393
402
  if (action === 'cancel') {
394
- if (job.state === 'COMPLETED')
395
- throw new Error(`Cannot cancel completed job ${job.id}`);
396
- for (const task of job.tasks.filter(item => !['SUCCEEDED', 'UNBLOCKED', 'CANCELLED'].includes(item.state))) {
397
- await transitionTaskEvent(task, 'cancel');
398
- task.error = 'Job cancelled';
399
- task.leaseOwner = null;
400
- task.leaseExpiresAt = null;
401
- task.executionId = null;
402
- await store.saveTask(task);
403
- }
403
+ await applyJobCancelAction(store, job);
404
404
  await store.closePullRequest();
405
- await transitionJobEvent(job, 'cancel');
406
- await store.saveJob(job);
407
405
  console.log(JSON.stringify(await store.load(id), null, 2));
408
406
  return;
409
407
  }
@@ -454,25 +452,12 @@ export function dashboardJobAction(store, reconciler, action) {
454
452
  return async (jobId) => {
455
453
  const job = await store.load(jobId);
456
454
  if (action === 'start') {
457
- if (job.state !== 'DRAFT')
458
- throw new Error(`Job ${job.id} is not startable (${job.state})`);
459
- await transitionJobEvent(job, 'activate');
455
+ await applyJobActivateAction(job);
460
456
  await store.saveJob(job);
461
457
  }
462
458
  else if (action === 'cancel') {
463
- if (job.state === 'COMPLETED' || job.state === 'CANCELLED')
464
- throw new Error(`Job ${job.id} is not cancellable (${job.state})`);
465
- for (const task of job.tasks.filter(item => !['SUCCEEDED', 'CANCELLED'].includes(item.state))) {
466
- await transitionTaskEvent(task, 'cancel');
467
- task.error = 'Job cancelled';
468
- task.leaseOwner = null;
469
- task.leaseExpiresAt = null;
470
- task.executionId = null;
471
- await store.saveTask(task);
472
- }
459
+ await applyJobCancelAction(store, job);
473
460
  await store.closePullRequest?.();
474
- await transitionJobEvent(job, 'cancel');
475
- await store.saveJob(job);
476
461
  return await store.load(job.id);
477
462
  }
478
463
  return await reconciler.wake(job.id);
@@ -588,7 +573,8 @@ async function main() {
588
573
  }
589
574
  if (command === 'init') {
590
575
  const now = new Date().toISOString();
591
- const job = { id: arg(args, '--id', randomUUID()), title: arg(args, '--title', 'Agents Relay job'), objective: arg(args, '--objective') || undefined, priority: priority(args), executionMode: executionMode(args), schedule: scheduleFromArgs(args), autoMerge: !hasArg(args, '--no-auto-merge'), reviewEnabled: !hasArg(args, '--no-review'), prNumber: Number(arg(args, '--pr', '0')), repository: arg(args, '--repo'), state: scheduleFromArgs(args) ? 'ACTIVE' : 'DRAFT', continuation: continuation(args), createdAt: now, updatedAt: now, tasks: [] };
576
+ const initialSchedule = scheduleFromArgs(args);
577
+ const job = createJobEntity({ id: arg(args, '--id', randomUUID()), 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) });
592
578
  let store;
593
579
  let file = null;
594
580
  if (arg(args, '--repo') && arg(args, '--pr'))
@@ -1,5 +1,7 @@
1
- import { extendRetryBudget, transitionTaskEvent } from './scheduler.js';
2
- import { applyJobWorkflowDecision, applyTaskWorkflowDecision } from './workflow/runtime.js';
1
+ import { applyJobGraphTarget, applyTaskGraphTarget } from './workflow/runtime.js';
2
+ import { jobLifecycleV2, taskLifecycleV2 } from './workflow/definitions-v2.js';
3
+ import { dispatchWorkflowTargetOverride } from './workflow/dispatcher.js';
4
+ import { dispatchTaskAction } from './task-actions.js';
3
5
  export function dashboardTaskAction(store, reconciler, action) {
4
6
  return async (jobId, taskId) => {
5
7
  const job = await store.load(jobId);
@@ -7,24 +9,11 @@ export function dashboardTaskAction(store, reconciler, action) {
7
9
  if (!task)
8
10
  throw new Error(`Task ${taskId} not found`);
9
11
  if (action === 'retry') {
10
- if (task.state === 'BLOCKED')
11
- throw new Error(`Task ${task.id} is BLOCKED; use the unblock CLI with a reason`);
12
- if (task.state !== 'FAILED')
13
- throw new Error(`Task ${task.id} is not restartable (${task.state})`);
14
- task.maxAttempts = extendRetryBudget(task);
15
- await transitionTaskEvent(task, 'retry');
16
- task.error = null;
12
+ await dispatchTaskAction(store, { id: 'task.retry', jobId, taskId });
17
13
  }
18
14
  else {
19
- if (!['DRAFT', 'QUEUED', 'RUNNING', 'BLOCKED', 'FAILED'].includes(task.state))
20
- throw new Error(`Task ${task.id} is not cancellable (${task.state})`);
21
- await transitionTaskEvent(task, task.state === 'RUNNING' ? 'task.cancelled' : 'cancel');
22
- task.error = 'Cancelled by user';
15
+ await dispatchTaskAction(store, { id: 'task.cancel', jobId, taskId });
23
16
  }
24
- task.leaseOwner = null;
25
- task.leaseExpiresAt = null;
26
- task.executionId = null;
27
- await store.saveTask(task);
28
17
  return await reconciler.wake(job.id);
29
18
  };
30
19
  }
@@ -33,16 +22,10 @@ export function dashboardJobSetState(store) {
33
22
  const job = await store.load(jobId);
34
23
  if (job.state === state)
35
24
  return job;
36
- applyJobWorkflowDecision(job, {
37
- accepted: true,
38
- target: state,
39
- guard: null,
40
- actions: [],
41
- mutations: [],
42
- effects: [],
43
- reason: 'accepted',
25
+ await dispatchWorkflowTargetOverride(jobLifecycleV2, {
26
+ currentState: job.state, targetState: state, kind: 'job',
27
+ persistTransition: async (_from, target) => { applyJobGraphTarget(job, target); await store.saveJob(job); },
44
28
  });
45
- await store.saveJob(job);
46
29
  return await store.load(job.id);
47
30
  };
48
31
  }
@@ -62,15 +45,10 @@ export function dashboardTaskSetState(store) {
62
45
  throw new Error(`Task ${task.id} is historical UNBLOCKED work and cannot be changed by dashboard state override`);
63
46
  const now = new Date().toISOString();
64
47
  const from = task.state;
65
- applyTaskWorkflowDecision(task, {
66
- accepted: true,
67
- target: state,
68
- guard: null,
69
- actions: [],
70
- mutations: [],
71
- effects: [],
72
- reason: 'accepted',
73
- }, now);
48
+ await dispatchWorkflowTargetOverride(taskLifecycleV2, {
49
+ currentState: from, targetState: state, kind: 'task',
50
+ persistTransition: (_source, target) => { applyTaskGraphTarget(task, target, now); },
51
+ });
74
52
  task.manualStateOverrides = [
75
53
  ...(task.manualStateOverrides ?? []),
76
54
  { from, to: state, at: now, actor: 'dashboard-user' },