agents-relay 1.0.162 → 1.0.163
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/dist/blockers.js +23 -13
- package/dist/cli-blocker.js +2 -1
- package/dist/cli-support.js +16 -0
- package/dist/cli-unblock.js +14 -6
- package/dist/cli.js +11 -4
- package/dist/dashboard-actions.js +5 -1
- package/dist/dashboard-html.js +4 -4
- package/dist/manual-plan.js +5 -2
- package/dist/markers.js +22 -1
- package/dist/planner.js +49 -2
- package/dist/pool.js +1 -1
- package/dist/reconciler.js +37 -14
- package/dist/runtime-status.js +3 -3
- package/dist/store-github.js +60 -4
- package/dist/store.js +15 -2
- package/dist/unblock.js +42 -24
- package/dist/workflow/contracts.js +1 -0
- package/package.json +1 -1
- package/skills/agents-relay/agents/planner.agent.md +1 -1
- package/skills/agents-relay/contracts/task-job-contract.md +7 -3
- package/workflow/task-lifecycle.mmd +3 -1
package/dist/blockers.js
CHANGED
|
@@ -1,5 +1,5 @@
|
|
|
1
1
|
import { extendRetryBudget, transitionTaskEvent } from './scheduler.js';
|
|
2
|
-
import { isEffectivelyBlocked, unblockAutonomousTasks } from './unblock.js';
|
|
2
|
+
import { isEffectivelyBlocked, unblockAutonomousTasks, unblockTask } from './unblock.js';
|
|
3
3
|
function dependentKey(value) {
|
|
4
4
|
return `${value.repository}#${value.prNumber}:${value.jobId}:${value.taskId}:a${value.blockedAttempt}`;
|
|
5
5
|
}
|
|
@@ -60,12 +60,13 @@ export async function resolveBlockerDependencies(options) {
|
|
|
60
60
|
const mode = blocker.blockerResolutionMode ?? 'merge';
|
|
61
61
|
if (mode === 'verified' && options.resolution !== 'verified') {
|
|
62
62
|
const waiting = (blocker.unblocks ?? []).filter(item => item.state === 'waiting').length;
|
|
63
|
-
return { resumed: 0, obsolete: 0, waiting };
|
|
63
|
+
return { resumed: 0, obsolete: 0, waiting, unblocked: [] };
|
|
64
64
|
}
|
|
65
65
|
const now = options.now ?? new Date().toISOString();
|
|
66
66
|
const relations = blocker.unblocks ?? [];
|
|
67
67
|
let resumed = 0;
|
|
68
68
|
let obsolete = 0;
|
|
69
|
+
const unblocked = [];
|
|
69
70
|
for (const relation of relations) {
|
|
70
71
|
if (relation.state !== 'waiting')
|
|
71
72
|
continue;
|
|
@@ -83,7 +84,7 @@ export async function resolveBlockerDependencies(options) {
|
|
|
83
84
|
continue;
|
|
84
85
|
}
|
|
85
86
|
const task = targetJob.tasks.find(item => item.id === relation.taskId);
|
|
86
|
-
const matches = !!task && ['DRAFT', 'FAILED', 'BLOCKED'].includes(task.state) && task.attempt === relation.blockedAttempt;
|
|
87
|
+
const matches = !!task && ['DRAFT', 'FAILED', 'BLOCKED', 'UNBLOCKED'].includes(task.state) && task.attempt === relation.blockedAttempt;
|
|
87
88
|
if (!matches) {
|
|
88
89
|
relation.state = 'obsolete';
|
|
89
90
|
relation.resolvedAt = now;
|
|
@@ -101,6 +102,7 @@ export async function resolveBlockerDependencies(options) {
|
|
|
101
102
|
trigger: { source: 'blocker_job', reason: `Blocker Job ${blocker.id} resolved (${options.resolution})`, blockerJobId: blocker.id, resolution: options.resolution },
|
|
102
103
|
now,
|
|
103
104
|
});
|
|
105
|
+
unblocked.push({ repository: relation.repository, prNumber: relation.prNumber, jobId: relation.jobId, taskId: relation.taskId });
|
|
104
106
|
relation.state = 'resumed';
|
|
105
107
|
relation.resolvedAt = now;
|
|
106
108
|
relation.resolution = options.resolution;
|
|
@@ -108,7 +110,7 @@ export async function resolveBlockerDependencies(options) {
|
|
|
108
110
|
resumed += 1;
|
|
109
111
|
continue;
|
|
110
112
|
}
|
|
111
|
-
if (task.state === '
|
|
113
|
+
if (task.state === 'UNBLOCKED' && task.unblock) {
|
|
112
114
|
relation.state = 'resumed';
|
|
113
115
|
relation.resolvedAt = now;
|
|
114
116
|
relation.resolution = options.resolution;
|
|
@@ -119,18 +121,26 @@ export async function resolveBlockerDependencies(options) {
|
|
|
119
121
|
}
|
|
120
122
|
if (task.state === 'DRAFT') {
|
|
121
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 });
|
|
122
132
|
}
|
|
123
133
|
else {
|
|
124
134
|
task.maxAttempts = extendRetryBudget(task);
|
|
125
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);
|
|
126
143
|
}
|
|
127
|
-
task.error = null;
|
|
128
|
-
task.leaseOwner = null;
|
|
129
|
-
task.leaseExpiresAt = null;
|
|
130
|
-
task.executionId = null;
|
|
131
|
-
delete task.blockedBy;
|
|
132
|
-
task.updatedAt = now;
|
|
133
|
-
await store.saveTask(task);
|
|
134
144
|
relation.state = 'resumed';
|
|
135
145
|
relation.resolvedAt = now;
|
|
136
146
|
relation.resolution = options.resolution;
|
|
@@ -140,7 +150,7 @@ export async function resolveBlockerDependencies(options) {
|
|
|
140
150
|
const remaining = relations.filter(relation => relation.state === 'waiting');
|
|
141
151
|
const consumed = remaining.length !== relations.length;
|
|
142
152
|
if (!consumed)
|
|
143
|
-
return { resumed, obsolete, waiting: remaining.length };
|
|
153
|
+
return { resumed, obsolete, waiting: remaining.length, unblocked };
|
|
144
154
|
if (remaining.length > 0)
|
|
145
155
|
blocker.unblocks = remaining;
|
|
146
156
|
else
|
|
@@ -148,5 +158,5 @@ export async function resolveBlockerDependencies(options) {
|
|
|
148
158
|
blocker.blockerResolvedAt = now;
|
|
149
159
|
blocker.updatedAt = now;
|
|
150
160
|
await options.blockerStore.saveJob(blocker);
|
|
151
|
-
return { resumed, obsolete, waiting: remaining.length };
|
|
161
|
+
return { resumed, obsolete, waiting: remaining.length, unblocked };
|
|
152
162
|
}
|
package/dist/cli-blocker.js
CHANGED
|
@@ -1,7 +1,7 @@
|
|
|
1
1
|
import { GitHubStore } from './store.js';
|
|
2
2
|
import { githubAuthContext } from './github-auth.js';
|
|
3
3
|
import { registerBlockerDependency } from './blockers.js';
|
|
4
|
-
import { arg, repository } from './cli-support.js';
|
|
4
|
+
import { arg, eventBus, publishDependentUnblocked, repository } from './cli-support.js';
|
|
5
5
|
import { usage } from './cli-help.js';
|
|
6
6
|
export async function runBlockerCommand(action, args) {
|
|
7
7
|
if (!['add', 'resolve', 'show'].includes(action))
|
|
@@ -19,6 +19,7 @@ export async function runBlockerCommand(action, args) {
|
|
|
19
19
|
}
|
|
20
20
|
if (action === 'resolve') {
|
|
21
21
|
const summary = await blockerStore.resolveBlockerDependents(id, 'verified');
|
|
22
|
+
await publishDependentUnblocked(eventBus(args), summary.unblocked, `Blocker Job ${id} resolved (verified)`);
|
|
22
23
|
console.log(JSON.stringify({ blocker: id, resolution: 'verified', ...summary }, null, 2));
|
|
23
24
|
return;
|
|
24
25
|
}
|
package/dist/cli-support.js
CHANGED
|
@@ -75,6 +75,22 @@ export async function publishWake(bus, job, taskId, message) { if (!bus)
|
|
|
75
75
|
catch (error) {
|
|
76
76
|
process.stderr.write(`warning: event wake unavailable: ${error instanceof Error ? error.message : String(error)}\n`);
|
|
77
77
|
} }
|
|
78
|
+
export async function publishTaskUnblocked(bus, job, taskId, reason) { if (!bus)
|
|
79
|
+
return; try {
|
|
80
|
+
await bus.publish(eventFor(job.id, taskId, null, 'task.unblocked', 'unblocked', `Task ${taskId} unblocked: ${reason}`, 'orchestrator', { repository: job.repository, prNumber: job.prNumber, reason }));
|
|
81
|
+
}
|
|
82
|
+
catch (error) {
|
|
83
|
+
process.stderr.write(`warning: unblock event unavailable: ${error instanceof Error ? error.message : String(error)}\n`);
|
|
84
|
+
} }
|
|
85
|
+
export async function publishDependentUnblocked(bus, targets, reason) { if (!bus)
|
|
86
|
+
return; for (const target of targets) {
|
|
87
|
+
try {
|
|
88
|
+
await bus.publish(eventFor(target.jobId, target.taskId, null, 'task.unblocked', 'unblocked', `Task ${target.taskId} unblocked: ${reason}`, 'orchestrator', { repository: target.repository, prNumber: target.prNumber, reason }));
|
|
89
|
+
}
|
|
90
|
+
catch (error) {
|
|
91
|
+
process.stderr.write(`warning: dependent unblock event unavailable: ${error instanceof Error ? error.message : String(error)}\n`);
|
|
92
|
+
}
|
|
93
|
+
} }
|
|
78
94
|
export async function requestManagedReconciliation(bus, job) {
|
|
79
95
|
if (!bus)
|
|
80
96
|
throw new Error('Managed GitHub reconciliation requires --events nats so agents-relayd can execute the job');
|
package/dist/cli-unblock.js
CHANGED
|
@@ -1,4 +1,4 @@
|
|
|
1
|
-
import { arg, eventBus, publishWake, saveLocal } from './cli-support.js';
|
|
1
|
+
import { arg, eventBus, publishTaskUnblocked, publishWake, saveLocal } from './cli-support.js';
|
|
2
2
|
import { isEffectivelyBlocked, unblockAutonomousTasks, unblockTask } from './unblock.js';
|
|
3
3
|
export async function runTaskUnblockCommand(args, store, job, task, localFile) {
|
|
4
4
|
const reason = arg(args, '--reason');
|
|
@@ -7,7 +7,9 @@ export async function runTaskUnblockCommand(args, store, job, task, localFile) {
|
|
|
7
7
|
const result = await unblockTask({ store, job, task, trigger: { source: 'cli', reason } });
|
|
8
8
|
const refreshed = await store.load(job.id);
|
|
9
9
|
await saveLocal(localFile, refreshed);
|
|
10
|
-
|
|
10
|
+
const bus = eventBus(args);
|
|
11
|
+
await publishTaskUnblocked(bus, refreshed, task.id, reason);
|
|
12
|
+
await publishWake(bus, refreshed, task.id, `Task ${task.id} unblocked${result.replanning ? ' for replanning' : ''}`);
|
|
11
13
|
console.log(JSON.stringify({ taskId: task.id, ...result, task: refreshed.tasks.find(item => item.id === task.id) }, null, 2));
|
|
12
14
|
}
|
|
13
15
|
export async function runJobUnblockCommand(args, store, job) {
|
|
@@ -18,15 +20,21 @@ export async function runJobUnblockCommand(args, store, job) {
|
|
|
18
20
|
if (blocked.length === 0)
|
|
19
21
|
throw new Error(`Job ${job.id} has no effective BLOCKED work task`);
|
|
20
22
|
if (job.executionMode === 'autonomous') {
|
|
21
|
-
const
|
|
23
|
+
const planTrigger = await unblockAutonomousTasks({ store, job, tasks: blocked, trigger: { source: 'cli', reason } });
|
|
22
24
|
const refreshed = await store.load(job.id);
|
|
23
|
-
|
|
24
|
-
|
|
25
|
+
const bus = eventBus(args);
|
|
26
|
+
for (const task of blocked)
|
|
27
|
+
await publishTaskUnblocked(bus, refreshed, task.id, reason);
|
|
28
|
+
await publishWake(bus, refreshed, 'job', `Job ${job.id} unblocked for replanning`);
|
|
29
|
+
console.log(JSON.stringify({ jobId: job.id, replanning: true, planTrigger }, null, 2));
|
|
25
30
|
return;
|
|
26
31
|
}
|
|
27
32
|
for (const task of blocked)
|
|
28
33
|
await unblockTask({ store, job, task, trigger: { source: 'cli', reason } });
|
|
29
34
|
const refreshed = await store.load(job.id);
|
|
30
|
-
|
|
35
|
+
const bus = eventBus(args);
|
|
36
|
+
for (const task of blocked)
|
|
37
|
+
await publishTaskUnblocked(bus, refreshed, task.id, reason);
|
|
38
|
+
await publishWake(bus, refreshed, 'job', `Job ${job.id} unblocked`);
|
|
31
39
|
console.log(JSON.stringify({ jobId: job.id, replanning: false, resumedTasks: blocked.map(task => task.id) }, null, 2));
|
|
32
40
|
}
|
package/dist/cli.js
CHANGED
|
@@ -26,7 +26,7 @@ import { helpText, usage } from './cli-help.js';
|
|
|
26
26
|
import { parseAgentGraph } from './agent-graph.js';
|
|
27
27
|
import { runJobUnblockCommand, runTaskUnblockCommand } from './cli-unblock.js';
|
|
28
28
|
export { helpText } from './cli-help.js';
|
|
29
|
-
import { SERVICE_DEFAULTS, ServiceEventBus, arg, hasArg, textOption, integerArg, repository, storeFor, saveLocal, githubStore, eventBus, publishWake, priority, storySize, executionMode, chatGptTabClosePolicy, requestedExecutionMode, continuation, scheduleFromArgs, jobTagFromArgs, requestManagedReconciliation, } from './cli-support.js';
|
|
29
|
+
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';
|
|
30
30
|
export { SERVICE_DEFAULTS, eventBus, executionMode, chatGptTabClosePolicy } from './cli-support.js';
|
|
31
31
|
export async function ensureManagedGitHubJob(client, repositoryName, trustedAuthors, options) {
|
|
32
32
|
if (!options.id)
|
|
@@ -196,10 +196,14 @@ async function runTaskCommand(action, args) {
|
|
|
196
196
|
if (action === 'retry') {
|
|
197
197
|
if (task.state === 'BLOCKED')
|
|
198
198
|
throw new Error(`Task ${id} is BLOCKED; use task unblock --reason instead of retry`);
|
|
199
|
+
if (task.state === 'UNBLOCKED')
|
|
200
|
+
throw new Error(`Task ${id} is historical UNBLOCKED work; only fixed-job authoritative unblock may requeue it`);
|
|
199
201
|
task.maxAttempts = extendRetryBudget(task);
|
|
200
202
|
if (task.kind === 'planner' && task.error)
|
|
201
203
|
task.plannerRetryFeedback = task.error;
|
|
202
204
|
}
|
|
205
|
+
if (action === 'cancel' && task.state === 'UNBLOCKED')
|
|
206
|
+
throw new Error(`Task ${id} is historical UNBLOCKED work and is immutable`);
|
|
203
207
|
await transitionTaskEvent(task, action === 'retry' ? 'retry' : task.state === 'RUNNING' ? 'task.cancelled' : 'cancel');
|
|
204
208
|
task.error = action === 'retry' ? null : 'Cancelled by user';
|
|
205
209
|
task.leaseOwner = null;
|
|
@@ -215,7 +219,7 @@ async function runTaskCommand(action, args) {
|
|
|
215
219
|
console.log(JSON.stringify(task, null, 2));
|
|
216
220
|
return;
|
|
217
221
|
}
|
|
218
|
-
if (['RUNNING', 'SUCCEEDED', 'CANCELLED'].includes(task.state))
|
|
222
|
+
if (['RUNNING', 'UNBLOCKED', 'SUCCEEDED', 'CANCELLED'].includes(task.state))
|
|
219
223
|
throw new Error(`Task ${id} is ${task.state}; durable execution fields are immutable in this state`);
|
|
220
224
|
if (hasArg(args, '--adapter')) {
|
|
221
225
|
const adapter = arg(args, '--adapter');
|
|
@@ -266,6 +270,8 @@ async function runTaskCommand(action, args) {
|
|
|
266
270
|
await transitionTaskEvent(task, 'retry');
|
|
267
271
|
else if (task.state === 'BLOCKED')
|
|
268
272
|
throw new Error(`Task ${id} is BLOCKED; use task unblock --reason instead of --queue`);
|
|
273
|
+
else if (task.state === 'UNBLOCKED')
|
|
274
|
+
throw new Error(`Task ${id} is historical UNBLOCKED work and cannot be queued`);
|
|
269
275
|
}
|
|
270
276
|
if (hasArg(args, '--max-attempts') && task.maxAttempts < task.attempt)
|
|
271
277
|
throw new Error('--max-attempts cannot be less than the current attempt count');
|
|
@@ -384,14 +390,15 @@ async function runJobCommand(action, args) {
|
|
|
384
390
|
await store.mergePullRequest(headSha ?? undefined);
|
|
385
391
|
await transitionJobEvent(job, 'pr.merged');
|
|
386
392
|
await store.saveJob(job);
|
|
387
|
-
await store.resolveBlockerDependents(id, 'merge');
|
|
393
|
+
const resolved = await store.resolveBlockerDependents(id, 'merge');
|
|
394
|
+
await publishDependentUnblocked(eventBus(args), resolved.unblocked, `Blocker Job ${id} resolved (merge)`);
|
|
388
395
|
console.log(JSON.stringify(await store.load(id), null, 2));
|
|
389
396
|
return;
|
|
390
397
|
}
|
|
391
398
|
if (action === 'cancel') {
|
|
392
399
|
if (job.state === 'COMPLETED')
|
|
393
400
|
throw new Error(`Cannot cancel completed job ${job.id}`);
|
|
394
|
-
for (const task of job.tasks.filter(item => !['SUCCEEDED', 'CANCELLED'].includes(item.state))) {
|
|
401
|
+
for (const task of job.tasks.filter(item => !['SUCCEEDED', 'UNBLOCKED', 'CANCELLED'].includes(item.state))) {
|
|
395
402
|
await transitionTaskEvent(task, 'cancel');
|
|
396
403
|
task.error = 'Job cancelled';
|
|
397
404
|
task.leaseOwner = null;
|
|
@@ -55,7 +55,11 @@ export function dashboardTaskSetState(store) {
|
|
|
55
55
|
if (task.state === state)
|
|
56
56
|
return job;
|
|
57
57
|
if (task.state === 'BLOCKED' && state !== 'CANCELLED')
|
|
58
|
-
throw new Error(`Task ${task.id} is BLOCKED; only
|
|
58
|
+
throw new Error(`Task ${task.id} is BLOCKED; only authoritative unblock may transition it to UNBLOCKED`);
|
|
59
|
+
if (state === 'UNBLOCKED')
|
|
60
|
+
throw new Error(`Task ${task.id} may enter UNBLOCKED only through authoritative unblock`);
|
|
61
|
+
if (task.state === 'UNBLOCKED')
|
|
62
|
+
throw new Error(`Task ${task.id} is historical UNBLOCKED work and cannot be changed by dashboard state override`);
|
|
59
63
|
const now = new Date().toISOString();
|
|
60
64
|
const from = task.state;
|
|
61
65
|
applyTaskWorkflowDecision(task, {
|
package/dist/dashboard-html.js
CHANGED
|
@@ -62,7 +62,7 @@ export const dashboardHtml = `<!doctype html>
|
|
|
62
62
|
.status-pill.ok,.badge.ACTIVE,.badge.RUNNING,.badge.SUCCEEDED,.badge.COMPLETED,.badge.MERGED { background:#e5f3e9; color:var(--ok); }
|
|
63
63
|
.status-pill.warn,.badge.BLOCKED,.badge.DRAFT,.badge.QUEUED,.badge.WAITING,.badge.OPEN { background:#fff1d2; color:var(--warn); }
|
|
64
64
|
.status-pill.bad,.badge.FAILED { background:#ffe8e8; color:var(--bad); }
|
|
65
|
-
.status-pill.neutral,.badge.IDLE,.badge.CANCELLED { background:#edf0f4; color:var(--muted); }
|
|
65
|
+
.status-pill.neutral,.badge.IDLE,.badge.UNBLOCKED,.badge.CANCELLED { background:#edf0f4; color:var(--muted); }
|
|
66
66
|
.badge.DRAFT { background:#eef2ff; color:#3f51b5; }
|
|
67
67
|
.badge.MERGE_PENDING { background:#ffe3a3; color:#7a4a00; }
|
|
68
68
|
.job-summary,.detail-groups { display:grid; grid-template-columns:repeat(2,minmax(0,1fr)); gap:.55rem; }
|
|
@@ -282,7 +282,7 @@ function rememberSelection(){if(selectedJob)sessionStorage.setItem('agents-relay
|
|
|
282
282
|
function syncMobileJobsView(){const detail=mobileJobs.matches&&Boolean(selectedJob)&&location.hash.startsWith('#/jobs/');document.body.dataset.mobileJobsView=detail?'detail':'list';}
|
|
283
283
|
function selected(){return jobs.find(job=>job.id===selectedJob)||jobs[0]||null;}
|
|
284
284
|
function counts(tasks){const out={};for(const task of Array.isArray(tasks)?tasks:[])out[task.state]=(out[task.state]||0)+1;return out;}
|
|
285
|
-
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';}
|
|
285
|
+
function compactCounts(input){const order=['RUNNING','BLOCKED','UNBLOCKED','FAILED','QUEUED','DRAFT','SUCCEEDED','CANCELLED'];return order.filter(state=>input[state]).map(state=>input[state]+' '+state).join(', ')||'No tasks';}
|
|
286
286
|
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';}
|
|
287
287
|
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;}
|
|
288
288
|
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;}
|
|
@@ -327,9 +327,9 @@ function attemptBudget(task){const attempt=Number(task?.attempt)||0;const max=Nu
|
|
|
327
327
|
const JOB_STATES=['DRAFT','ACTIVE','COMPLETED','CANCELLED'];
|
|
328
328
|
async function setJobState(job,state,select){if(!job||state===job.state)return;select.disabled=true;try{const response=await fetch('/api/job-state?id='+encodeURIComponent(job.id)+'&state='+encodeURIComponent(state),{method:'POST'});if(!response.ok)throw new Error(response.status+' '+response.statusText);const updated=await response.json();const index=jobs.findIndex(candidate=>candidate.id===job.id);if(index>=0)jobs[index]=updated;else jobs.push(updated);renderAll();void loadEventHistory();}catch(error){select.title='Set job lifecycle state: '+error.message;select.value=job.state;}finally{select.disabled=false;}}
|
|
329
329
|
function jobStateField(job){const box=element('div','detail-group');box.append(element('p','detail-label',text('Lifecycle state')));const select=document.createElement('select');select.className='job-filter job-state-select';select.setAttribute('aria-label','Job lifecycle state');for(const state of JOB_STATES)select.append(new Option(state,state));select.value=job.state;select.addEventListener('change',()=>void setJobState(job,select.value,select));box.append(select);return box;}
|
|
330
|
-
const TASK_STATES=['DRAFT','QUEUED','RUNNING','BLOCKED','SUCCEEDED','FAILED','CANCELLED'];
|
|
330
|
+
const TASK_STATES=['DRAFT','QUEUED','RUNNING','BLOCKED','UNBLOCKED','SUCCEEDED','FAILED','CANCELLED'];
|
|
331
331
|
async function setTaskState(task,state,select){const job=selected();if(!job||state===task.state)return;select.disabled=true;try{const response=await fetch('/api/task-state?id='+encodeURIComponent(job.id)+'&task='+encodeURIComponent(task.id)+'&state='+encodeURIComponent(state),{method:'POST'});if(!response.ok)throw new Error(response.status+' '+response.statusText);const updated=await response.json();const index=jobs.findIndex(candidate=>candidate.id===job.id);if(index>=0)jobs[index]=updated;else jobs.push(updated);renderAll();void loadEventHistory();}catch(error){select.title='Set task state: '+error.message;select.value=task.state;}finally{select.disabled=false;}}
|
|
332
|
-
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)
|
|
332
|
+
function taskStateField(task){const box=element('div','detail-group');box.append(element('p','detail-label',text('State')));const select=document.createElement('select');select.className='job-filter task-state-select';select.setAttribute('aria-label','Task state');for(const state of TASK_STATES){const option=new Option(state,state);if(state==='UNBLOCKED'&&task.state!=='UNBLOCKED')option.disabled=true;select.append(option);}select.value=task.state;select.addEventListener('change',()=>void setTaskState(task,select.value,select));box.append(select);return box;}
|
|
333
333
|
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):'—');}
|
|
334
334
|
function renderTaskDetail(task){taskDetailRoot.replaceChildren();if(!task){taskDetailRoot.append(element('p','empty',text('Select a task to inspect execution details.')));return;}const routing=task.routing||{};const groups=element('div','detail-groups');groups.append(field('Task',task.id),field('Kind',task.kind||'work'),taskStateField(task),manualOverrideField(task),field('Planning decision',task.planningDecisionId||'—'),field('Planning source',task.planningSource||'—'),field('Planning round',task.planningRound===undefined?'—':String(task.planningRound)),field('Project / agent',value(task.projectName)+' / '+value(task.agentName)),field('Parent task',task.parentTaskId||'root'),field('Adapter',task.adapter),field('Provider / model',value(routing.provider)+' / '+value(routing.model)),field('Reasoning / profile',value(routing.reasoning||routing.profile)),field('Attempt',attemptBudget(task)),field('Dependencies',Array.isArray(task.dependencies)&&task.dependencies.length?task.dependencies.join(', '):'none'),taskCommentField(task),chatField(task));const job=selected();if(task.reviewHeadSha&&job)groups.append(commitField('HEAD commit',job,task.reviewHeadSha));const graph=agentGraphField(task);if(graph)groups.append(graph);if(task.plannerResult)groups.append(field('Objective status',task.plannerResult.objective_status),markdownField('Assessment',task.plannerResult.assessment));if(task.error)groups.append(field('Error',task.error));taskDetailRoot.append(groups);}
|
|
335
335
|
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();}
|
package/dist/manual-plan.js
CHANGED
|
@@ -1,4 +1,4 @@
|
|
|
1
|
-
import { createPlannerRoundTask } from './planner.js';
|
|
1
|
+
import { createPlannerRoundTask, pendingPlanForPlannerTask, recoverPendingPlannerTask } from './planner.js';
|
|
2
2
|
import { transitionJobEvent } from './scheduler.js';
|
|
3
3
|
export async function executeManualPlan(store, jobId, guidance, timeoutMs, launchPlanner) {
|
|
4
4
|
let job = await store.load(jobId);
|
|
@@ -7,7 +7,10 @@ export async function executeManualPlan(store, jobId, guidance, timeoutMs, launc
|
|
|
7
7
|
const active = job.tasks.find(task => task.kind === 'planner' && ['DRAFT', 'QUEUED', 'RUNNING'].includes(task.state));
|
|
8
8
|
if (active)
|
|
9
9
|
throw new Error(`Planning round ${active.id} is already ${active.state}`);
|
|
10
|
-
const
|
|
10
|
+
const recovered = recoverPendingPlannerTask(job, timeoutMs);
|
|
11
|
+
const round = recovered ?? createPlannerRoundTask(job, { guidance, manual: true, timeoutMs });
|
|
12
|
+
if (!recovered)
|
|
13
|
+
await store.appendPlan(pendingPlanForPlannerTask(job, round));
|
|
11
14
|
await store.appendTask(round);
|
|
12
15
|
job = await store.load(jobId);
|
|
13
16
|
if (job.state === 'DRAFT') {
|
package/dist/markers.js
CHANGED
|
@@ -1,5 +1,6 @@
|
|
|
1
1
|
export const JOB_MARKER = 'agents-relay:job:v1';
|
|
2
2
|
export const TASK_MARKER = 'agents-relay:task:v1';
|
|
3
|
+
export const PLAN_MARKER = 'agents-relay:plan:v1';
|
|
3
4
|
export const AGENT_MARKER = 'agents-relay:agent:v1';
|
|
4
5
|
export function marker(label, value) { return `<!-- ${label} ${JSON.stringify(value).replace(/>/g, '\\u003e')} -->`; }
|
|
5
6
|
export function parseMarker(body, label) {
|
|
@@ -33,7 +34,7 @@ function contentBeforeDelimiter(body, heading, end) {
|
|
|
33
34
|
return body.slice(contentStart, endAt);
|
|
34
35
|
}
|
|
35
36
|
function jobMetadata(job) {
|
|
36
|
-
const { objective: _objective, description: _description, tasks: _tasks, ...metadata } = job;
|
|
37
|
+
const { objective: _objective, description: _description, tasks: _tasks, plans: _plans, ...metadata } = job;
|
|
37
38
|
return metadata;
|
|
38
39
|
}
|
|
39
40
|
export function renderJobBody(job) {
|
|
@@ -104,5 +105,25 @@ export function parseTask(body) {
|
|
|
104
105
|
return null;
|
|
105
106
|
return { ...task, input, kind: task.kind === 'planner' ? 'planner' : 'work' };
|
|
106
107
|
}
|
|
108
|
+
export function renderPlanComment(plan) {
|
|
109
|
+
const { planUrl: _planUrl, ...metadata } = plan;
|
|
110
|
+
const triggerLines = [
|
|
111
|
+
`- **State:** ${plan.status}`,
|
|
112
|
+
`- **Source:** ${plan.trigger.source}`,
|
|
113
|
+
plan.trigger.taskIds?.length ? `- **Triggered tasks:** ${plan.trigger.taskIds.join(', ')}` : '',
|
|
114
|
+
plan.trigger.reason ? `- **Reason:** ${plan.trigger.reason}` : '',
|
|
115
|
+
plan.trigger.blockedHeadShas?.length ? `- **Blocked head(s):** ${plan.trigger.blockedHeadShas.join(', ')}` : '',
|
|
116
|
+
plan.trigger.currentHeadSha ? `- **Current head:** ${plan.trigger.currentHeadSha}` : '',
|
|
117
|
+
].filter(Boolean).join('\n');
|
|
118
|
+
const generated = plan.generatedTaskIds.length ? plan.generatedTaskIds.map(id => `- ${id}`).join('\n') : '- _No tasks generated._';
|
|
119
|
+
const facts = plan.trigger.facts?.length ? `\n\n### Change facts\n\n${plan.trigger.facts.map(fact => `- ${fact}`).join('\n')}` : '';
|
|
120
|
+
return `${marker(PLAN_MARKER, metadata)}\n## Plan ${plan.round}: ${plan.id}\n\n${triggerLines}${facts}\n\n### Assessment\n\n${plan.assessment}\n\n### Generated tasks\n\n${generated}`;
|
|
121
|
+
}
|
|
122
|
+
export function parsePlan(body) {
|
|
123
|
+
const plan = parseMarker(body, PLAN_MARKER);
|
|
124
|
+
if (!plan || typeof plan.jobId !== 'string' || typeof plan.id !== 'string' || !Number.isInteger(plan.round))
|
|
125
|
+
return null;
|
|
126
|
+
return plan;
|
|
127
|
+
}
|
|
107
128
|
export function renderAgentComment(agent) { return `${marker(AGENT_MARKER, agent)}\n## Registered agent ${agent.name}\nRole: ${agent.role}\nAvailability: ${agent.availability}`; }
|
|
108
129
|
export function parseAgent(body) { return parseMarker(body, AGENT_MARKER); }
|
package/dist/planner.js
CHANGED
|
@@ -5,6 +5,29 @@ import { dirname, join } from 'node:path';
|
|
|
5
5
|
import { storySizeTimeoutMs, taskOverallTimeoutMs } from './types.js';
|
|
6
6
|
import { executionContextPrompt } from './execution-context.js';
|
|
7
7
|
import { parseAgentGraph } from './agent-graph.js';
|
|
8
|
+
function plannerTrigger(job, manual = false) {
|
|
9
|
+
if (manual)
|
|
10
|
+
return { source: 'manual' };
|
|
11
|
+
const consumed = new Set((job.plans ?? []).flatMap(plan => plan.trigger.source === 'unblock' ? plan.trigger.taskIds ?? [] : []));
|
|
12
|
+
const unblocked = job.tasks.filter(task => task.state === 'UNBLOCKED' && task.unblock && !consumed.has(task.id));
|
|
13
|
+
if (unblocked.length > 0) {
|
|
14
|
+
const records = unblocked.map(task => task.unblock);
|
|
15
|
+
const blockedHeadShas = [...new Set(records.map(record => record.baseline.headSha).filter((value) => Boolean(value)))];
|
|
16
|
+
return {
|
|
17
|
+
source: 'unblock', taskIds: unblocked.map(task => task.id),
|
|
18
|
+
reason: [...new Set(records.map(record => record.reason))].join('; '),
|
|
19
|
+
blockedHeadShas,
|
|
20
|
+
currentHeadSha: records.map(record => record.currentHeadSha).find((value) => Boolean(value)),
|
|
21
|
+
unblockSource: records[0].source,
|
|
22
|
+
facts: [...new Set(records.flatMap(record => record.facts ?? [`unblock: ${record.reason}`]))],
|
|
23
|
+
};
|
|
24
|
+
}
|
|
25
|
+
if (job.changeCycle && !job.changeCycle.consumedByPlannerTaskId)
|
|
26
|
+
return { source: 'unblock', taskIds: job.changeCycle.taskIds, reason: job.changeCycle.reason, facts: job.changeCycle.facts, blockedHeadShas: job.changeCycle.baselineHeadShas, currentHeadSha: job.changeCycle.currentHeadSha, unblockSource: job.changeCycle.source };
|
|
27
|
+
if (job.schedule?.currentRunSlot)
|
|
28
|
+
return { source: 'schedule' };
|
|
29
|
+
return (job.plans?.length ?? 0) === 0 && !job.tasks.some(task => task.kind === 'planner') ? { source: 'initial' } : { source: 'continuation' };
|
|
30
|
+
}
|
|
8
31
|
export function plannerRoundNumber(task) {
|
|
9
32
|
if (task.kind !== 'planner')
|
|
10
33
|
return null;
|
|
@@ -30,6 +53,7 @@ export function createPlannerRoundTask(job, options = {}) {
|
|
|
30
53
|
tasks: job.tasks,
|
|
31
54
|
objective: job.objective ?? job.description ?? job.title,
|
|
32
55
|
previousPlannerTaskId: previousPlanner?.id ?? null,
|
|
56
|
+
trigger: plannerTrigger(job, options.manual === true),
|
|
33
57
|
guidance: options.guidance,
|
|
34
58
|
};
|
|
35
59
|
return {
|
|
@@ -39,9 +63,31 @@ export function createPlannerRoundTask(job, options = {}) {
|
|
|
39
63
|
maxAttempts: options.manual ? 1 : 3, leaseOwner: null, leaseExpiresAt: null, executionId: null, threadId: null,
|
|
40
64
|
result: null, plannerResult: null, error: null, timeoutMs: options.timeoutMs ?? storySizeTimeoutMs('M'),
|
|
41
65
|
createdAt: now, updatedAt: now, planningDecisionId: id, planningSource: 'model-planner', planningRound: round, previousPlannerTaskId: previousPlanner?.id ?? null,
|
|
42
|
-
plannerGuidance: options.guidance, manualPlanning: options.manual === true,
|
|
66
|
+
plannerGuidance: options.guidance, manualPlanning: options.manual === true, planTrigger: context.trigger,
|
|
43
67
|
};
|
|
44
68
|
}
|
|
69
|
+
export function pendingPlanForPlannerTask(job, task) {
|
|
70
|
+
return {
|
|
71
|
+
jobId: job.id, id: task.id, round: task.planningRound ?? 1, source: task.planningSource ?? 'model-planner',
|
|
72
|
+
previousPlanId: task.previousPlannerTaskId ?? null, trigger: task.planTrigger ?? { source: task.manualPlanning ? 'manual' : 'continuation' },
|
|
73
|
+
status: 'PLANNING', guidance: task.plannerGuidance, assessment: 'Planning in progress.', generatedTaskIds: [],
|
|
74
|
+
headSha: task.planTrigger?.currentHeadSha ?? job.headSha, createdAt: task.createdAt, updatedAt: task.updatedAt,
|
|
75
|
+
};
|
|
76
|
+
}
|
|
77
|
+
export function recoverPendingPlannerTask(job, timeoutMs) {
|
|
78
|
+
const pending = (job.plans ?? []).filter(plan => plan.status === 'PLANNING' && !job.tasks.some(task => task.kind === 'planner' && task.id === plan.id)).sort((a, b) => a.round - b.round)[0];
|
|
79
|
+
if (!pending)
|
|
80
|
+
return null;
|
|
81
|
+
const task = createPlannerRoundTask(job, { guidance: pending.guidance, manual: pending.trigger.source === 'manual', timeoutMs, now: pending.createdAt });
|
|
82
|
+
if (task.id !== pending.id || task.planningRound !== pending.round)
|
|
83
|
+
throw new Error(`Pending Plan ${pending.id} cannot be recovered as planner round ${task.id}`);
|
|
84
|
+
task.previousPlannerTaskId = pending.previousPlanId;
|
|
85
|
+
task.planTrigger = pending.trigger;
|
|
86
|
+
task.planningSource = pending.source;
|
|
87
|
+
task.planningDecisionId = pending.id;
|
|
88
|
+
task.input = plannerInput({ job, tasks: job.tasks, objective: job.objective ?? job.description ?? job.title, previousPlannerTaskId: pending.previousPlanId, trigger: pending.trigger, guidance: pending.guidance });
|
|
89
|
+
return task;
|
|
90
|
+
}
|
|
45
91
|
export function planningSpec(task) {
|
|
46
92
|
if (!task.output && task.adapter !== 'orchestrator')
|
|
47
93
|
throw new Error(`Initial task ${task.id} requires an output contract`);
|
|
@@ -281,7 +327,8 @@ export function plannerInput(context) {
|
|
|
281
327
|
retry_feedback: context.retryFeedback,
|
|
282
328
|
caller_prompt: context.guidance,
|
|
283
329
|
previous_planner_task_id: context.previousPlannerTaskId,
|
|
284
|
-
|
|
330
|
+
plan_trigger: context.trigger,
|
|
331
|
+
legacy_change_cycle: context.job.changeCycle,
|
|
285
332
|
project: project ? { name: project.name, path: project.path } : undefined,
|
|
286
333
|
visible_agents: visibleAgents ?? [],
|
|
287
334
|
tasks: context.tasks.map(task => ({
|
package/dist/pool.js
CHANGED
|
@@ -226,7 +226,7 @@ export class RepositoryWorkerPool {
|
|
|
226
226
|
}
|
|
227
227
|
if (routedWebhook)
|
|
228
228
|
return;
|
|
229
|
-
const lifecycle = ['task.started', 'task.completed', 'task.failed', 'task.blocked', 'task.cancelled', 'review.started', 'review.approved', 'review.changes_required', 'review.blocked'].includes(event.type);
|
|
229
|
+
const lifecycle = ['task.started', 'task.completed', 'task.failed', 'task.blocked', 'task.unblocked', 'task.cancelled', 'review.started', 'review.approved', 'review.changes_required', 'review.blocked'].includes(event.type);
|
|
230
230
|
const wake = event.type === 'job.wake' || event.type === 'github.webhook';
|
|
231
231
|
if (!lifecycle && !wake)
|
|
232
232
|
return;
|
package/dist/reconciler.js
CHANGED
|
@@ -4,7 +4,7 @@ import { dependencyReady, schedule, transitionJobEvent, transitionTaskEvent } fr
|
|
|
4
4
|
import { implementationComplete } from './runtime-status.js';
|
|
5
5
|
import { LaunchFailure, StartAckTimeout } from './adapters.js';
|
|
6
6
|
import { eventFor } from './events.js';
|
|
7
|
-
import { createPlannerRoundTask, parsePlannerResult, plannerInput, validatePlannerHierarchy } from './planner.js';
|
|
7
|
+
import { createPlannerRoundTask, pendingPlanForPlannerTask, recoverPendingPlannerTask, parsePlannerResult, plannerInput, validatePlannerHierarchy } from './planner.js';
|
|
8
8
|
import { agentContractPrompt, executionContextPrompt, resolveExecutionContext } from './execution-context.js';
|
|
9
9
|
import { agentGraphPrompt, validateAgentSelection } from './agent-graph.js';
|
|
10
10
|
import { ReconcilerReviewBase } from './reconciler-review.js';
|
|
@@ -17,7 +17,7 @@ import { pendingTroubleshooterTasks } from './troubleshooter.js';
|
|
|
17
17
|
import { prepareManagedExecutionWorktree } from './workspace.js';
|
|
18
18
|
import { createReviewChairExecutionPlan } from './review-chair.js';
|
|
19
19
|
import { samePlannerChildDefinition } from './planner-child.js';
|
|
20
|
-
import {
|
|
20
|
+
import { isEffectivelyBlocked } from './unblock.js';
|
|
21
21
|
import { taskTagWorkerPrompt } from './task-tag.js';
|
|
22
22
|
const PRIORITY_RANK = { P0: 0, P1: 1, P2: 2, P3: 3 };
|
|
23
23
|
export function compareTaskPriority(a, b, jobPriority = 'P2') { const ar = PRIORITY_RANK[a.priority ?? jobPriority]; const br = PRIORITY_RANK[b.priority ?? jobPriority]; return ar - br || a.createdAt.localeCompare(b.createdAt) || a.id.localeCompare(b.id); }
|
|
@@ -46,14 +46,10 @@ Job ID: ${job.id}
|
|
|
46
46
|
Task URL: ${taskUrl}
|
|
47
47
|
Task ID: ${task.id}
|
|
48
48
|
Project: ${task.projectName ?? context?.project.name ?? 'unspecified'}
|
|
49
|
-
|
|
50
49
|
Retrieve the durable job and task from GitHub before doing the work. Use whatever GitHub access this environment provides: the URLs, a GitHub integration/tool, or GitHub CLI/API. GitHub is the source of truth for task intent and declared output.
|
|
51
|
-
|
|
52
50
|
${context ? executionContextPrompt(context) + '\n\n' : ''}${selectedAgent ? selectedAgent + '\n\n' : ''}${selectedGraph ? selectedGraph + '\n\n' : ''}${chairPlan ? `[Review Chair execution plan]\n${JSON.stringify(chairPlan, null, 2)}\n\nUse this runtime-discovered panel to create sibling durable specialist Tasks. Preserve each specialistTasks entry as review metadata, keep the target immutable, and synthesize structured results with synthesizeReview semantics before returning the unified revision plan.\n\n` : ''}Follow the resolved project instructions and Agents Relay/events-bus worker contracts.
|
|
53
|
-
|
|
54
51
|
[Output contract]
|
|
55
52
|
${output}
|
|
56
|
-
|
|
57
53
|
${taskTag}[Execution event contract — mandatory]
|
|
58
54
|
Use the canonical Neo events-bus with exactly Job ${job.id} and Task ${task.id}.
|
|
59
55
|
Your exact event source identity is: ${sourceId}
|
|
@@ -61,7 +57,6 @@ Every lifecycle event for this execution MUST set source.id to that exact string
|
|
|
61
57
|
Follow the events-bus skill/protocol. For sandboxed Codex or hosted ChatGPT,
|
|
62
58
|
publish through the federated MCP tool events__publish; do not open NATS directly.
|
|
63
59
|
For a direct non-sandbox local worker, use NEO_EVENTS_EMIT when the caller provides it.
|
|
64
|
-
|
|
65
60
|
As your FIRST execution action, before reading files, editing code, running commands, or doing other work,
|
|
66
61
|
you MUST publish exactly one canonical task.started event for this exact Job/Task.
|
|
67
62
|
|
|
@@ -136,8 +131,11 @@ export class Reconciler extends ReconcilerReviewBase {
|
|
|
136
131
|
await this.applyJobEffectOutcomes(job, outcomes);
|
|
137
132
|
}
|
|
138
133
|
}
|
|
139
|
-
if (prState === 'MERGED' && this.store.resolveBlockerDependents)
|
|
140
|
-
await this.store.resolveBlockerDependents(job.id, 'merge');
|
|
134
|
+
if (prState === 'MERGED' && this.store.resolveBlockerDependents) {
|
|
135
|
+
const resolved = await this.store.resolveBlockerDependents(job.id, 'merge');
|
|
136
|
+
for (const target of resolved.unblocked)
|
|
137
|
+
await this.emit(eventFor(target.jobId, target.taskId, null, 'task.unblocked', 'unblocked', `Blocker Job ${job.id} resolved (merge)`, 'orchestrator', { repository: target.repository, prNumber: target.prNumber, blockerJobId: job.id }));
|
|
138
|
+
}
|
|
141
139
|
return this.store.load(jobId);
|
|
142
140
|
}
|
|
143
141
|
if (job.state !== 'ACTIVE')
|
|
@@ -266,6 +264,12 @@ export class Reconciler extends ReconcilerReviewBase {
|
|
|
266
264
|
return job;
|
|
267
265
|
if (job.initialPlanning?.status === 'pending')
|
|
268
266
|
return job;
|
|
267
|
+
const recovered = recoverPendingPlannerTask(job, this.options.plannerTimeoutMs ?? storySizeTimeoutMs('M'));
|
|
268
|
+
if (recovered) {
|
|
269
|
+
await this.store.appendTask(recovered);
|
|
270
|
+
await this.emit(eventFor(job.id, recovered.id, null, 'planner.created', 'queued', `Recovered pending Plan ${recovered.id}`, 'orchestrator'));
|
|
271
|
+
return this.store.load(job.id);
|
|
272
|
+
}
|
|
269
273
|
const allPlanners = job.tasks.filter(task => task.kind === 'planner').sort((a, b) => a.createdAt.localeCompare(b.createdAt) || a.id.localeCompare(b.id));
|
|
270
274
|
if (allPlanners.length === 0 && job.tasks.length === 0)
|
|
271
275
|
return job;
|
|
@@ -284,9 +288,8 @@ export class Reconciler extends ReconcilerReviewBase {
|
|
|
284
288
|
if (latest?.plannerResult?.objective_status === 'satisfied')
|
|
285
289
|
return job;
|
|
286
290
|
const task = createPlannerRoundTask(job, { timeoutMs: this.options.plannerTimeoutMs ?? storySizeTimeoutMs('M') });
|
|
291
|
+
await this.store.appendPlan(pendingPlanForPlannerTask(job, task));
|
|
287
292
|
await this.store.appendTask(task);
|
|
288
|
-
if (consumeChangeCycle(job, task.id))
|
|
289
|
-
await this.store.saveJob(job);
|
|
290
293
|
await this.emit(eventFor(job.id, task.id, task.parentTaskId, 'planner.created', 'queued', `Planner task ${task.id} created`, 'orchestrator'));
|
|
291
294
|
return this.store.load(job.id);
|
|
292
295
|
}
|
|
@@ -296,7 +299,15 @@ export class Reconciler extends ReconcilerReviewBase {
|
|
|
296
299
|
return;
|
|
297
300
|
const executionContext = await this.plannerContext(job, contextTask);
|
|
298
301
|
for (const planner of job.tasks.filter(task => task.kind === 'planner' && task.state === 'SUCCEEDED' && task.plannerResult)) {
|
|
299
|
-
const
|
|
302
|
+
const result = planner.plannerResult;
|
|
303
|
+
const plan = { jobId: job.id, id: planner.id, round: planner.planningRound ?? 1, source: planner.planningSource ?? 'model-planner', previousPlanId: planner.previousPlannerTaskId ?? null, trigger: planner.planTrigger ?? { source: 'continuation' }, status: 'SUCCEEDED', guidance: planner.plannerGuidance, objectiveStatus: result.objective_status, assessment: result.assessment, generatedTaskIds: result.next_tasks.map(item => item.id), headSha: planner.planTrigger?.currentHeadSha ?? job.headSha, createdAt: planner.createdAt, updatedAt: planner.updatedAt };
|
|
304
|
+
if (!(job.plans ?? []).some(item => item.id === plan.id)) {
|
|
305
|
+
await this.store.appendPlan(plan);
|
|
306
|
+
(job.plans ??= []).push(plan);
|
|
307
|
+
}
|
|
308
|
+
else
|
|
309
|
+
await this.store.savePlan(plan);
|
|
310
|
+
const children = result.next_tasks.map(spec => this.plannerChild(job, planner, spec, executionContext));
|
|
300
311
|
for (const child of children) {
|
|
301
312
|
const existing = job.tasks.find(item => item.id === child.id);
|
|
302
313
|
if (existing) {
|
|
@@ -458,7 +469,7 @@ export class Reconciler extends ReconcilerReviewBase {
|
|
|
458
469
|
let promise;
|
|
459
470
|
try {
|
|
460
471
|
const executionContext = await this.plannerContext(job, task);
|
|
461
|
-
const context = { job, tasks: job.tasks, objective: job.objective ?? job.description ?? job.title, previousPlannerTaskId: task.previousPlannerTaskId ?? null, executionContext, retryFeedback: task.plannerRetryFeedback, guidance: task.plannerGuidance };
|
|
472
|
+
const context = { job, tasks: job.tasks, objective: job.objective ?? job.description ?? job.title, previousPlannerTaskId: task.previousPlannerTaskId ?? null, trigger: task.planTrigger, executionContext, retryFeedback: task.plannerRetryFeedback, guidance: task.plannerGuidance };
|
|
462
473
|
task.input = plannerInput(context);
|
|
463
474
|
await this.store.saveTask(task);
|
|
464
475
|
promise = planner.plan(context, controller.signal);
|
|
@@ -491,6 +502,8 @@ export class Reconciler extends ReconcilerReviewBase {
|
|
|
491
502
|
if (task.attempt < task.maxAttempts)
|
|
492
503
|
await transitionTaskEvent(task, 'retry');
|
|
493
504
|
await this.store.saveTask(task);
|
|
505
|
+
const pending = (job.plans ?? []).find(plan => plan.id === task.id) ?? pendingPlanForPlannerTask(job, task);
|
|
506
|
+
await this.store.savePlan({ ...pending, status: task.state === 'FAILED' ? 'FAILED' : 'PLANNING', error: error.message, assessment: task.state === 'FAILED' ? `Planning failed: ${error.message}` : `Planning retry: ${error.message}`, updatedAt: new Date().toISOString() });
|
|
494
507
|
await this.emit(eventFor(job.id, task.id, task.parentTaskId, task.state === 'FAILED' ? 'planner.failed' : 'planner.retry', task.state === 'FAILED' ? 'failed' : 'queued', `Planner ${task.id}: ${error.message}`, 'user'));
|
|
495
508
|
this.detach(this.reconcile(jobId), jobId, task.id, 'planner retry reconciliation');
|
|
496
509
|
return;
|
|
@@ -509,7 +522,15 @@ export class Reconciler extends ReconcilerReviewBase {
|
|
|
509
522
|
if (existing && !samePlannerChildDefinition(existing, child))
|
|
510
523
|
throw new Error(`Planner child ${child.id} already exists with a different definition`);
|
|
511
524
|
}
|
|
512
|
-
// Persist the
|
|
525
|
+
// Persist the first-class Plan decision before exposing executable child Task comments.
|
|
526
|
+
const now = new Date().toISOString();
|
|
527
|
+
const plan = { jobId: job.id, id: task.id, round: task.planningRound ?? 1, source: task.planningSource ?? 'model-planner', previousPlanId: task.previousPlannerTaskId ?? null, trigger: task.planTrigger ?? { source: task.manualPlanning ? 'manual' : 'continuation' }, status: 'SUCCEEDED', guidance: task.plannerGuidance, objectiveStatus: result.objective_status, assessment: result.assessment, generatedTaskIds: result.next_tasks.map(item => item.id), headSha: task.planTrigger?.currentHeadSha ?? await this.store.headSha?.() ?? job.headSha, createdAt: task.createdAt, updatedAt: now };
|
|
528
|
+
await this.store.savePlan(plan);
|
|
529
|
+
if (job.changeCycle && !job.changeCycle.consumedByPlannerTaskId && plan.trigger.source === 'unblock') {
|
|
530
|
+
job.changeCycle.consumedByPlannerTaskId = task.id;
|
|
531
|
+
job.updatedAt = now;
|
|
532
|
+
await this.store.saveJob(job);
|
|
533
|
+
}
|
|
513
534
|
task.plannerResult = result;
|
|
514
535
|
task.result = { summary: result.assessment, data: { objective_status: result.objective_status, next_tasks: result.next_tasks, unblocks: result.unblocks } };
|
|
515
536
|
task.error = null;
|
|
@@ -543,6 +564,8 @@ export class Reconciler extends ReconcilerReviewBase {
|
|
|
543
564
|
await this.store.saveTask(task);
|
|
544
565
|
await this.emit(eventFor(job.id, task.id, task.parentTaskId, task.state === 'FAILED' ? 'planner.failed' : 'planner.retry', task.state === 'FAILED' ? 'failed' : 'queued', `Planner ${task.id}: ${message}`, 'user'));
|
|
545
566
|
}
|
|
567
|
+
const pending = (await this.store.load(job.id)).plans?.find(plan => plan.id === task.id) ?? pendingPlanForPlannerTask(job, task);
|
|
568
|
+
await this.store.savePlan({ ...pending, status: task.state === 'QUEUED' ? 'PLANNING' : 'FAILED', error: message, assessment: task.state === 'QUEUED' ? `Planning retry: ${message}` : `Planning failed: ${message}`, updatedAt: new Date().toISOString() });
|
|
546
569
|
}
|
|
547
570
|
if (!task.manualPlanning)
|
|
548
571
|
this.detach(this.reconcile(jobId), jobId, task.id, 'planner follow-up reconciliation');
|
package/dist/runtime-status.js
CHANGED
|
@@ -5,7 +5,7 @@ export function implementationTasks(job) {
|
|
|
5
5
|
return job.tasks.filter(task => task.agentName !== 'reviewer'
|
|
6
6
|
&& task.agentName !== 'troubleshooter'
|
|
7
7
|
&& !isSuperseded(task)
|
|
8
|
-
&&
|
|
8
|
+
&& task.state !== 'UNBLOCKED');
|
|
9
9
|
}
|
|
10
10
|
export function implementationComplete(job) {
|
|
11
11
|
const tasks = implementationTasks(job);
|
|
@@ -23,7 +23,7 @@ export function deriveJobRuntimeStatus(job, prState = 'OPEN') {
|
|
|
23
23
|
return 'RUNNING';
|
|
24
24
|
if (job.tasks.some(task => task.state === 'QUEUED'))
|
|
25
25
|
return 'QUEUED';
|
|
26
|
-
if (job.tasks.some(task => task.state === 'BLOCKED'
|
|
26
|
+
if (job.tasks.some(task => task.state === 'BLOCKED'))
|
|
27
27
|
return 'BLOCKED';
|
|
28
28
|
if (job.tasks.some(task => task.state === 'FAILED'))
|
|
29
29
|
return 'FAILED';
|
|
@@ -43,7 +43,7 @@ export function deriveJobRuntimeStatus(job, prState = 'OPEN') {
|
|
|
43
43
|
return 'RUNNING';
|
|
44
44
|
if (job.tasks.some(task => task.state === 'QUEUED'))
|
|
45
45
|
return 'QUEUED';
|
|
46
|
-
if (job.tasks.some(task => task.state === 'BLOCKED'
|
|
46
|
+
if (job.tasks.some(task => task.state === 'BLOCKED'))
|
|
47
47
|
return 'BLOCKED';
|
|
48
48
|
if (job.tasks.some(task => task.state === 'FAILED'))
|
|
49
49
|
return 'FAILED';
|
package/dist/store-github.js
CHANGED
|
@@ -1,7 +1,8 @@
|
|
|
1
|
-
import { parseAgent, parseJob, parseTask, renderAgentComment, renderJobBody, renderTaskComment, JOB_MARKER } from './markers.js';
|
|
1
|
+
import { parseAgent, parseJob, parsePlan, parseTask, renderAgentComment, renderJobBody, renderPlanComment, renderTaskComment, JOB_MARKER } from './markers.js';
|
|
2
2
|
import { isTaskForJob, sameTaskDefinition } from './store-task-definition.js';
|
|
3
3
|
import { registerBlockerDependency, resolveBlockerDependencies } from './blockers.js';
|
|
4
4
|
const TASK_WRITE_OVERLAY = new Map();
|
|
5
|
+
const PLAN_WRITE_OVERLAY = new Map();
|
|
5
6
|
export class GitHubStore {
|
|
6
7
|
client;
|
|
7
8
|
repository;
|
|
@@ -24,7 +25,9 @@ export class GitHubStore {
|
|
|
24
25
|
}
|
|
25
26
|
}
|
|
26
27
|
taskKey(task) { return `${this.repository}#${this.prNumber}:${task.jobId ?? ''}/${task.id}`; }
|
|
28
|
+
planKey(plan) { return `${this.repository}#${this.prNumber}:${plan.jobId}/${plan.id}`; }
|
|
27
29
|
taskKeyFromBody(body) { const task = parseTask(body); return task ? this.taskKey(task) : null; }
|
|
30
|
+
planKeyFromBody(body) { const plan = parsePlan(body); return plan ? this.planKey(plan) : null; }
|
|
28
31
|
overlayTaskWrites(comments) {
|
|
29
32
|
if (TASK_WRITE_OVERLAY.size === 0)
|
|
30
33
|
return comments;
|
|
@@ -46,6 +49,27 @@ export class GitHubStore {
|
|
|
46
49
|
return result;
|
|
47
50
|
}
|
|
48
51
|
rememberTaskWrite(task, comment, body) { TASK_WRITE_OVERLAY.set(this.taskKey(task), { ...comment, body }); }
|
|
52
|
+
overlayPlanWrites(comments) {
|
|
53
|
+
if (PLAN_WRITE_OVERLAY.size === 0)
|
|
54
|
+
return comments;
|
|
55
|
+
const result = [...comments];
|
|
56
|
+
for (const [key, overlay] of [...PLAN_WRITE_OVERLAY]) {
|
|
57
|
+
const index = result.findIndex(comment => this.planKeyFromBody(comment.body) === key);
|
|
58
|
+
if (index >= 0) {
|
|
59
|
+
const remote = result[index];
|
|
60
|
+
if (remote.updatedAt && overlay.updatedAt && remote.updatedAt >= overlay.updatedAt) {
|
|
61
|
+
PLAN_WRITE_OVERLAY.delete(key);
|
|
62
|
+
continue;
|
|
63
|
+
}
|
|
64
|
+
result[index] = { ...remote, ...overlay, id: remote.id || overlay.id, nodeId: remote.nodeId ?? overlay.nodeId, url: remote.url ?? overlay.url, authorLogin: remote.authorLogin ?? overlay.authorLogin };
|
|
65
|
+
}
|
|
66
|
+
else {
|
|
67
|
+
PLAN_WRITE_OVERLAY.delete(key);
|
|
68
|
+
}
|
|
69
|
+
}
|
|
70
|
+
return result;
|
|
71
|
+
}
|
|
72
|
+
rememberPlanWrite(plan, comment, body) { PLAN_WRITE_OVERLAY.set(this.planKey(plan), { ...comment, body }); }
|
|
49
73
|
isTrusted(comment) { return comment.authorLogin !== null && this.trustedAuthors.has(comment.authorLogin); }
|
|
50
74
|
repoParts() {
|
|
51
75
|
const [owner, name] = this.repository.split('/', 2);
|
|
@@ -86,9 +110,9 @@ export class GitHubStore {
|
|
|
86
110
|
}
|
|
87
111
|
async list() {
|
|
88
112
|
if (this.commentsCache)
|
|
89
|
-
return this.overlayTaskWrites(this.commentsCache);
|
|
113
|
+
return this.overlayPlanWrites(this.overlayTaskWrites(this.commentsCache));
|
|
90
114
|
if (this.commentsFlight)
|
|
91
|
-
return this.overlayTaskWrites(await this.commentsFlight);
|
|
115
|
+
return this.overlayPlanWrites(this.overlayTaskWrites(await this.commentsFlight));
|
|
92
116
|
const flight = (async () => {
|
|
93
117
|
const { owner, name } = this.repoParts();
|
|
94
118
|
const all = [];
|
|
@@ -111,7 +135,7 @@ export class GitHubStore {
|
|
|
111
135
|
this.commentsFlight = flight;
|
|
112
136
|
try {
|
|
113
137
|
this.commentsCache = await flight;
|
|
114
|
-
return this.overlayTaskWrites(this.commentsCache);
|
|
138
|
+
return this.overlayPlanWrites(this.overlayTaskWrites(this.commentsCache));
|
|
115
139
|
}
|
|
116
140
|
finally {
|
|
117
141
|
if (this.commentsFlight === flight)
|
|
@@ -182,6 +206,8 @@ export class GitHubStore {
|
|
|
182
206
|
throw new Error(`JOB_MARKER_MISSING: Managed GitHub job ${jobId} has no durable ${JOB_MARKER} marker in PR #${this.prNumber}`);
|
|
183
207
|
}
|
|
184
208
|
const soleTrustedJob = jobs.length === 1;
|
|
209
|
+
job.plans = comments.map(comment => { const plan = parsePlan(comment.body); if (!plan || plan.jobId !== jobId)
|
|
210
|
+
return null; const planUrl = comment.url ?? (comment.id > 0 ? `https://github.com/${this.repository}/pull/${this.prNumber}#issuecomment-${comment.id}` : undefined); return planUrl ? { ...plan, planUrl } : plan; }).filter((x) => x !== null).sort((a, b) => a.round - b.round);
|
|
185
211
|
job.tasks = comments.map(comment => {
|
|
186
212
|
const task = parseTask(comment.body);
|
|
187
213
|
if (!task)
|
|
@@ -285,6 +311,36 @@ export class GitHubStore {
|
|
|
285
311
|
for (const legacy of jobComments.filter(comment => parseJob(comment.body)?.id === job.id))
|
|
286
312
|
await this.deleteComment(legacy);
|
|
287
313
|
}
|
|
314
|
+
async appendPlan(plan) {
|
|
315
|
+
await this.load(plan.jobId);
|
|
316
|
+
const comments = (await this.list()).filter(comment => this.isTrusted(comment));
|
|
317
|
+
const matches = comments.filter(comment => { const existing = parsePlan(comment.body); return existing !== null && existing.jobId === plan.jobId && existing.id === plan.id; });
|
|
318
|
+
if (matches.length === 0) {
|
|
319
|
+
const body = renderPlanComment(plan);
|
|
320
|
+
const persisted = await this.comment(body);
|
|
321
|
+
this.rememberPlanWrite(plan, persisted, body);
|
|
322
|
+
return;
|
|
323
|
+
}
|
|
324
|
+
const existing = parsePlan(matches[0].body);
|
|
325
|
+
if (!existing || JSON.stringify({ ...existing, planUrl: undefined }) !== JSON.stringify({ ...plan, planUrl: undefined }))
|
|
326
|
+
throw new Error(`Plan ${plan.id} already exists with a different definition`);
|
|
327
|
+
for (const duplicate of matches.slice(1))
|
|
328
|
+
await this.deleteComment(duplicate);
|
|
329
|
+
}
|
|
330
|
+
async savePlan(plan) {
|
|
331
|
+
await this.load(plan.jobId);
|
|
332
|
+
const comments = (await this.list()).filter(comment => this.isTrusted(comment));
|
|
333
|
+
const existing = comments.filter(comment => { const markerPlan = parsePlan(comment.body); return markerPlan !== null && markerPlan.jobId === plan.jobId && markerPlan.id === plan.id; });
|
|
334
|
+
if (existing.length > 0) {
|
|
335
|
+
const body = renderPlanComment(plan);
|
|
336
|
+
const persisted = await this.updateComment(existing[0], body);
|
|
337
|
+
this.rememberPlanWrite(plan, persisted, body);
|
|
338
|
+
for (const duplicate of existing.slice(1))
|
|
339
|
+
await this.deleteComment(duplicate);
|
|
340
|
+
}
|
|
341
|
+
else
|
|
342
|
+
await this.appendPlan(plan);
|
|
343
|
+
}
|
|
288
344
|
async appendTask(task) {
|
|
289
345
|
if (!task.jobId)
|
|
290
346
|
throw new Error('GitHub task requires jobId');
|
package/dist/store.js
CHANGED
|
@@ -1,7 +1,7 @@
|
|
|
1
1
|
import { spawn } from 'node:child_process';
|
|
2
2
|
import { createSign } from 'node:crypto';
|
|
3
3
|
import { AsyncLocalStorage } from 'node:async_hooks';
|
|
4
|
-
import { parseAgent, parseJob, parseTask, renderAgentComment, renderJobComment, renderTaskComment, AGENT_MARKER, JOB_MARKER, TASK_MARKER } from './markers.js';
|
|
4
|
+
import { parseAgent, parseJob, parsePlan, parseTask, renderAgentComment, renderJobComment, renderPlanComment, renderTaskComment, AGENT_MARKER, JOB_MARKER, PLAN_MARKER, TASK_MARKER } from './markers.js';
|
|
5
5
|
import { isTaskForJob, sameTaskDefinition } from './store-task-definition.js';
|
|
6
6
|
import { parsePackagePublicationMarker } from './github-webhook.js';
|
|
7
7
|
function publicationFromComments(rows, trustedAuthors) {
|
|
@@ -44,6 +44,8 @@ export async function loadManagedGitHubPullRequestSnapshotGraphql(client, reposi
|
|
|
44
44
|
const commentJobs = trusted.map(item => parseJob(String(item.body ?? ''))).filter((item) => item !== null);
|
|
45
45
|
const jobs = bodyJob ? [bodyJob] : commentJobs;
|
|
46
46
|
const publication = publicationFromComments(commentRows, trustedAuthors);
|
|
47
|
+
const plans = trusted.map(row => { const plan = parsePlan(String(row.body ?? '')); if (!plan)
|
|
48
|
+
return null; const planUrl = typeof row.url === 'string' ? row.url : Number.isInteger(row.databaseId) ? `https://github.com/${repository}/pull/${prNumber}#issuecomment-${row.databaseId}` : undefined; return planUrl ? { ...plan, planUrl } : plan; }).filter((item) => item !== null);
|
|
47
49
|
const tasks = trusted.map(row => {
|
|
48
50
|
const task = parseTask(String(row.body ?? ''));
|
|
49
51
|
if (!task)
|
|
@@ -54,6 +56,7 @@ export async function loadManagedGitHubPullRequestSnapshotGraphql(client, reposi
|
|
|
54
56
|
const state = String(pr.state ?? '').toUpperCase();
|
|
55
57
|
const githubState = pr.merged === true ? 'MERGED' : state === 'CLOSED' ? 'CLOSED' : 'OPEN';
|
|
56
58
|
const overviews = jobs.map(job => {
|
|
59
|
+
job.plans = plans.filter(plan => plan.jobId === job.id).sort((a, b) => a.round - b.round);
|
|
57
60
|
job.tasks = tasks.filter(task => task.jobId === job.id || (!task.jobId && jobs.length === 1));
|
|
58
61
|
if (!job.description && typeof pr.body === 'string' && pr.body)
|
|
59
62
|
job.description = pr.body;
|
|
@@ -84,13 +87,22 @@ export class InMemoryStore {
|
|
|
84
87
|
constructor(job) {
|
|
85
88
|
if (job) {
|
|
86
89
|
this.values.set(`${JOB_MARKER}:${job.id}`, renderJobComment(job));
|
|
90
|
+
for (const plan of job.plans ?? [])
|
|
91
|
+
this.values.set(`${PLAN_MARKER}:${plan.jobId}:${plan.id}`, renderPlanComment(plan));
|
|
87
92
|
for (const task of job.tasks)
|
|
88
93
|
this.values.set(`${TASK_MARKER}:${task.jobId ?? ''}:${task.id}`, renderTaskComment(task));
|
|
89
94
|
}
|
|
90
95
|
}
|
|
91
96
|
async load(jobId) { const job = await this.findJob(jobId); if (!job)
|
|
92
|
-
throw new Error(`Job ${jobId} not found`); job.tasks = this.tasksForJob(jobId); return job; }
|
|
97
|
+
throw new Error(`Job ${jobId} not found`); job.plans = this.plansForJob(jobId); job.tasks = this.tasksForJob(jobId); return job; }
|
|
93
98
|
async saveJob(job) { this.values.set(`${JOB_MARKER}:${job.id}`, renderJobComment(job)); }
|
|
99
|
+
async appendPlan(plan) { const key = `${PLAN_MARKER}:${plan.jobId}:${plan.id}`; const existing = this.values.get(key); if (existing) {
|
|
100
|
+
const current = parsePlan(existing);
|
|
101
|
+
if (current && JSON.stringify(current) === JSON.stringify(plan))
|
|
102
|
+
return;
|
|
103
|
+
throw new Error(`Plan ${plan.id} already exists with a different definition`);
|
|
104
|
+
} this.values.set(key, renderPlanComment(plan)); }
|
|
105
|
+
async savePlan(plan) { this.values.set(`${PLAN_MARKER}:${plan.jobId}:${plan.id}`, renderPlanComment(plan)); }
|
|
94
106
|
async appendTask(task) {
|
|
95
107
|
const key = `${TASK_MARKER}:${task.jobId ?? ''}:${task.id}`;
|
|
96
108
|
const existing = this.values.get(key);
|
|
@@ -110,6 +122,7 @@ export class InMemoryStore {
|
|
|
110
122
|
async saveAgent(agent) { this.values.set(`${AGENT_MARKER}:${agent.id}`, renderAgentComment(agent)); }
|
|
111
123
|
async comments() { return [...this.values.values()].map((body, i) => ({ id: i + 1, body, updatedAt: new Date().toISOString(), authorLogin: 'local' })); }
|
|
112
124
|
findJob(jobId) { return [...this.values.values()].map(parseJob).find((x) => x !== null && x.id === jobId) ?? null; }
|
|
125
|
+
plansForJob(jobId) { return [...this.values.values()].map(parsePlan).filter((x) => x !== null && x.jobId === jobId).sort((a, b) => a.round - b.round); }
|
|
113
126
|
tasksForJob(jobId) {
|
|
114
127
|
const jobs = [...this.values.values()].map(parseJob).filter((x) => x !== null && typeof x.id === 'string' && x.id.length > 0);
|
|
115
128
|
return [...this.values.values()].map(parseTask).filter((x) => x !== null).filter(task => isTaskForJob(task, jobId, jobs.length === 1));
|
package/dist/unblock.js
CHANGED
|
@@ -1,9 +1,9 @@
|
|
|
1
1
|
import { extendRetryBudget, transitionTaskEvent } from './scheduler.js';
|
|
2
|
-
export function isEffectivelyBlocked(task) { return task.kind !== 'planner' && task.state === 'BLOCKED'
|
|
2
|
+
export function isEffectivelyBlocked(task) { return task.kind !== 'planner' && task.state === 'BLOCKED'; }
|
|
3
3
|
function baselineFor(task) {
|
|
4
4
|
return task.blockedBaseline ?? { blockedAt: task.updatedAt, reason: task.error ?? undefined };
|
|
5
5
|
}
|
|
6
|
-
function
|
|
6
|
+
function unblockFacts(job, tasks, currentHeadSha, reason) {
|
|
7
7
|
const facts = [`unblock: ${reason}`];
|
|
8
8
|
const baselines = tasks.map(task => baselineFor(task));
|
|
9
9
|
const heads = [...new Set(baselines.map(item => item.headSha).filter((item) => Boolean(item)))];
|
|
@@ -11,7 +11,7 @@ function cycleFacts(job, tasks, currentHeadSha, reason) {
|
|
|
11
11
|
facts.push(`PR head advanced from ${heads.join(', ')} to ${currentHeadSha}`);
|
|
12
12
|
const times = baselines.map(item => Date.parse(item.blockedAt)).filter(Number.isFinite).sort((a, b) => a - b);
|
|
13
13
|
const earliest = times[0];
|
|
14
|
-
if (earliest !== undefined)
|
|
14
|
+
if (earliest !== undefined)
|
|
15
15
|
for (const task of job.tasks) {
|
|
16
16
|
if (tasks.some(item => item.id === task.id))
|
|
17
17
|
continue;
|
|
@@ -21,9 +21,28 @@ function cycleFacts(job, tasks, currentHeadSha, reason) {
|
|
|
21
21
|
const detail = task.result?.summary ?? task.error ?? task.state;
|
|
22
22
|
facts.push(`task ${task.id} -> ${task.state}: ${detail}`);
|
|
23
23
|
}
|
|
24
|
-
}
|
|
25
24
|
return [...new Set(facts)];
|
|
26
25
|
}
|
|
26
|
+
export function unconsumedUnblockTrigger(job) {
|
|
27
|
+
const consumed = new Set((job.plans ?? []).flatMap(plan => plan.trigger.source === 'unblock' ? plan.trigger.taskIds ?? [] : []));
|
|
28
|
+
const tasks = job.tasks.filter(task => task.state === 'UNBLOCKED' && task.unblock && !consumed.has(task.id));
|
|
29
|
+
if (tasks.length === 0) {
|
|
30
|
+
const legacy = job.changeCycle;
|
|
31
|
+
if (!legacy || legacy.consumedByPlannerTaskId)
|
|
32
|
+
return undefined;
|
|
33
|
+
return { source: 'unblock', taskIds: legacy.taskIds, reason: legacy.reason, facts: legacy.facts, blockedHeadShas: legacy.baselineHeadShas, currentHeadSha: legacy.currentHeadSha, unblockSource: legacy.source };
|
|
34
|
+
}
|
|
35
|
+
const records = tasks.map(task => task.unblock);
|
|
36
|
+
return {
|
|
37
|
+
source: 'unblock',
|
|
38
|
+
taskIds: tasks.map(task => task.id),
|
|
39
|
+
reason: [...new Set(records.map(item => item.reason))].join('; '),
|
|
40
|
+
facts: [...new Set(records.flatMap(item => item.facts ?? [`unblock: ${item.reason}`]))],
|
|
41
|
+
blockedHeadShas: [...new Set(records.map(item => item.baseline.headSha).filter((item) => Boolean(item)))],
|
|
42
|
+
currentHeadSha: records.map(item => item.currentHeadSha).find((item) => Boolean(item)),
|
|
43
|
+
unblockSource: records[0].source,
|
|
44
|
+
};
|
|
45
|
+
}
|
|
27
46
|
export async function unblockAutonomousTasks(options) {
|
|
28
47
|
const { store, job, trigger } = options;
|
|
29
48
|
const tasks = options.tasks.filter(isEffectivelyBlocked);
|
|
@@ -31,44 +50,43 @@ export async function unblockAutonomousTasks(options) {
|
|
|
31
50
|
throw new Error('No effective BLOCKED work task is available to unblock');
|
|
32
51
|
const now = options.now ?? new Date().toISOString();
|
|
33
52
|
const currentHeadSha = await store.headSha?.() ?? job.headSha ?? null;
|
|
53
|
+
const facts = unblockFacts(job, tasks, currentHeadSha, trigger.reason);
|
|
34
54
|
for (const task of tasks) {
|
|
35
55
|
const baseline = baselineFor(task);
|
|
36
|
-
const record = { at: now, source: trigger.source, reason: trigger.reason, baseline, currentHeadSha: currentHeadSha ?? undefined, blockerJobId: trigger.blockerJobId, resolution: trigger.resolution };
|
|
37
56
|
task.blockedBaseline = baseline;
|
|
38
|
-
task.unblock =
|
|
57
|
+
task.unblock = { at: now, source: trigger.source, reason: trigger.reason, baseline, currentHeadSha: currentHeadSha ?? undefined, facts, blockerJobId: trigger.blockerJobId, resolution: trigger.resolution };
|
|
58
|
+
await transitionTaskEvent(task, 'task.unblocked');
|
|
39
59
|
task.updatedAt = now;
|
|
40
60
|
delete task.blockedBy;
|
|
41
61
|
await store.saveTask(task);
|
|
42
62
|
}
|
|
43
|
-
const
|
|
44
|
-
|
|
45
|
-
|
|
46
|
-
|
|
47
|
-
|
|
48
|
-
return cycle;
|
|
63
|
+
const planTrigger = unconsumedUnblockTrigger(job) ?? {
|
|
64
|
+
source: 'unblock', taskIds: tasks.map(task => task.id), reason: trigger.reason,
|
|
65
|
+
facts, currentHeadSha: currentHeadSha ?? undefined, unblockSource: trigger.source,
|
|
66
|
+
};
|
|
67
|
+
return planTrigger;
|
|
49
68
|
}
|
|
50
69
|
export async function unblockTask(options) {
|
|
51
70
|
if (!isEffectivelyBlocked(options.task))
|
|
52
71
|
throw new Error(`Task ${options.task.id} is not an effective BLOCKED work task`);
|
|
53
|
-
if (options.job.executionMode === 'autonomous')
|
|
54
|
-
|
|
55
|
-
|
|
56
|
-
|
|
72
|
+
if (options.job.executionMode === 'autonomous')
|
|
73
|
+
return { replanning: true, planTrigger: await unblockAutonomousTasks({ store: options.store, job: options.job, tasks: [options.task], trigger: options.trigger, now: options.now }) };
|
|
74
|
+
const now = options.now ?? new Date().toISOString();
|
|
75
|
+
const currentHeadSha = await options.store.headSha?.() ?? options.job.headSha ?? null;
|
|
76
|
+
const baseline = baselineFor(options.task);
|
|
77
|
+
options.task.blockedBaseline = baseline;
|
|
78
|
+
const facts = unblockFacts(options.job, [options.task], currentHeadSha, options.trigger.reason);
|
|
79
|
+
options.task.unblock = { at: now, source: options.trigger.source, reason: options.trigger.reason, baseline, currentHeadSha: currentHeadSha ?? undefined, facts, blockerJobId: options.trigger.blockerJobId, resolution: options.trigger.resolution };
|
|
80
|
+
await transitionTaskEvent(options.task, 'task.unblocked');
|
|
81
|
+
await options.store.saveTask(options.task);
|
|
57
82
|
options.task.maxAttempts = extendRetryBudget(options.task);
|
|
58
83
|
await transitionTaskEvent(options.task, 'retry');
|
|
59
84
|
options.task.error = null;
|
|
60
85
|
options.task.leaseOwner = null;
|
|
61
86
|
options.task.leaseExpiresAt = null;
|
|
62
87
|
options.task.executionId = null;
|
|
63
|
-
options.task.updatedAt =
|
|
88
|
+
options.task.updatedAt = now;
|
|
64
89
|
delete options.task.blockedBy;
|
|
65
90
|
await options.store.saveTask(options.task);
|
|
66
91
|
return { replanning: false };
|
|
67
92
|
}
|
|
68
|
-
export function consumeChangeCycle(job, plannerTaskId, now = new Date().toISOString()) {
|
|
69
|
-
if (!job.changeCycle || job.changeCycle.consumedByPlannerTaskId)
|
|
70
|
-
return false;
|
|
71
|
-
job.changeCycle.consumedByPlannerTaskId = plannerTaskId;
|
|
72
|
-
job.updatedAt = now;
|
|
73
|
-
return true;
|
|
74
|
-
}
|
package/package.json
CHANGED
|
@@ -6,7 +6,7 @@ Before producing tasks, read and follow `../contracts/task-job-contract.md`.
|
|
|
6
6
|
|
|
7
7
|
You are a pure planning agent. Relay invokes you with the durable Job, existing Tasks, previous planner rounds, and resolved project context. You return one structured planning result. You do not call the Agents Relay CLI, persist state, create Jobs, or mutate Tasks yourself.
|
|
8
8
|
|
|
9
|
-
Relay persists each
|
|
9
|
+
Relay persists each planning round as a first-class Plan PR comment/marker before generated Task comments. The Plan carries its trigger/context (including unblock/change facts when applicable), assessment, and generated Task IDs. Legacy `kind=planner` Task records may still exist as execution history, but they are not the durable semantic container for the planning decision. Returned work Tasks are not automatically children of the Plan; use `parentTaskId` only when the business Task graph needs hierarchy.
|
|
10
10
|
|
|
11
11
|
## Planning contract
|
|
12
12
|
|
|
@@ -124,8 +124,12 @@ This does not weaken immutable review semantics: final review and specialist rev
|
|
|
124
124
|
|
|
125
125
|
`dependencies` describe normal DAG ordering. Dependency completion does not create an unblock event or a planning change cycle; Relay simply releases dependent work when its prerequisites succeed. `BLOCKED` is different: it records that execution reached a runtime/external condition that invalidates normal progress.
|
|
126
126
|
|
|
127
|
-
A BLOCKED Task cannot be resumed through retry, queue, dashboard state editing, PR push, webhook, ordinary task update, or troubleshooter inference. Except for blocker-Job resolution, the authoritative entry points are `task unblock --reason ...` and `job unblock --reason ...`.
|
|
127
|
+
A BLOCKED Task cannot be resumed through retry, queue, dashboard state editing, PR push, webhook, ordinary task update, or troubleshooter inference. Except for blocker-Job resolution, the authoritative entry points are `task unblock --reason ...` and `job unblock --reason ...`. Authoritative unblock records the blocked baseline and current evidence, then transitions `BLOCKED -> UNBLOCKED` through the durable `task.unblocked` lifecycle event. `BLOCKED` therefore always means the blocker is still effective; `UNBLOCKED` means the old blocked attempt is historical and its blocker was explicitly cleared.
|
|
128
128
|
|
|
129
|
-
|
|
129
|
+
For autonomous Jobs, an UNBLOCKED Task is never silently retried. Its unblock metadata becomes the trigger/change context for the next Planner round, which decides whether to replace, split, wait, or otherwise re-plan the objective. For fixed Jobs, authoritative unblock records the UNBLOCKED transition first and may then requeue the same Task according to fixed-job retry semantics.
|
|
130
130
|
|
|
131
|
-
|
|
131
|
+
Each Planner round is also a first-class durable Plan entity stored in its own `agents-relay:plan:v1` PR comment. Relay creates the Plan comment before the internal planner execution record and before any generated Task comments, then updates that same Plan comment with the final assessment and generated Task IDs. Unblock/change facts belong to the Plan trigger/context, not to new Job-level Change Cycle state. Historical `kind=planner` Task records and legacy Job `changeCycle` fields remain readable for compatibility only.
|
|
132
|
+
|
|
133
|
+
A blocker Job owns reverse `unblocks` references. When it resolves, Relay checks the dependent Job. If that autonomous Job has an effective BLOCKED work Task, resolution uses the same authoritative `BLOCKED -> UNBLOCKED -> Planner` path. If it does not have BLOCKED work (for example the registered Task is DRAFT or FAILED), existing release/retry behavior remains unchanged.
|
|
134
|
+
|
|
135
|
+
Automatic Planner creation treats only a Task whose lifecycle state is exactly `BLOCKED` as a block gate. `UNBLOCKED` and FAILED history do not themselves gate a new Planner round; active DRAFT/QUEUED/RUNNING work still prevents a competing planning round.
|
|
@@ -32,9 +32,11 @@ stateDiagram-v2
|
|
|
32
32
|
RUNNING --> FAILED: watchdog.failed
|
|
33
33
|
RUNNING --> CANCELLED: task.cancelled
|
|
34
34
|
|
|
35
|
-
BLOCKED -->
|
|
35
|
+
BLOCKED --> UNBLOCKED: task.unblocked
|
|
36
36
|
BLOCKED --> CANCELLED: cancel
|
|
37
37
|
|
|
38
|
+
UNBLOCKED --> QUEUED: retry / mutation.clearError,mutation.clearFailureReason
|
|
39
|
+
|
|
38
40
|
FAILED --> QUEUED: retry / mutation.clearError,mutation.clearFailureReason
|
|
39
41
|
FAILED --> RUNNING: task.started [recoverableExecution] / mutation.clearError,mutation.clearFailureReason
|
|
40
42
|
FAILED --> SUCCEEDED: task.completed [recoverableExecution] / mutation.clearError,mutation.clearFailureReason
|