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.
Files changed (65) hide show
  1. package/README.md +4 -2
  2. package/dist/adapters.js +26 -2
  3. package/dist/blockers.js +60 -65
  4. package/dist/cli-support.js +33 -1
  5. package/dist/cli.js +72 -86
  6. package/dist/dashboard-actions.js +13 -35
  7. package/dist/dashboard-html.js +1 -1
  8. package/dist/dashboard.js +39 -0
  9. package/dist/execution-recovery.js +37 -0
  10. package/dist/graph-runtime.js +1 -0
  11. package/dist/job-actions.js +53 -0
  12. package/dist/manual-plan.js +15 -17
  13. package/dist/planner-runtime.js +135 -0
  14. package/dist/planner.js +76 -60
  15. package/dist/pool.js +54 -10
  16. package/dist/projected-store.js +314 -0
  17. package/dist/reconciler-review.js +185 -110
  18. package/dist/reconciler-runtime.js +67 -7
  19. package/dist/reconciler.js +134 -282
  20. package/dist/relay-store.js +380 -0
  21. package/dist/relayd.js +122 -29
  22. package/dist/runtime-status.js +2 -1
  23. package/dist/scheduled-jobs.js +40 -31
  24. package/dist/scheduler.js +19 -18
  25. package/dist/store.js +1 -1
  26. package/dist/task-actions.js +139 -0
  27. package/dist/troubleshooter.js +14 -8
  28. package/dist/types.js +3 -3
  29. package/dist/unblock.js +31 -21
  30. package/dist/workflow/authoritative-unblock-v2.js +20 -0
  31. package/dist/workflow/blocker-resolution-v2.js +33 -0
  32. package/dist/workflow/contracts.js +21 -0
  33. package/dist/workflow/definitions-v2.js +75 -0
  34. package/dist/workflow/dispatcher.js +56 -0
  35. package/dist/workflow/entity-creation.js +32 -0
  36. package/dist/workflow/graph-v2.js +391 -0
  37. package/dist/workflow/job-reconcile-v2.js +19 -0
  38. package/dist/workflow/lifecycle.js +82 -4
  39. package/dist/workflow/planner-orchestration-v2.js +58 -0
  40. package/dist/workflow/planner-trigger-v2.js +24 -0
  41. package/dist/workflow/review-v2.js +42 -0
  42. package/dist/workflow/runtime.js +15 -1
  43. package/dist/workflow/schedule-cycle-v2.js +25 -0
  44. package/dist/workflow/scheduled-run-v2.js +30 -0
  45. package/dist/workflow/task-action-policy.js +19 -0
  46. package/dist/workflow/task-reconcile.js +6 -6
  47. package/dist/workflow/task-related-action-v2.js +42 -0
  48. package/dist/workflow/troubleshooter-v2.js +29 -0
  49. package/dist/workspace.js +6 -1
  50. package/package.json +1 -1
  51. package/skills/agents-relay/agents/planner.agent.md +5 -5
  52. package/skills/agents-relay/contracts/task-job-contract.md +2 -51
  53. package/workflow/authoritative-unblock-v2.mmd +15 -0
  54. package/workflow/blocker-resolution-v2.mmd +73 -0
  55. package/workflow/job-lifecycle-v2.mmd +70 -0
  56. package/workflow/job-lifecycle.mmd +2 -2
  57. package/workflow/planner-orchestration-v2.mmd +65 -0
  58. package/workflow/planner-trigger-v2.mmd +32 -0
  59. package/workflow/review-lifecycle-v2.mmd +124 -0
  60. package/workflow/schedule-cycle-v2.mmd +19 -0
  61. package/workflow/scheduled-run-v2.mmd +55 -0
  62. package/workflow/task-lifecycle-v2.mmd +201 -0
  63. package/workflow/task-lifecycle.mmd +3 -0
  64. package/workflow/task-related-action-v2.mmd +28 -0
  65. package/workflow/troubleshooter-v2.mmd +43 -0
