agents-relay 1.0.177 → 2.0.0
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.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 +1 -1
- 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/dist/dashboard-html.js
CHANGED
|
@@ -395,7 +395,6 @@ function creditProvider(provider){const row=element('section','usage-provider');
|
|
|
395
395
|
function shortReset(reset){if(!Number.isFinite(reset))return '';const at=new Date(reset*1000);const minutes=Math.max(0,Math.ceil((at.getTime()-Date.now())/60000));const relative=minutes<60?minutes+'m':minutes<1440?Math.floor(minutes/60)+'h '+minutes%60+'m':Math.floor(minutes/1440)+'d '+Math.floor((minutes%1440)/60)+'h';return 'Reset '+relative;}
|
|
396
396
|
function githubBucket(label,bucket){const row=element('div','usage-row');const heading=element('div','usage-label');heading.append(element('span','',text(label)),element('span','',text(bucket.remaining+' / '+bucket.limit)));const percent=bucket.limit>0?Math.max(0,Math.min(100,100*bucket.remaining/bucket.limit)):0;const track=element('div','usage-track');track.setAttribute('role','progressbar');track.setAttribute('aria-label',label+' remaining');track.setAttribute('aria-valuemin','0');track.setAttribute('aria-valuemax','100');track.setAttribute('aria-valuenow',String(Math.round(percent)));const fill=element('div','usage-fill');fill.style.width=percent+'%';track.append(fill);row.append(heading,track);const reset=shortReset(bucket.reset);if(reset){const hint=element('div','subtle',text(reset));hint.title=new Date(bucket.reset*1000).toLocaleString();row.append(hint);}return row;}
|
|
397
397
|
function githubCounterLine(label,values){const entries=Object.entries(values||{}).sort((a,b)=>Number(b[1])-Number(a[1])).slice(0,8);return entries.length?element('div','subtle',text(label+' · '+entries.map(([key,count])=>key+' '+count).join(' · '))):null;}
|
|
398
|
-
function githubDiagnostics(status){const body=element('div','usage-details-body');if(status?.calls)body.append(element('div','subtle',text('Calls · GraphQL '+(status.calls.graphql||0)+' · REST '+(status.calls.rest||0)+' · quota '+(status.calls.rateLimit||0))));const operations=githubCounterLine('GraphQL operations',status?.graphql?.operations);if(operations)body.append(operations);const contexts=githubCounterLine('GraphQL contexts',status?.graphql?.contexts);if(contexts)body.append(contexts);if(status?.readPaths){body.append(element('div','subtle',text('Reads · scans '+(status.readPaths.workspaceScans||0)+' · PR '+(status.readPaths.prRefreshes||0)+' · coalesced '+(status.readPaths.prRefreshCoalesced||0))));const triggers=githubCounterLine('Refresh triggers',status.readPaths.prRefreshesByTrigger);if(triggers)body.append(triggers);const coalesced=githubCounterLine('Coalesced',status.readPaths.prRefreshCoalescedByTrigger);if(coalesced)body.append(coalesced);}if(!body.children.length)return null;const details=element('details','usage-details');details.append(element('summary','',text('Diagnostics')),body);return details;}
|
|
399
398
|
function githubObserved(label,observed){
|
|
400
399
|
if(!observed)return null;
|
|
401
400
|
const state=observed.throttled?'Throttled':observed.available===true?'Available':observed.available===false?'Unavailable':'Unknown';
|
|
@@ -409,6 +408,7 @@ function githubObserved(label,observed){
|
|
|
409
408
|
if(observed.reason)row.title=observed.reason;
|
|
410
409
|
return row;
|
|
411
410
|
}
|
|
411
|
+
function githubDiagnostics(status){const body=element('div','usage-details-body');if(status?.calls)body.append(element('div','subtle',text('Calls · GraphQL '+(status.calls.graphql||0)+' · REST '+(status.calls.rest||0)+' · quota '+(status.calls.rateLimit||0))));const operations=githubCounterLine('GraphQL operations',status?.graphql?.operations);if(operations)body.append(operations);const contexts=githubCounterLine('GraphQL contexts',status?.graphql?.contexts);if(contexts)body.append(contexts);if(status?.readPaths){body.append(element('div','subtle',text('Reads · scans '+(status.readPaths.workspaceScans||0)+' · PR '+(status.readPaths.prRefreshes||0)+' · coalesced '+(status.readPaths.prRefreshCoalesced||0))));const triggers=githubCounterLine('Refresh triggers',status.readPaths.prRefreshesByTrigger);if(triggers)body.append(triggers);const coalesced=githubCounterLine('Coalesced',status.readPaths.prRefreshCoalescedByTrigger);if(coalesced)body.append(coalesced);}if(!body.children.length)return null;const details=element('details','usage-details');details.append(element('summary','',text('Diagnostics')),body);return details;}
|
|
412
412
|
function githubUsageCard(snapshot,status){
|
|
413
413
|
const card=element('section','usage-provider');
|
|
414
414
|
card.append(element('h3','',text('GitHub API')));
|
package/dist/dashboard.js
CHANGED
|
@@ -243,6 +243,45 @@ export function serveDashboard(store, port = 8787, bus, refreshMs = 300000, jobI
|
|
|
243
243
|
}
|
|
244
244
|
return;
|
|
245
245
|
}
|
|
246
|
+
if (new URL(req.url ?? '/', 'http://127.0.0.1').pathname === '/api/task-command') {
|
|
247
|
+
if (req.method !== 'POST') {
|
|
248
|
+
res.writeHead(404);
|
|
249
|
+
res.end('Task command unavailable');
|
|
250
|
+
return;
|
|
251
|
+
}
|
|
252
|
+
try {
|
|
253
|
+
const chunks = [];
|
|
254
|
+
for await (const chunk of req)
|
|
255
|
+
chunks.push(Buffer.isBuffer(chunk) ? chunk : Buffer.from(chunk));
|
|
256
|
+
const body = JSON.parse(Buffer.concat(chunks).toString('utf8'));
|
|
257
|
+
const requestedJobId = typeof body.jobId === 'string' ? body.jobId : '';
|
|
258
|
+
const requestedTaskId = typeof body.taskId === 'string' ? body.taskId : '';
|
|
259
|
+
if (!/^[A-Za-z0-9_-]+$/.test(requestedJobId))
|
|
260
|
+
throw new Error('Invalid job id');
|
|
261
|
+
let result;
|
|
262
|
+
if (body.action === 'create' && body.task && resolvedActions?.taskCreate)
|
|
263
|
+
result = await resolvedActions.taskCreate(requestedJobId, body.task);
|
|
264
|
+
else if (body.action === 'retry' && /^[A-Za-z0-9_-]+$/.test(requestedTaskId) && resolvedActions?.taskRetry)
|
|
265
|
+
result = await resolvedActions.taskRetry(requestedJobId, requestedTaskId);
|
|
266
|
+
else if (body.action === 'cancel' && /^[A-Za-z0-9_-]+$/.test(requestedTaskId) && resolvedActions?.taskCancel)
|
|
267
|
+
result = await resolvedActions.taskCancel(requestedJobId, requestedTaskId);
|
|
268
|
+
else if (body.action === 'complete' && /^[A-Za-z0-9_-]+$/.test(requestedTaskId) && typeof body.summary === 'string' && body.summary.trim() && resolvedActions?.taskComplete)
|
|
269
|
+
result = await resolvedActions.taskComplete(requestedJobId, requestedTaskId, body.summary);
|
|
270
|
+
else
|
|
271
|
+
throw new Error('Task command unavailable');
|
|
272
|
+
if (!res.headersSent)
|
|
273
|
+
res.writeHead(200, { 'content-type': 'application/json' });
|
|
274
|
+
if (!res.writableEnded)
|
|
275
|
+
res.end(JSON.stringify(result));
|
|
276
|
+
}
|
|
277
|
+
catch (error) {
|
|
278
|
+
if (!res.headersSent)
|
|
279
|
+
res.writeHead(409);
|
|
280
|
+
if (!res.writableEnded)
|
|
281
|
+
res.end(error instanceof Error ? error.message : String(error));
|
|
282
|
+
}
|
|
283
|
+
return;
|
|
284
|
+
}
|
|
246
285
|
if (new URL(req.url ?? '/', 'http://127.0.0.1').pathname === '/api/task-action') {
|
|
247
286
|
const url = new URL(req.url ?? '/', 'http://127.0.0.1');
|
|
248
287
|
const requestedJobId = url.searchParams.get('id') ?? '';
|
|
@@ -0,0 +1,37 @@
|
|
|
1
|
+
import { transitionTaskEvent } from './scheduler.js';
|
|
2
|
+
async function currentGeneration(store, job, stale) {
|
|
3
|
+
const task = (await store.load(job.id)).tasks.find(item => item.id === stale.id);
|
|
4
|
+
if (!task)
|
|
5
|
+
return null;
|
|
6
|
+
const sameGeneration = task.executionId === stale.executionId && task.leaseOwner === stale.leaseOwner && task.leaseExpiresAt === stale.leaseExpiresAt;
|
|
7
|
+
return sameGeneration ? task : null;
|
|
8
|
+
}
|
|
9
|
+
async function recoverTask(task, event, reason, ports) {
|
|
10
|
+
ports.cancelGeneration(task);
|
|
11
|
+
ports.cancelPlanner(task.id);
|
|
12
|
+
const decision = await transitionTaskEvent(task, event, { retryAvailable: task.attempt < task.maxAttempts });
|
|
13
|
+
await ports.store.saveTask(task);
|
|
14
|
+
if (decision.target === 'FAILED')
|
|
15
|
+
await ports.emitFailed(task, reason);
|
|
16
|
+
}
|
|
17
|
+
export async function recoverExpiredExecutions(job, now, ports) {
|
|
18
|
+
const candidates = job.tasks.filter(item => item.kind !== 'planner' && (item.state === 'RUNNING' || (item.state === 'QUEUED' && item.executionId !== null)));
|
|
19
|
+
for (const stale of candidates) {
|
|
20
|
+
if (stale.leaseExpiresAt === null || new Date(stale.leaseExpiresAt).getTime() > now)
|
|
21
|
+
continue;
|
|
22
|
+
const task = await currentGeneration(ports.store, job, stale);
|
|
23
|
+
const recoverable = task && (task.state === 'RUNNING' || (task.state === 'QUEUED' && task.executionId !== null));
|
|
24
|
+
if (!recoverable || task.leaseExpiresAt === null || new Date(task.leaseExpiresAt).getTime() > now)
|
|
25
|
+
continue;
|
|
26
|
+
await recoverTask(task, 'lease.expired', 'lease_expired', ports);
|
|
27
|
+
}
|
|
28
|
+
}
|
|
29
|
+
export async function recoverStartupOrphans(job, ports) {
|
|
30
|
+
const candidates = job.tasks.filter(task => task.kind !== 'planner' && task.state === 'RUNNING' && task.executionId && task.leaseOwner && task.leaseOwner !== ports.owner && task.adapter !== 'chatgpt');
|
|
31
|
+
for (const stale of candidates) {
|
|
32
|
+
const task = await currentGeneration(ports.store, job, stale);
|
|
33
|
+
if (!task || task.state !== 'RUNNING' || task.adapter === 'chatgpt' || !task.executionId || !task.leaseOwner || task.leaseOwner === ports.owner)
|
|
34
|
+
continue;
|
|
35
|
+
await recoverTask(task, 'execution.orphaned', 'startup_orphaned', ports);
|
|
36
|
+
}
|
|
37
|
+
}
|
|
@@ -0,0 +1 @@
|
|
|
1
|
+
export * from './workflow/graph-v2.js';
|
|
@@ -0,0 +1,53 @@
|
|
|
1
|
+
import { transitionJobEvent } from './scheduler.js';
|
|
2
|
+
import { tryApplyTaskCancelAction } from './task-actions.js';
|
|
3
|
+
export const JOB_ACTIVATE_ACTION = 'job.activate';
|
|
4
|
+
export const JOB_CANCEL_ACTION = 'job.cancel';
|
|
5
|
+
async function transitionJobAction(job, event, action) {
|
|
6
|
+
try {
|
|
7
|
+
await transitionJobEvent(job, event);
|
|
8
|
+
}
|
|
9
|
+
catch (error) {
|
|
10
|
+
if (error instanceof Error && error.message.startsWith('No enabled workflow transition')) {
|
|
11
|
+
throw new Error(`Job ${job.id} is not ${action} (${job.state})`);
|
|
12
|
+
}
|
|
13
|
+
throw error;
|
|
14
|
+
}
|
|
15
|
+
}
|
|
16
|
+
export async function applyJobActivateAction(job) {
|
|
17
|
+
await transitionJobAction(job, 'activate', 'startable');
|
|
18
|
+
return job;
|
|
19
|
+
}
|
|
20
|
+
async function cancelActiveWork(store, job, reason) {
|
|
21
|
+
for (const plan of (job.plans ?? []).filter(item => item.status === 'PLANNING')) {
|
|
22
|
+
plan.status = 'FAILED';
|
|
23
|
+
plan.error = reason;
|
|
24
|
+
plan.assessment = `Planning failed: ${reason}`;
|
|
25
|
+
plan.leaseOwner = null;
|
|
26
|
+
plan.leaseExpiresAt = null;
|
|
27
|
+
plan.executionId = null;
|
|
28
|
+
plan.updatedAt = new Date().toISOString();
|
|
29
|
+
await store.savePlan(plan);
|
|
30
|
+
}
|
|
31
|
+
for (const task of job.tasks) {
|
|
32
|
+
if (!tryApplyTaskCancelAction(task, reason))
|
|
33
|
+
continue;
|
|
34
|
+
await store.saveTask(task);
|
|
35
|
+
}
|
|
36
|
+
}
|
|
37
|
+
export async function applyJobCancelAction(store, job, reason = 'Job cancelled') {
|
|
38
|
+
const from = job.state;
|
|
39
|
+
try {
|
|
40
|
+
await transitionJobEvent(job, 'cancel', {}, async (actionId) => {
|
|
41
|
+
if (actionId !== 'effect.cancelActiveTasks')
|
|
42
|
+
throw new Error(`Unsupported job.cancel effect ${actionId}`);
|
|
43
|
+
await cancelActiveWork(store, job, reason);
|
|
44
|
+
}, async () => { await store.saveJob(job); });
|
|
45
|
+
}
|
|
46
|
+
catch (error) {
|
|
47
|
+
if (error instanceof Error && error.message.startsWith('No enabled workflow transition')) {
|
|
48
|
+
throw new Error(`Job ${job.id} is not cancellable (${from})`);
|
|
49
|
+
}
|
|
50
|
+
throw error;
|
|
51
|
+
}
|
|
52
|
+
return job;
|
|
53
|
+
}
|
package/dist/manual-plan.js
CHANGED
|
@@ -1,37 +1,35 @@
|
|
|
1
|
-
import {
|
|
2
|
-
import {
|
|
1
|
+
import { createPlanRound, pendingPlan } from './planner.js';
|
|
2
|
+
import { applyJobActivateAction } from './job-actions.js';
|
|
3
3
|
export async function executeManualPlan(store, jobId, guidance, timeoutMs, launchPlanner) {
|
|
4
4
|
let job = await store.load(jobId);
|
|
5
5
|
if (['COMPLETED', 'CANCELLED'].includes(job.state))
|
|
6
6
|
throw new Error(`Cannot plan terminal job ${job.id} (${job.state})`);
|
|
7
|
-
const active = job
|
|
8
|
-
if (active)
|
|
9
|
-
throw new Error(`Planning round ${active.id} is already
|
|
10
|
-
const
|
|
11
|
-
|
|
12
|
-
|
|
13
|
-
await store.appendPlan(pendingPlanForPlannerTask(job, round));
|
|
14
|
-
await store.appendTask(round);
|
|
7
|
+
const active = pendingPlan(job);
|
|
8
|
+
if (active && active.executionId)
|
|
9
|
+
throw new Error(`Planning round ${active.id} is already PLANNING`);
|
|
10
|
+
const round = active ?? createPlanRound(job, { guidance, manual: true, timeoutMs });
|
|
11
|
+
if (!active)
|
|
12
|
+
await store.appendPlan(round);
|
|
15
13
|
job = await store.load(jobId);
|
|
16
14
|
if (job.state === 'DRAFT') {
|
|
17
|
-
await
|
|
15
|
+
await applyJobActivateAction(job);
|
|
18
16
|
await store.saveJob(job);
|
|
19
17
|
job = await store.load(jobId);
|
|
20
18
|
}
|
|
21
|
-
const durableRound = job.
|
|
19
|
+
const durableRound = (job.plans ?? []).find(plan => plan.id === round.id);
|
|
22
20
|
if (!durableRound)
|
|
23
21
|
throw new Error(`Planning round ${round.id} was not persisted`);
|
|
24
22
|
await launchPlanner(job, durableRound);
|
|
25
23
|
let completed = await store.load(jobId);
|
|
26
|
-
let completedRound = completed.
|
|
27
|
-
while (completedRound
|
|
24
|
+
let completedRound = (completed.plans ?? []).find(plan => plan.id === round.id);
|
|
25
|
+
while (completedRound?.status === 'PLANNING') {
|
|
28
26
|
await new Promise(resolve => setTimeout(resolve, 25));
|
|
29
27
|
completed = await store.load(jobId);
|
|
30
|
-
completedRound = completed.
|
|
28
|
+
completedRound = (completed.plans ?? []).find(plan => plan.id === round.id);
|
|
31
29
|
}
|
|
32
30
|
if (!completedRound)
|
|
33
31
|
throw new Error(`Planning round ${round.id} disappeared`);
|
|
34
|
-
if (completedRound.
|
|
35
|
-
throw new Error(completedRound.error ?? `Planning round ${round.id} ended ${completedRound.
|
|
32
|
+
if (completedRound.status !== 'SUCCEEDED')
|
|
33
|
+
throw new Error(completedRound.error ?? `Planning round ${round.id} ended ${completedRound.status}`);
|
|
36
34
|
return { job: completed, round: completedRound };
|
|
37
35
|
}
|
|
@@ -0,0 +1,135 @@
|
|
|
1
|
+
import { randomUUID } from 'node:crypto';
|
|
2
|
+
import { eventFor } from './events.js';
|
|
3
|
+
import { materializePlannerChild, parsePlannerResult, validatePlannerHierarchy } from './planner.js';
|
|
4
|
+
import { samePlannerChildDefinition } from './planner-child.js';
|
|
5
|
+
import { withExecutionBoundary } from './reconciler-runtime.js';
|
|
6
|
+
import { storySizeTimeoutMs } from './types.js';
|
|
7
|
+
async function finishPlanner(ports, jobId, planId, executionId, error, raw) {
|
|
8
|
+
const live = ports.live.get(planId);
|
|
9
|
+
if (live?.executionId === executionId)
|
|
10
|
+
clearTimeout(live.timer);
|
|
11
|
+
const releaseLive = () => { if (ports.live.get(planId)?.executionId === executionId)
|
|
12
|
+
ports.live.delete(planId); };
|
|
13
|
+
const job = await ports.store.load(jobId);
|
|
14
|
+
const plan = (job.plans ?? []).find(item => item.id === planId);
|
|
15
|
+
if (!plan || (executionId !== null && plan.executionId !== executionId)) {
|
|
16
|
+
releaseLive();
|
|
17
|
+
return;
|
|
18
|
+
}
|
|
19
|
+
const failOrRetry = async (message) => {
|
|
20
|
+
const attempt = plan.attempt ?? 0;
|
|
21
|
+
const maxAttempts = plan.maxAttempts ?? (plan.manualPlanning ? 1 : 3);
|
|
22
|
+
const retry = attempt < maxAttempts;
|
|
23
|
+
plan.status = retry ? 'PLANNING' : 'FAILED';
|
|
24
|
+
plan.retryFeedback = message;
|
|
25
|
+
plan.error = message;
|
|
26
|
+
plan.assessment = retry ? `Planning retry: ${message}` : `Planning failed: ${message}`;
|
|
27
|
+
plan.leaseOwner = null;
|
|
28
|
+
plan.leaseExpiresAt = null;
|
|
29
|
+
plan.executionId = null;
|
|
30
|
+
plan.updatedAt = new Date().toISOString();
|
|
31
|
+
await ports.store.savePlan(plan);
|
|
32
|
+
await ports.emit(eventFor(job.id, plan.id, null, retry ? 'planner.retry' : 'planner.failed', retry ? 'queued' : 'failed', `Planner ${plan.id}: ${message}`, 'user'));
|
|
33
|
+
if (retry && !plan.manualPlanning)
|
|
34
|
+
ports.detach(ports.reconcile(jobId), jobId, plan.id, 'planner retry reconciliation');
|
|
35
|
+
};
|
|
36
|
+
if (error) {
|
|
37
|
+
await failOrRetry(error.message);
|
|
38
|
+
releaseLive();
|
|
39
|
+
return;
|
|
40
|
+
}
|
|
41
|
+
try {
|
|
42
|
+
const result = parsePlannerResult(raw);
|
|
43
|
+
const executionContext = await ports.plannerContext(job, plan);
|
|
44
|
+
validatePlannerHierarchy(job.tasks, result.next_tasks);
|
|
45
|
+
if (result.unblocks.length > 0 && !ports.store.registerBlockerDependent)
|
|
46
|
+
throw new Error('Durable store does not support Planner cross-Job unblock registration');
|
|
47
|
+
for (const unblock of result.unblocks)
|
|
48
|
+
await ports.store.registerBlockerDependent?.(job.id, unblock);
|
|
49
|
+
const children = result.next_tasks.map(spec => materializePlannerChild(job, plan, spec, executionContext));
|
|
50
|
+
for (const child of children) {
|
|
51
|
+
const existing = job.tasks.find(item => item.id === child.id);
|
|
52
|
+
if (existing && !samePlannerChildDefinition(existing, child))
|
|
53
|
+
throw new Error(`Planner child ${child.id} already exists with a different definition`);
|
|
54
|
+
}
|
|
55
|
+
const now = new Date().toISOString();
|
|
56
|
+
plan.status = 'SUCCEEDED';
|
|
57
|
+
plan.objectiveStatus = result.objective_status;
|
|
58
|
+
plan.plannerResult = result;
|
|
59
|
+
plan.assessment = result.assessment;
|
|
60
|
+
plan.generatedTaskIds = result.next_tasks.map(item => item.id);
|
|
61
|
+
plan.headSha = plan.trigger.currentHeadSha ?? await ports.store.headSha?.() ?? job.headSha;
|
|
62
|
+
plan.error = undefined;
|
|
63
|
+
plan.retryFeedback = undefined;
|
|
64
|
+
plan.leaseOwner = null;
|
|
65
|
+
plan.leaseExpiresAt = null;
|
|
66
|
+
plan.executionId = null;
|
|
67
|
+
plan.updatedAt = now;
|
|
68
|
+
await ports.store.savePlan(plan);
|
|
69
|
+
if (job.changeCycle && !job.changeCycle.consumedByPlannerTaskId && plan.trigger.source === 'unblock') {
|
|
70
|
+
job.changeCycle.consumedByPlannerTaskId = plan.id;
|
|
71
|
+
job.updatedAt = now;
|
|
72
|
+
await ports.store.saveJob(job);
|
|
73
|
+
}
|
|
74
|
+
for (const child of children)
|
|
75
|
+
if (!job.tasks.some(item => item.id === child.id))
|
|
76
|
+
await ports.store.appendTask(child);
|
|
77
|
+
await ports.emit(eventFor(job.id, plan.id, null, 'planner.completed', 'succeeded', `Plan ${plan.id} completed: ${result.objective_status}`, 'orchestrator', { objective_status: result.objective_status }));
|
|
78
|
+
}
|
|
79
|
+
catch (caught) {
|
|
80
|
+
await failOrRetry(caught instanceof Error ? caught.message : String(caught));
|
|
81
|
+
releaseLive();
|
|
82
|
+
return;
|
|
83
|
+
}
|
|
84
|
+
releaseLive();
|
|
85
|
+
if (!plan.manualPlanning)
|
|
86
|
+
ports.detach(ports.reconcile(jobId), jobId, plan.id, 'planner follow-up reconciliation');
|
|
87
|
+
}
|
|
88
|
+
export async function launchPlanExecution(ports, job, plan) {
|
|
89
|
+
const planner = ports.options.planner;
|
|
90
|
+
if (!planner) {
|
|
91
|
+
const failed = { ...plan, status: 'FAILED', error: 'Planner unavailable', assessment: 'Planning failed: Planner unavailable', leaseOwner: null, leaseExpiresAt: null, executionId: null, updatedAt: new Date().toISOString() };
|
|
92
|
+
await ports.store.savePlan(failed);
|
|
93
|
+
await ports.emit(eventFor(job.id, plan.id, null, 'planner.failed', 'failed', `Planner ${plan.id}: Planner unavailable`, 'user'));
|
|
94
|
+
return;
|
|
95
|
+
}
|
|
96
|
+
const claimed = await withExecutionBoundary(job.id, plan.id, async () => {
|
|
97
|
+
const freshJob = await ports.store.load(job.id);
|
|
98
|
+
const fresh = (freshJob.plans ?? []).find(item => item.id === plan.id);
|
|
99
|
+
if (!fresh || fresh.status !== 'PLANNING')
|
|
100
|
+
return null;
|
|
101
|
+
const live = ports.live.get(fresh.id);
|
|
102
|
+
if (fresh.executionId && fresh.leaseOwner === ports.options.owner && live?.executionId === fresh.executionId)
|
|
103
|
+
return null;
|
|
104
|
+
const startedAt = (ports.options.now ?? new Date()).getTime();
|
|
105
|
+
fresh.attempt = (fresh.attempt ?? 0) + 1;
|
|
106
|
+
fresh.maxAttempts ??= fresh.manualPlanning ? 1 : 3;
|
|
107
|
+
fresh.timeoutMs ??= ports.options.plannerTimeoutMs ?? storySizeTimeoutMs('M');
|
|
108
|
+
fresh.leaseOwner = ports.options.owner;
|
|
109
|
+
fresh.leaseExpiresAt = new Date(startedAt + Math.max(ports.options.leaseMs ?? 300000, fresh.timeoutMs)).toISOString();
|
|
110
|
+
fresh.executionId = randomUUID();
|
|
111
|
+
fresh.updatedAt = new Date(startedAt).toISOString();
|
|
112
|
+
await ports.store.savePlan(fresh);
|
|
113
|
+
return fresh;
|
|
114
|
+
});
|
|
115
|
+
if (!claimed)
|
|
116
|
+
return;
|
|
117
|
+
plan = claimed;
|
|
118
|
+
await ports.emit(eventFor(job.id, plan.id, null, 'planner.started', 'running', `Plan ${plan.id} started`, 'orchestrator'));
|
|
119
|
+
const controller = new AbortController();
|
|
120
|
+
const executionId = plan.executionId;
|
|
121
|
+
const timeoutMs = plan.timeoutMs ?? ports.options.plannerTimeoutMs ?? storySizeTimeoutMs('M');
|
|
122
|
+
const timer = setTimeout(() => controller.abort(), timeoutMs);
|
|
123
|
+
ports.live.set(plan.id, { executionId, controller, timer });
|
|
124
|
+
let promise;
|
|
125
|
+
try {
|
|
126
|
+
const executionContext = await ports.plannerContext(job, plan);
|
|
127
|
+
const context = { job, tasks: job.tasks, objective: job.objective ?? job.description ?? job.title, previousPlannerTaskId: plan.previousPlanId, trigger: plan.trigger, executionContext, retryFeedback: plan.retryFeedback, guidance: plan.guidance };
|
|
128
|
+
promise = planner.plan(context, controller.signal);
|
|
129
|
+
}
|
|
130
|
+
catch (error) {
|
|
131
|
+
await finishPlanner(ports, job.id, plan.id, executionId, error instanceof Error ? error : new Error(String(error)));
|
|
132
|
+
return;
|
|
133
|
+
}
|
|
134
|
+
ports.detach(promise.then(result => finishPlanner(ports, job.id, plan.id, executionId, null, result), error => finishPlanner(ports, job.id, plan.id, executionId, error instanceof Error ? error : new Error(String(error)))), job.id, plan.id, 'planner completion');
|
|
135
|
+
}
|
package/dist/planner.js
CHANGED
|
@@ -4,8 +4,9 @@ import { fileURLToPath } from 'node:url';
|
|
|
4
4
|
import { dirname, join } from 'node:path';
|
|
5
5
|
import { storySizeTimeoutMs, taskOverallTimeoutMs } from './types.js';
|
|
6
6
|
import { executionContextPrompt } from './execution-context.js';
|
|
7
|
-
import { parseAgentGraph } from './agent-graph.js';
|
|
8
|
-
|
|
7
|
+
import { parseAgentGraph, validateAgentSelection } from './agent-graph.js';
|
|
8
|
+
import { dispatchPlannerTrigger } from './workflow/planner-trigger-v2.js';
|
|
9
|
+
import { createTaskEntity } from './workflow/entity-creation.js';
|
|
9
10
|
export function semanticTaskTree(tasks) {
|
|
10
11
|
const work = tasks.filter(task => task.kind !== 'planner');
|
|
11
12
|
const ids = new Set(work.map(task => task.id));
|
|
@@ -15,27 +16,43 @@ export function semanticTaskTree(tasks) {
|
|
|
15
16
|
};
|
|
16
17
|
}
|
|
17
18
|
function plannerTrigger(job, manual = false) {
|
|
18
|
-
if (manual)
|
|
19
|
-
return { source: 'manual' };
|
|
20
19
|
const consumed = new Set((job.plans ?? []).flatMap(plan => plan.trigger.source === 'unblock' ? plan.trigger.taskIds ?? [] : []));
|
|
21
20
|
const unblocked = job.tasks.filter(task => task.state === 'UNBLOCKED' && task.unblock && !consumed.has(task.id));
|
|
22
|
-
|
|
23
|
-
|
|
24
|
-
|
|
25
|
-
|
|
26
|
-
|
|
27
|
-
|
|
28
|
-
|
|
29
|
-
|
|
30
|
-
|
|
31
|
-
|
|
32
|
-
|
|
21
|
+
const hasUnblock = unblocked.length > 0 || Boolean(job.changeCycle && !job.changeCycle.consumedByPlannerTaskId);
|
|
22
|
+
const decision = dispatchPlannerTrigger({
|
|
23
|
+
manualPlan: manual,
|
|
24
|
+
hasUnblock,
|
|
25
|
+
scheduledRun: Boolean(job.schedule?.currentRunSlot),
|
|
26
|
+
initialPlan: (job.plans?.length ?? 0) === 0 && !job.tasks.some(task => task.kind === 'planner'),
|
|
27
|
+
});
|
|
28
|
+
if (!decision.accepted)
|
|
29
|
+
throw new Error('Planner trigger graph rejected plan.requested');
|
|
30
|
+
const source = decision.target;
|
|
31
|
+
if (source === 'manual')
|
|
32
|
+
return { source: 'manual' };
|
|
33
|
+
if (source === 'unblock') {
|
|
34
|
+
if (unblocked.length > 0) {
|
|
35
|
+
const records = unblocked.map(task => task.unblock);
|
|
36
|
+
const blockedHeadShas = [...new Set(records.map(record => record.baseline.headSha).filter((value) => Boolean(value)))];
|
|
37
|
+
return {
|
|
38
|
+
source: 'unblock', taskIds: unblocked.map(task => task.id),
|
|
39
|
+
reason: [...new Set(records.map(record => record.reason))].join('; '),
|
|
40
|
+
blockedHeadShas,
|
|
41
|
+
currentHeadSha: records.map(record => record.currentHeadSha).find((value) => Boolean(value)),
|
|
42
|
+
unblockSource: records[0].source,
|
|
43
|
+
facts: [...new Set(records.flatMap(record => record.facts ?? ['unblock: ' + record.reason]))],
|
|
44
|
+
};
|
|
45
|
+
}
|
|
46
|
+
const cycle = job.changeCycle;
|
|
47
|
+
if (!cycle || cycle.consumedByPlannerTaskId)
|
|
48
|
+
throw new Error('Planner trigger graph selected unblock without unblock facts');
|
|
49
|
+
return { source: 'unblock', taskIds: cycle.taskIds, reason: cycle.reason, facts: cycle.facts, blockedHeadShas: cycle.baselineHeadShas, currentHeadSha: cycle.currentHeadSha, unblockSource: cycle.source };
|
|
33
50
|
}
|
|
34
|
-
if (
|
|
35
|
-
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 };
|
|
36
|
-
if (job.schedule?.currentRunSlot)
|
|
51
|
+
if (source === 'schedule')
|
|
37
52
|
return { source: 'schedule' };
|
|
38
|
-
|
|
53
|
+
if (source === 'initial')
|
|
54
|
+
return { source: 'initial' };
|
|
55
|
+
return { source: 'continuation' };
|
|
39
56
|
}
|
|
40
57
|
export function plannerRoundNumber(task) {
|
|
41
58
|
if (task.kind !== 'planner')
|
|
@@ -45,57 +62,44 @@ export function plannerRoundNumber(task) {
|
|
|
45
62
|
const match = /^(?:plan|planner)-(\d+)$/.exec(task.id);
|
|
46
63
|
return match ? Number(match[1]) : null;
|
|
47
64
|
}
|
|
48
|
-
|
|
49
|
-
|
|
50
|
-
.sort((a, b) => a.createdAt.localeCompare(b.createdAt) || a.id.localeCompare(b.id));
|
|
51
|
-
|
|
52
|
-
|
|
65
|
+
function latestLegacyPlanner(job) {
|
|
66
|
+
return job.tasks.filter(task => task.kind === 'planner')
|
|
67
|
+
.sort((a, b) => a.createdAt.localeCompare(b.createdAt) || a.id.localeCompare(b.id)).at(-1);
|
|
68
|
+
}
|
|
69
|
+
export function createPlanRound(job, options = {}) {
|
|
70
|
+
const plans = [...(job.plans ?? [])].sort((a, b) => a.round - b.round || a.createdAt.localeCompare(b.createdAt));
|
|
71
|
+
const legacy = job.tasks.filter(task => task.kind === 'planner');
|
|
72
|
+
const used = new Set([...job.tasks.map(task => task.id), ...plans.map(plan => plan.id)]);
|
|
73
|
+
let round = Math.max(0, ...plans.map(plan => plan.round), ...legacy.map(task => plannerRoundNumber(task) ?? 0)) + 1;
|
|
53
74
|
let id = `plan-${round}`;
|
|
54
75
|
while (used.has(id)) {
|
|
55
76
|
round += 1;
|
|
56
77
|
id = `plan-${round}`;
|
|
57
78
|
}
|
|
58
|
-
const
|
|
79
|
+
const previousPlan = plans.at(-1);
|
|
80
|
+
const previousLegacy = previousPlan ? undefined : latestLegacyPlanner(job);
|
|
81
|
+
const previousPlanId = previousPlan?.id ?? previousLegacy?.id ?? null;
|
|
59
82
|
const now = options.now ?? new Date().toISOString();
|
|
60
|
-
const
|
|
61
|
-
job,
|
|
62
|
-
tasks: job.tasks,
|
|
63
|
-
objective: job.objective ?? job.description ?? job.title,
|
|
64
|
-
previousPlannerTaskId: previousPlanner?.id ?? null,
|
|
65
|
-
trigger: plannerTrigger(job, options.manual === true),
|
|
66
|
-
guidance: options.guidance,
|
|
67
|
-
};
|
|
83
|
+
const trigger = plannerTrigger(job, options.manual === true);
|
|
68
84
|
return {
|
|
69
|
-
jobId: job.id, id,
|
|
70
|
-
|
|
71
|
-
|
|
72
|
-
maxAttempts: options.manual ? 1 : 3, leaseOwner: null, leaseExpiresAt: null, executionId: null,
|
|
73
|
-
|
|
74
|
-
createdAt: now, updatedAt: now,
|
|
75
|
-
plannerGuidance: options.guidance, manualPlanning: options.manual === true, planTrigger: context.trigger,
|
|
85
|
+
jobId: job.id, id, round, source: 'model-planner', previousPlanId, trigger,
|
|
86
|
+
status: 'PLANNING', guidance: options.guidance, assessment: 'Planning in progress.', generatedTaskIds: [],
|
|
87
|
+
headSha: trigger.currentHeadSha ?? job.headSha,
|
|
88
|
+
attempt: 0, maxAttempts: options.manual ? 1 : 3, leaseOwner: null, leaseExpiresAt: null, executionId: null,
|
|
89
|
+
timeoutMs: options.timeoutMs ?? storySizeTimeoutMs('M'), manualPlanning: options.manual === true,
|
|
90
|
+
createdAt: now, updatedAt: now,
|
|
76
91
|
};
|
|
77
92
|
}
|
|
78
|
-
export function
|
|
79
|
-
return
|
|
80
|
-
jobId: job.id, id: task.id, round: task.planningRound ?? 1, source: task.planningSource ?? 'model-planner',
|
|
81
|
-
previousPlanId: task.previousPlannerTaskId ?? null, trigger: task.planTrigger ?? { source: task.manualPlanning ? 'manual' : 'continuation' },
|
|
82
|
-
status: 'PLANNING', guidance: task.plannerGuidance, assessment: 'Planning in progress.', generatedTaskIds: [],
|
|
83
|
-
headSha: task.planTrigger?.currentHeadSha ?? job.headSha, createdAt: task.createdAt, updatedAt: task.updatedAt,
|
|
84
|
-
};
|
|
93
|
+
export function pendingPlan(job) {
|
|
94
|
+
return [...(job.plans ?? [])].filter(plan => plan.status === 'PLANNING').sort((a, b) => a.round - b.round)[0] ?? null;
|
|
85
95
|
}
|
|
86
|
-
export function
|
|
87
|
-
|
|
88
|
-
|
|
89
|
-
|
|
90
|
-
const
|
|
91
|
-
|
|
92
|
-
|
|
93
|
-
task.previousPlannerTaskId = pending.previousPlanId;
|
|
94
|
-
task.planTrigger = pending.trigger;
|
|
95
|
-
task.planningSource = pending.source;
|
|
96
|
-
task.planningDecisionId = pending.id;
|
|
97
|
-
task.input = plannerInput({ job, tasks: job.tasks, objective: job.objective ?? job.description ?? job.title, previousPlannerTaskId: pending.previousPlanId, trigger: pending.trigger, guidance: pending.guidance });
|
|
98
|
-
return task;
|
|
96
|
+
export function materializePlannerChild(job, planner, spec, executionContext) {
|
|
97
|
+
validateAgentSelection(executionContext, spec.agentName, spec.agentGraph);
|
|
98
|
+
const now = new Date().toISOString();
|
|
99
|
+
const decisionId = 'round' in planner ? planner.id : planner.id;
|
|
100
|
+
const source = 'round' in planner ? planner.source : planner.planningSource ?? 'model-planner';
|
|
101
|
+
const round = 'round' in planner ? planner.round : planner.planningRound ?? 1;
|
|
102
|
+
return createTaskEntity(job, { jobId: job.id, id: spec.id, kind: 'work', priority: spec.priority ?? job.priority, storySize: spec.storySize ?? 'M', projectName: spec.projectName, agentName: spec.agentName, agentGraph: spec.agentGraph, review: spec.review, parentTaskId: spec.parentTaskId ?? null, dependencies: spec.dependencies ?? [], capabilities: spec.capabilities ?? [], workerRequirements: spec.workerRequirements, adapter: 'codex', input: spec.input, output: spec.output, continuation: spec.continuation ?? null, continuationDeliveredAt: null, attempt: 0, maxAttempts: spec.maxAttempts ?? 3, leaseOwner: null, leaseExpiresAt: null, executionId: null, threadId: null, result: null, plannerResult: null, error: null, timeoutMs: spec.timeoutMs ?? taskOverallTimeoutMs(spec.storySize ?? 'M'), createdAt: now, updatedAt: now, planningDecisionId: decisionId, planningSource: source, planningRound: round });
|
|
99
103
|
}
|
|
100
104
|
export function planningSpec(task) {
|
|
101
105
|
if (!task.output && task.adapter !== 'orchestrator')
|
|
@@ -339,6 +343,18 @@ export function plannerInput(context) {
|
|
|
339
343
|
plan_trigger: context.trigger,
|
|
340
344
|
legacy_change_cycle: context.job.changeCycle,
|
|
341
345
|
semantic_task_tree: semanticTaskTree(context.tasks),
|
|
346
|
+
plans: (context.job.plans ?? []).map(plan => ({
|
|
347
|
+
id: plan.id,
|
|
348
|
+
round: plan.round,
|
|
349
|
+
status: plan.status,
|
|
350
|
+
source: plan.source,
|
|
351
|
+
previous_plan_id: plan.previousPlanId,
|
|
352
|
+
trigger: plan.trigger,
|
|
353
|
+
objective_status: plan.objectiveStatus,
|
|
354
|
+
assessment: boundedText(plan.assessment),
|
|
355
|
+
generated_task_ids: plan.generatedTaskIds,
|
|
356
|
+
error: boundedText(plan.error, 2000),
|
|
357
|
+
})),
|
|
342
358
|
project: project ? { name: project.name, path: project.path } : undefined,
|
|
343
359
|
visible_agents: visibleAgents ?? [],
|
|
344
360
|
tasks: context.tasks.map(task => ({
|