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.
- package/README.md +4 -2
- package/dist/adapters.js +26 -2
- package/dist/blockers.js +60 -65
- package/dist/cli-support.js +33 -1
- package/dist/cli.js +72 -86
- package/dist/dashboard-actions.js +13 -35
- package/dist/dashboard-html.js +1 -1
- package/dist/dashboard.js +39 -0
- package/dist/execution-recovery.js +37 -0
- package/dist/graph-runtime.js +1 -0
- package/dist/job-actions.js +53 -0
- package/dist/manual-plan.js +15 -17
- package/dist/planner-runtime.js +135 -0
- package/dist/planner.js +76 -60
- package/dist/pool.js +54 -10
- package/dist/projected-store.js +314 -0
- package/dist/reconciler-review.js +185 -110
- package/dist/reconciler-runtime.js +67 -7
- package/dist/reconciler.js +134 -282
- package/dist/relay-store.js +380 -0
- package/dist/relayd-main.js +6 -0
- package/dist/relayd.js +122 -29
- package/dist/runtime-status.js +2 -1
- package/dist/scheduled-jobs.js +40 -31
- package/dist/scheduler.js +19 -18
- package/dist/store.js +1 -1
- package/dist/task-actions.js +139 -0
- package/dist/troubleshooter.js +14 -8
- package/dist/types.js +3 -3
- package/dist/unblock.js +31 -21
- package/dist/workflow/authoritative-unblock-v2.js +20 -0
- package/dist/workflow/blocker-resolution-v2.js +33 -0
- package/dist/workflow/contracts.js +21 -0
- package/dist/workflow/definitions-v2.js +75 -0
- package/dist/workflow/dispatcher.js +56 -0
- package/dist/workflow/entity-creation.js +32 -0
- package/dist/workflow/graph-v2.js +391 -0
- package/dist/workflow/job-reconcile-v2.js +19 -0
- package/dist/workflow/lifecycle.js +82 -4
- package/dist/workflow/planner-orchestration-v2.js +58 -0
- package/dist/workflow/planner-trigger-v2.js +24 -0
- package/dist/workflow/review-v2.js +42 -0
- package/dist/workflow/runtime.js +15 -1
- package/dist/workflow/schedule-cycle-v2.js +25 -0
- package/dist/workflow/scheduled-run-v2.js +30 -0
- package/dist/workflow/task-action-policy.js +19 -0
- package/dist/workflow/task-reconcile.js +6 -6
- package/dist/workflow/task-related-action-v2.js +42 -0
- package/dist/workflow/troubleshooter-v2.js +29 -0
- package/dist/workspace.js +6 -1
- package/package.json +3 -3
- package/skills/agents-relay/agents/planner.agent.md +5 -5
- package/skills/agents-relay/contracts/task-job-contract.md +2 -51
- package/workflow/authoritative-unblock-v2.mmd +15 -0
- package/workflow/blocker-resolution-v2.mmd +73 -0
- package/workflow/job-lifecycle-v2.mmd +70 -0
- package/workflow/job-lifecycle.mmd +2 -2
- package/workflow/planner-orchestration-v2.mmd +65 -0
- package/workflow/planner-trigger-v2.mmd +32 -0
- package/workflow/review-lifecycle-v2.mmd +124 -0
- package/workflow/schedule-cycle-v2.mmd +19 -0
- package/workflow/scheduled-run-v2.mmd +55 -0
- package/workflow/task-lifecycle-v2.mmd +201 -0
- package/workflow/task-lifecycle.mmd +3 -0
- package/workflow/task-related-action-v2.mmd +28 -0
- 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
|
|
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
|
|
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
|
|
116
|
+
signal.addEventListener('abort', () => terminateChild(child), { once: true });
|
|
93
117
|
});
|
|
94
|
-
execution = { id: randomUUID(), promise, launched, cancel: () => child
|
|
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 {
|
|
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
|
-
|
|
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
|
|
87
|
-
|
|
88
|
-
|
|
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 (
|
|
97
|
-
|
|
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 =
|
|
142
|
+
relation.note = note;
|
|
148
143
|
resumed += 1;
|
|
149
144
|
}
|
|
150
145
|
const remaining = relations.filter(relation => relation.state === 'waiting');
|
package/dist/cli-support.js
CHANGED
|
@@ -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 {
|
|
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
|
-
|
|
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
|
|
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
|
-
|
|
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
|
-
|
|
193
|
-
|
|
194
|
-
|
|
195
|
-
|
|
196
|
-
|
|
197
|
-
|
|
198
|
-
|
|
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
|
-
|
|
201
|
-
|
|
202
|
-
|
|
203
|
-
|
|
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,
|
|
213
|
-
await publishWake(eventBus(args),
|
|
214
|
-
console.log(JSON.stringify(
|
|
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
|
|
274
|
+
await applyTaskHoldAction(task);
|
|
258
275
|
if (hasArg(args, '--queue')) {
|
|
259
|
-
|
|
260
|
-
|
|
261
|
-
|
|
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
|
-
|
|
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
|
-
|
|
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
|
-
|
|
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
|
|
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 {
|
|
2
|
-
import {
|
|
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
|
-
|
|
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
|
-
|
|
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
|
-
|
|
37
|
-
|
|
38
|
-
|
|
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
|
-
|
|
66
|
-
|
|
67
|
-
|
|
68
|
-
|
|
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' },
|