@@ -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
+ }
@@ -1,37 +1,35 @@
1
- import { createPlannerRoundTask, pendingPlanForPlannerTask, recoverPendingPlannerTask } from './planner.js';
2
- import { transitionJobEvent } from './scheduler.js';
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.tasks.find(task => task.kind === 'planner' && ['DRAFT', 'QUEUED', 'RUNNING'].includes(task.state));
8
- if (active)
9
- throw new Error(`Planning round ${active.id} is already ${active.state}`);
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));
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 transitionJobEvent(job, 'activate');
15
+ await applyJobActivateAction(job);
18
16
  await store.saveJob(job);
19
17
  job = await store.load(jobId);
20
18
  }
21
- const durableRound = job.tasks.find(task => task.id === round.id);
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.tasks.find(task => task.id === round.id);
27
- while (completedRound && ['QUEUED', 'RUNNING'].includes(completedRound.state)) {
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.tasks.find(task => task.id === round.id);
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.state !== 'SUCCEEDED')
35
- throw new Error(completedRound.error ?? `Planning round ${round.id} ended ${completedRound.state}`);
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
- /** Business-task ownership only; planner rounds remain provenance records. */
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
- if (unblocked.length > 0) {
23
- const records = unblocked.map(task => task.unblock);
24
- const blockedHeadShas = [...new Set(records.map(record => record.baseline.headSha).filter((value) => Boolean(value)))];
25
- return {
26
- source: 'unblock', taskIds: unblocked.map(task => task.id),
27
- reason: [...new Set(records.map(record => record.reason))].join('; '),
28
- blockedHeadShas,
29
- currentHeadSha: records.map(record => record.currentHeadSha).find((value) => Boolean(value)),
30
- unblockSource: records[0].source,
31
- facts: [...new Set(records.flatMap(record => record.facts ?? [`unblock: ${record.reason}`]))],
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 (job.changeCycle && !job.changeCycle.consumedByPlannerTaskId)
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
- return (job.plans?.length ?? 0) === 0 && !job.tasks.some(task => task.kind === 'planner') ? { source: 'initial' } : { source: 'continuation' };
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
- export function createPlannerRoundTask(job, options = {}) {
49
- const planners = job.tasks.filter(task => task.kind === 'planner')
50
- .sort((a, b) => a.createdAt.localeCompare(b.createdAt) || a.id.localeCompare(b.id));
51
- const used = new Set(job.tasks.map(task => task.id));
52
- let round = Math.max(0, ...planners.map(task => plannerRoundNumber(task) ?? 0)) + 1;
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 previousPlanner = planners.at(-1);
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 context = {
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, kind: 'planner', storySize: 'M', agentName: 'planner',
70
- parentTaskId: null, dependencies: [], capabilities: ['planner'], adapter: 'orchestrator',
71
- input: plannerInput(context), continuation: null, continuationDeliveredAt: null, state: 'QUEUED', attempt: 0,
72
- maxAttempts: options.manual ? 1 : 3, leaseOwner: null, leaseExpiresAt: null, executionId: null, threadId: null,
73
- result: null, plannerResult: null, error: null, timeoutMs: options.timeoutMs ?? storySizeTimeoutMs('M'),
74
- createdAt: now, updatedAt: now, planningDecisionId: id, planningSource: 'model-planner', planningRound: round, previousPlannerTaskId: previousPlanner?.id ?? null,
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 pendingPlanForPlannerTask(job, task) {
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 recoverPendingPlannerTask(job, timeoutMs) {
87
- 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];
88
- if (!pending)
89
- return null;
90
- const task = createPlannerRoundTask(job, { guidance: pending.guidance, manual: pending.trigger.source === 'manual', timeoutMs, now: pending.createdAt });
91
- if (task.id !== pending.id || task.planningRound !== pending.round)
92
- throw new Error(`Pending Plan ${pending.id} cannot be recovered as planner round ${task.id}`);
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 => ({