agents-relay 2.0.68 → 2.0.70

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 (56) hide show
  1. package/README.md +32 -1
  2. package/dist/adapters.js +39 -3
  3. package/dist/blockers.js +17 -4
  4. package/dist/cli-blocker.js +12 -28
  5. package/dist/cli-help.js +11 -8
  6. package/dist/cli-job-create.js +19 -13
  7. package/dist/cli-local-merge-config.js +4 -2
  8. package/dist/cli-projection.js +23 -55
  9. package/dist/cli-review.js +7 -24
  10. package/dist/cli-support.js +59 -4
  11. package/dist/cli-worker-task.js +63 -0
  12. package/dist/cli.js +179 -129
  13. package/dist/dashboard-html.js +15 -12
  14. package/dist/dashboard.js +26 -1
  15. package/dist/e2e-injection.js +76 -0
  16. package/dist/github-binding-order.js +15 -0
  17. package/dist/github-client.js +7 -2
  18. package/dist/github-pr-archive.js +22 -0
  19. package/dist/github-webhook.js +31 -6
  20. package/dist/job-actions.js +29 -0
  21. package/dist/local-authority-store.js +10 -1
  22. package/dist/local-commit-events.js +2 -0
  23. package/dist/local-git-merge.js +17 -6
  24. package/dist/local-job-path.js +5 -0
  25. package/dist/local-job-registry.js +10 -4
  26. package/dist/local-managed-store.js +2 -4
  27. package/dist/local-merge-delivery.js +3 -2
  28. package/dist/local-merge-projection.js +60 -0
  29. package/dist/managed-worker-input.js +13 -6
  30. package/dist/manual-plan.js +29 -7
  31. package/dist/planner-runtime.js +182 -41
  32. package/dist/planner.js +35 -2
  33. package/dist/pool.js +22 -6
  34. package/dist/reconciler-cancellation.js +21 -0
  35. package/dist/reconciler-review.js +74 -18
  36. package/dist/reconciler-runtime.js +69 -34
  37. package/dist/reconciler.js +92 -26
  38. package/dist/relay-store.js +1 -1
  39. package/dist/relayd-control.js +184 -0
  40. package/dist/relayd-dashboard-actions.js +108 -0
  41. package/dist/relayd-utilities.js +107 -0
  42. package/dist/relayd.js +220 -210
  43. package/dist/review-generation-fence.js +12 -0
  44. package/dist/runtime-status-events.js +21 -0
  45. package/dist/runtime-status.js +13 -0
  46. package/dist/service-publication.js +2 -4
  47. package/dist/task-actions.js +59 -1
  48. package/dist/troubleshooter.js +21 -2
  49. package/dist/unblock.js +47 -15
  50. package/dist/worker-rejection.js +11 -0
  51. package/dist/workflow-effect-failures.js +30 -0
  52. package/dist/workspace.js +95 -6
  53. package/package.json +5 -4
  54. package/skills/agents-relay/agents/planner.agent.md +3 -0
  55. package/skills/agents-relay/contracts/orchestrator-workflows.md +4 -7
  56. package/workflow/review-lifecycle.mmd +3 -0
@@ -27,8 +27,9 @@ export async function reconcileLocalGitCompletion(store, finalState, headSha, no
27
27
  if (!projectPathForJob)
28
28
  throw new Error('Local Git merge requires an explicit project path');
29
29
  const repoPath = await projectPathForJob(finalState);
30
- const delivered = deliverLocalMerge(repoPath, { ...activeReceipt, sourceSha: activeReceipt.sourceSha ?? headSha ?? undefined });
31
- finalState.localMerge = { ...activeReceipt, ...delivered, status: 'succeeded',
30
+ const delivered = deliverLocalMerge(repoPath, { ...activeReceipt, sourceSha: activeReceipt.sourceSha ?? headSha ?? undefined,
31
+ correlationJobId: finalState.id, displayPrNumber: finalState.externalPrNumber ?? (finalState.prNumber > 0 ? finalState.prNumber : undefined) });
32
+ finalState.localMerge = { ...activeReceipt, ...delivered, status: 'succeeded', projectionStrategy: 'target_ref_push',
32
33
  error: undefined, nextAttemptAt: undefined, updatedAt: new Date().toISOString() };
33
34
  }
34
35
  catch (error) {
@@ -0,0 +1,60 @@
1
+ import { execFileSync } from 'node:child_process';
2
+ function git(cwd, ...args) {
3
+ return execFileSync('git', ['-C', cwd, ...args], {
4
+ encoding: 'utf8', timeout: 120000, maxBuffer: 1024 * 1024,
5
+ env: { ...process.env, GIT_TERMINAL_PROMPT: '0' },
6
+ }).trim();
7
+ }
8
+ function branchRef(name) {
9
+ if (!/^refs\/heads\/[A-Za-z0-9][A-Za-z0-9._/-]*$/.test(name) || name.includes('..') || name.endsWith('.lock'))
10
+ throw new Error('Local merge projection requires explicit refs/heads/ target');
11
+ return name;
12
+ }
13
+ function validSha(value, label) {
14
+ if (!value || !/^[0-9a-f]{40,64}$/.test(value))
15
+ throw new Error(`Local merge projection requires valid ${label} SHA`);
16
+ return value;
17
+ }
18
+ /**
19
+ * Publish SQLite-authoritative local main using a normal fast-forward push.
20
+ * GitHub never performs a second merge. Divergence is retried only after
21
+ * an operator or a separate Git synchronization workflow resolves it.
22
+ */
23
+ export function projectAuthoritativeLocalMerge(repoPath, job) {
24
+ const receipt = job.localMerge;
25
+ if (job.state !== 'COMPLETED' || receipt?.status !== 'succeeded')
26
+ throw new Error('Authoritative local merge projection requires COMPLETED Job with succeeded merge receipt');
27
+ const targetRef = branchRef(receipt.targetRef);
28
+ if (targetRef !== 'refs/heads/main')
29
+ throw new Error('Local Git target must always be refs/heads/main');
30
+ const sourceSha = validSha(receipt.sourceSha, 'source');
31
+ const mergedCommitSha = validSha(receipt.mergedCommitSha, 'merged commit');
32
+ const localTargetSha = git(repoPath, 'rev-parse', '--verify', targetRef);
33
+ if (localTargetSha !== mergedCommitSha) {
34
+ try {
35
+ git(repoPath, 'merge-base', '--is-ancestor', mergedCommitSha, localTargetSha);
36
+ }
37
+ catch {
38
+ throw new Error(`Local merge target ${targetRef} diverged after authoritative delivery for ${job.id}`);
39
+ }
40
+ }
41
+ const branch = targetRef.slice('refs/heads/'.length);
42
+ const remoteTracking = `refs/remotes/origin/${branch}`;
43
+ git(repoPath, 'fetch', '--no-tags', 'origin', `+${targetRef}:${remoteTracking}`);
44
+ const remoteTargetSha = git(repoPath, 'rev-parse', '--verify', remoteTracking);
45
+ try {
46
+ git(repoPath, 'merge-base', '--is-ancestor', localTargetSha, remoteTargetSha);
47
+ return { targetRef, localMergedCommitSha: mergedCommitSha, remoteTargetSha, projectedCommitSha: remoteTargetSha, status: 'already_projected' };
48
+ }
49
+ catch { /* remote projection required */ }
50
+ // GitHub mirrors the SQLite-authoritative local main; it must never synthesize
51
+ // a second merge or rewrite the tree when the remote has diverged.
52
+ try {
53
+ git(repoPath, 'merge-base', '--is-ancestor', remoteTargetSha, localTargetSha);
54
+ }
55
+ catch {
56
+ throw new Error(`GitHub sync deferred: origin/${branch} diverged from authoritative local main; resolve the remote conflict without force push`);
57
+ }
58
+ git(repoPath, 'push', 'origin', `${targetRef}:${targetRef}`);
59
+ return { targetRef, localMergedCommitSha: mergedCommitSha, remoteTargetSha, projectedCommitSha: localTargetSha, status: 'projected' };
60
+ }
@@ -4,7 +4,10 @@ import { createReviewChairExecutionPlan } from './review-chair.js';
4
4
  import { taskTagWorkerPrompt } from './task-tag.js';
5
5
  import { executionEventSource } from './reconciler-runtime.js';
6
6
  export async function managedWorkerInput(job, task, context) {
7
- if (!['codex', 'chatgpt'].includes(task.adapter) || !job.repository || job.prNumber <= 0)
7
+ const isLocalJob = job.prNumber === 0;
8
+ const externalPrNumber = job.externalPrNumber && job.externalPrNumber > 0 ? job.externalPrNumber : (job.prNumber > 0 ? job.prNumber : 0);
9
+ const projectedReview = isLocalJob && task.agentName === 'reviewer' && externalPrNumber > 0;
10
+ if (!['codex', 'chatgpt'].includes(task.adapter) || !job.repository)
8
11
  return task.input;
9
12
  const selectedAgent = task.agentName && context ? await agentContractPrompt(context, task.agentName) : '';
10
13
  const selectedGraph = task.agentGraph && context ? await agentGraphPrompt(context, task.agentGraph) : '';
@@ -16,19 +19,23 @@ export async function managedWorkerInput(job, task, context) {
16
19
  if (!task.executionId)
17
20
  throw new Error(`Task ${task.id} requires an executionId before worker input is built`);
18
21
  const sourceId = executionEventSource(job.id, task.id, task.executionId);
19
- const prUrl = `https://github.com/${job.repository}/pull/${job.prNumber}`;
20
- const taskUrl = task.taskUrl ?? `${prUrl}#issuecomment-${task.id}`;
22
+ const prUrl = externalPrNumber > 0 ? `https://github.com/${job.repository}/pull/${externalPrNumber}` : null;
23
+ const taskUrl = task.taskUrl ?? (isLocalJob ? `local:${job.id}/task/${task.id}` : `${prUrl}#issuecomment-${task.id}`);
21
24
  const output = task.output.kind === 'file'
22
25
  ? `file\nPath: ${task.output.path}`
23
- : `task/PR\nPR: ${prUrl}\nTask: ${task.id}`;
26
+ : (projectedReview ? `task/PR\nProjected PR: ${prUrl}\nTask: ${task.id}` : isLocalJob ? `task/PR\nTask: ${task.id}` : `task/PR\nPR: ${prUrl}\nTask: ${task.id}`);
24
27
  const taskTag = taskTagWorkerPrompt(job);
25
28
  return `[Agents Relay managed task]
26
- Job URL: ${prUrl}
29
+ ${projectedReview ? `Job: local ${job.id} (lifecycle authority)\nProjected GitHub PR: ${prUrl}` : isLocalJob ? `Job: local ${job.id}` : `Job URL: ${prUrl}`}
27
30
  Job ID: ${job.id}
28
31
  Task URL: ${taskUrl}
29
32
  Task ID: ${task.id}
30
33
  Project: ${task.projectName ?? context?.project.name ?? 'unspecified'}
31
- 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.
34
+ ${projectedReview
35
+ ? `The local SQLite Job is the lifecycle authority. The projected GitHub PR exists only for artifact/head verification. Review the projected PR at ${prUrl} and verify its exact immutable head against the durable review task input below. Do not use GitHub comments or PR metadata as lifecycle authority.\n\n${task.input}`
36
+ : isLocalJob
37
+ ? `Treat the following durable task input as the authoritative work contract; do not query GitHub.\n\n${task.input}`
38
+ : `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.`}
32
39
  ${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.
33
40
  [Output contract]
34
41
  ${output}
@@ -7,14 +7,36 @@ export async function executeManualPlan(store, jobId, guidance, timeoutMs, launc
7
7
  const active = pendingPlan(job);
8
8
  if (active && active.executionId)
9
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);
13
- job = await store.load(jobId);
14
- if (job.state === 'DRAFT') {
15
- await applyJobActivateAction(job);
16
- await store.saveJob(job);
10
+ let round = active ?? createPlanRound(job, { guidance, manual: true, timeoutMs });
11
+ if (store.mutateJob) {
12
+ const committed = await store.mutateJob(jobId, current => {
13
+ if (['COMPLETED', 'CANCELLED'].includes(current.state))
14
+ throw new Error(`Cannot plan terminal job ${current.id} (${current.state})`);
15
+ const concurrent = pendingPlan(current);
16
+ if (concurrent) {
17
+ if (concurrent.executionId)
18
+ throw new Error(`Planning round ${concurrent.id} is already PLANNING`);
19
+ round = structuredClone(concurrent);
20
+ }
21
+ else {
22
+ round = createPlanRound(current, { guidance, manual: true, timeoutMs });
23
+ current.plans = [...(current.plans ?? []), structuredClone(round)].sort((a, b) => a.round - b.round);
24
+ }
25
+ if (current.state === 'DRAFT')
26
+ applyJobActivateAction(current);
27
+ return current;
28
+ });
29
+ job = structuredClone(committed);
30
+ }
31
+ else {
32
+ if (!active)
33
+ await store.appendPlan(round);
17
34
  job = await store.load(jobId);
35
+ if (job.state === 'DRAFT') {
36
+ await applyJobActivateAction(job);
37
+ await store.saveJob(job);
38
+ job = await store.load(jobId);
39
+ }
18
40
  }
19
41
  const durableRound = (job.plans ?? []).find(plan => plan.id === round.id);
20
42
  if (!durableRound)
@@ -1,10 +1,53 @@
1
1
  import { randomUUID } from 'node:crypto';
2
2
  import { jobHasActiveExecution } from './job-execution-slot.js';
3
3
  import { eventFor } from './events.js';
4
- import { materializePlannerChild, parsePlannerResult, validatePlannerHierarchy } from './planner.js';
4
+ import { materializePlannerChild, parsePlannerResult, validatePlannerHierarchy, validatePlannerLifecycleOwnership, validatePlannerProgress } from './planner.js';
5
5
  import { samePlannerChildDefinition } from './planner-child.js';
6
6
  import { withExecutionBoundary } from './reconciler-runtime.js';
7
7
  import { storySizeTimeoutMs } from './types.js';
8
+ export async function drainPlannerUnblocks(ports, jobId, planId) {
9
+ const job = await ports.store.load(jobId);
10
+ const plan = (job.plans ?? []).find(item => item.id === planId);
11
+ if (!ports.store.registerBlockerDependent)
12
+ throw new Error("Blocker registration unavailable");
13
+ const pending = [...(plan?.pendingUnblocks ?? [])];
14
+ // Persist only acknowledgments observed by this invocation. Subtract from
15
+ // the latest transactional snapshot, never replace another drain's intents.
16
+ const acknowledged = new Set();
17
+ const key = (item) => JSON.stringify(item);
18
+ for (const unblock of pending) {
19
+ try {
20
+ await ports.store.registerBlockerDependent(jobId, unblock);
21
+ acknowledged.add(key(unblock));
22
+ }
23
+ catch { /* Pending intent is retained for the next reconcile. */ }
24
+ }
25
+ if (acknowledged.size > 0) {
26
+ if (!ports.store.mutateJob)
27
+ throw new Error('Atomic SQLite mutation is required to acknowledge planner unblocks');
28
+ await ports.store.mutateJob(jobId, current => {
29
+ const fresh = (current.plans ?? []).find(item => item.id === planId);
30
+ if (fresh?.pendingUnblocks?.length) {
31
+ const outstanding = fresh.pendingUnblocks.filter(item => !acknowledged.has(key(item)));
32
+ if (outstanding.length)
33
+ fresh.pendingUnblocks = outstanding;
34
+ else
35
+ delete fresh.pendingUnblocks;
36
+ fresh.updatedAt = new Date().toISOString();
37
+ }
38
+ return current;
39
+ });
40
+ }
41
+ const refreshed = await ports.store.load(jobId);
42
+ const remaining = (refreshed.plans ?? []).find(item => item.id === planId)?.pendingUnblocks ?? [];
43
+ return remaining.map(item => `${item.repository}#${item.prNumber}:${item.jobId}:${item.taskId}`);
44
+ }
45
+ export async function retryPendingPlannerUnblocks(ports, jobId) {
46
+ const job = await ports.store.load(jobId);
47
+ for (const plan of (job.plans ?? []).filter(item => item.status === 'SUCCEEDED' && (item.pendingUnblocks?.length ?? 0) > 0)) {
48
+ await drainPlannerUnblocks(ports, jobId, plan.id);
49
+ }
50
+ }
8
51
  async function finishPlanner(ports, jobId, planId, executionId, error, raw) {
9
52
  const live = ports.live.get(planId);
10
53
  if (live?.executionId === executionId)
@@ -13,40 +56,65 @@ async function finishPlanner(ports, jobId, planId, executionId, error, raw) {
13
56
  ports.live.delete(planId); };
14
57
  const job = await ports.store.load(jobId);
15
58
  const plan = (job.plans ?? []).find(item => item.id === planId);
16
- if (!plan || (executionId !== null && plan.executionId !== executionId)) {
59
+ if (!plan || plan.status !== "PLANNING" || plan.executionId !== executionId) {
17
60
  releaseLive();
18
61
  return;
19
62
  }
20
63
  const failOrRetry = async (message) => {
21
- const attempt = plan.attempt ?? 0;
22
- const maxAttempts = plan.maxAttempts ?? (plan.manualPlanning ? 1 : 3);
23
- const retry = attempt < maxAttempts;
24
- plan.status = retry ? 'PLANNING' : 'FAILED';
25
- plan.retryFeedback = message;
26
- plan.error = message;
27
- plan.assessment = retry ? `Planning retry: ${message}` : `Planning failed: ${message}`;
28
- plan.leaseOwner = null;
29
- plan.leaseExpiresAt = null;
30
- plan.executionId = null;
31
- plan.updatedAt = new Date().toISOString();
32
- await ports.store.savePlan(plan);
33
- await ports.emit(eventFor(job.id, plan.id, null, retry ? 'planner.retry' : 'planner.failed', retry ? 'queued' : 'failed', `Planner ${plan.id}: ${message}`, 'user'));
64
+ let retry = false;
65
+ let applied = false;
66
+ const update = (fresh) => {
67
+ if (fresh.status !== 'PLANNING' || fresh.executionId !== executionId)
68
+ return;
69
+ const attempt = fresh.attempt ?? 0;
70
+ const maxAttempts = fresh.maxAttempts ?? (fresh.manualPlanning ? 1 : 3);
71
+ retry = attempt < maxAttempts;
72
+ fresh.status = retry ? 'PLANNING' : 'FAILED';
73
+ fresh.retryFeedback = message;
74
+ fresh.error = message;
75
+ fresh.assessment = retry ? `Planning retry: ${message}` : `Planning failed: ${message}`;
76
+ fresh.leaseOwner = null;
77
+ fresh.leaseExpiresAt = null;
78
+ fresh.executionId = null;
79
+ fresh.updatedAt = new Date().toISOString();
80
+ applied = true;
81
+ };
82
+ if (ports.store.mutateJob) {
83
+ await ports.store.mutateJob(jobId, current => {
84
+ const fresh = (current.plans ?? []).find(item => item.id === planId);
85
+ if (fresh)
86
+ update(fresh);
87
+ return current;
88
+ });
89
+ }
90
+ else {
91
+ const fresh = (await ports.store.load(jobId)).plans?.find(item => item.id === planId);
92
+ if (fresh) {
93
+ update(fresh);
94
+ if (applied)
95
+ await ports.store.savePlan(fresh);
96
+ }
97
+ }
98
+ if (!applied)
99
+ return;
100
+ await ports.emit(eventFor(jobId, planId, null, retry ? 'planner.retry' : 'planner.failed', retry ? 'queued' : 'failed', `Planner ${planId}: ${message}`, 'user'));
34
101
  if (retry && !plan.manualPlanning)
35
- ports.detach(ports.reconcile(jobId), jobId, plan.id, 'planner retry reconciliation');
102
+ ports.detach(ports.reconcile(jobId), jobId, planId, 'planner retry reconciliation');
36
103
  };
37
104
  if (error) {
38
105
  await failOrRetry(error.message);
39
106
  releaseLive();
40
107
  return;
41
108
  }
109
+ let planCommitted = false;
42
110
  try {
43
111
  const result = parsePlannerResult(raw);
44
112
  const executionContext = await ports.plannerContext(job, plan);
45
113
  validatePlannerHierarchy(job.tasks, result.next_tasks);
114
+ validatePlannerLifecycleOwnership(job, result.next_tasks);
115
+ validatePlannerProgress(job, result);
46
116
  if (result.unblocks.length > 0 && !ports.store.registerBlockerDependent)
47
117
  throw new Error('Durable store does not support Planner cross-Job unblock registration');
48
- for (const unblock of result.unblocks)
49
- await ports.store.registerBlockerDependent?.(job.id, unblock);
50
118
  const children = result.next_tasks.map(spec => materializePlannerChild(job, plan, spec, executionContext));
51
119
  for (const child of children) {
52
120
  const existing = job.tasks.find(item => item.id === child.id);
@@ -54,29 +122,79 @@ async function finishPlanner(ports, jobId, planId, executionId, error, raw) {
54
122
  throw new Error(`Planner child ${child.id} already exists with a different definition`);
55
123
  }
56
124
  const now = new Date().toISOString();
57
- plan.status = 'SUCCEEDED';
58
- plan.objectiveStatus = result.objective_status;
59
- plan.plannerResult = result;
60
- plan.assessment = result.assessment;
61
- plan.generatedTaskIds = result.next_tasks.map(item => item.id);
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);
125
+ if (ports.store.mutateJob) {
126
+ let committedPlan = false;
127
+ const committed = await ports.store.mutateJob(job.id, current => {
128
+ const durablePlan = (current.plans ?? []).find(item => item.id === plan.id);
129
+ if (!durablePlan || durablePlan.status !== 'PLANNING' || durablePlan.executionId !== executionId)
130
+ return current;
131
+ durablePlan.status = 'SUCCEEDED';
132
+ durablePlan.objectiveStatus = result.objective_status;
133
+ durablePlan.plannerResult = result;
134
+ durablePlan.assessment = result.assessment;
135
+ durablePlan.generatedTaskIds = result.next_tasks.map(item => item.id);
136
+ durablePlan.pendingUnblocks = result.unblocks.length > 0 ? structuredClone(result.unblocks) : undefined;
137
+ durablePlan.error = undefined;
138
+ durablePlan.retryFeedback = undefined;
139
+ durablePlan.leaseOwner = null;
140
+ durablePlan.leaseExpiresAt = null;
141
+ durablePlan.executionId = null;
142
+ durablePlan.updatedAt = now;
143
+ if (current.changeCycle && !current.changeCycle.consumedByPlannerTaskId && durablePlan.trigger.source === 'unblock') {
144
+ current.changeCycle.consumedByPlannerTaskId = durablePlan.id;
145
+ }
146
+ for (const child of children) {
147
+ const existing = current.tasks.find(item => item.id === child.id);
148
+ if (!existing)
149
+ current.tasks.push(structuredClone(child));
150
+ else if (!samePlannerChildDefinition(existing, child))
151
+ throw new Error(`Planner child ${child.id} already exists with a different definition`);
152
+ }
153
+ current.updatedAt = now;
154
+ committedPlan = true;
155
+ return current;
156
+ });
157
+ Object.assign(job, structuredClone(committed));
158
+ if (!committedPlan) {
159
+ releaseLive();
160
+ return;
161
+ }
162
+ }
163
+ else {
164
+ plan.status = 'SUCCEEDED';
165
+ plan.objectiveStatus = result.objective_status;
166
+ plan.plannerResult = result;
167
+ plan.assessment = result.assessment;
168
+ plan.generatedTaskIds = result.next_tasks.map(item => item.id);
169
+ plan.pendingUnblocks = result.unblocks.length > 0 ? structuredClone(result.unblocks) : undefined;
170
+ plan.error = undefined;
171
+ plan.retryFeedback = undefined;
172
+ plan.leaseOwner = null;
173
+ plan.leaseExpiresAt = null;
174
+ plan.executionId = null;
175
+ plan.updatedAt = now;
176
+ await ports.store.savePlan(plan);
177
+ if (job.changeCycle && !job.changeCycle.consumedByPlannerTaskId && plan.trigger.source === 'unblock') {
178
+ job.changeCycle.consumedByPlannerTaskId = plan.id;
179
+ job.updatedAt = now;
180
+ await ports.store.saveJob(job);
181
+ }
182
+ for (const child of children)
183
+ if (!job.tasks.some(item => item.id === child.id))
184
+ await ports.store.appendTask(child);
73
185
  }
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 }));
186
+ planCommitted = true;
187
+ // Cross-Job registrations cannot join this Job's SQLite transaction. The
188
+ // committed Plan keeps replay intent until each external registration is
189
+ // confirmed, so a crash or partial failure is recoverable by reconcile.
190
+ const pendingUnblocks = await drainPlannerUnblocks(ports, job.id, plan.id);
191
+ 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, pendingUnblocks }));
78
192
  }
79
193
  catch (caught) {
194
+ if (planCommitted) {
195
+ releaseLive();
196
+ throw caught;
197
+ }
80
198
  await failOrRetry(caught instanceof Error ? caught.message : String(caught));
81
199
  releaseLive();
82
200
  return;
@@ -88,9 +206,32 @@ async function finishPlanner(ports, jobId, planId, executionId, error, raw) {
88
206
  export async function launchPlanExecution(ports, job, plan) {
89
207
  const planner = ports.options.planner;
90
208
  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'));
209
+ let failed = false;
210
+ if (ports.store.mutateJob) {
211
+ await ports.store.mutateJob(job.id, current => {
212
+ const fresh = current.plans?.find(item => item.id === plan.id);
213
+ if (fresh?.status === 'PLANNING' && fresh.executionId === plan.executionId) {
214
+ fresh.status = 'FAILED';
215
+ fresh.error = 'Planner unavailable';
216
+ fresh.assessment = 'Planning failed: Planner unavailable';
217
+ fresh.leaseOwner = null;
218
+ fresh.leaseExpiresAt = null;
219
+ fresh.executionId = null;
220
+ fresh.updatedAt = new Date().toISOString();
221
+ failed = true;
222
+ }
223
+ return current;
224
+ });
225
+ }
226
+ else {
227
+ const fresh = (await ports.store.load(job.id)).plans?.find(item => item.id === plan.id);
228
+ if (fresh?.status === 'PLANNING' && fresh.executionId === plan.executionId) {
229
+ await ports.store.savePlan({ ...fresh, status: 'FAILED', error: 'Planner unavailable', assessment: 'Planning failed: Planner unavailable', leaseOwner: null, leaseExpiresAt: null, executionId: null, updatedAt: new Date().toISOString() });
230
+ failed = true;
231
+ }
232
+ }
233
+ if (failed)
234
+ await ports.emit(eventFor(job.id, plan.id, null, 'planner.failed', 'failed', `Planner ${plan.id}: Planner unavailable`, 'user'));
94
235
  return;
95
236
  }
96
237
  const claimed = await withExecutionBoundary(job.id, '__job_execution_slot__', async () => {
package/dist/planner.js CHANGED
@@ -1,4 +1,5 @@
1
1
  import { readFile } from 'node:fs/promises';
2
+ import { parseTestInjection } from './e2e-injection.js';
2
3
  import { fileURLToPath } from 'node:url';
3
4
  import { dirname, join } from 'node:path';
4
5
  import { storySizeTimeoutMs, taskOverallTimeoutMs } from './types.js';
@@ -101,14 +102,14 @@ export function materializePlannerChild(job, planner, spec, executionContext) {
101
102
  const round = 'round' in planner ? planner.round : planner.planningRound ?? 1;
102
103
  const plannerHeadSha = 'round' in planner ? planner.headSha : undefined;
103
104
  const reviewHeadSha = spec.agentName === 'reviewer' && !(spec.dependencies?.length) ? plannerHeadSha ?? job.headSha : undefined;
104
- 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, reviewHeadSha });
105
+ 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, testInjection: spec.testInjection, 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, reviewHeadSha });
105
106
  }
106
107
  export function planningSpec(task) {
107
108
  if (!task.output && task.adapter !== 'orchestrator')
108
109
  throw new Error(`Initial task ${task.id} requires an output contract`);
109
110
  if (!['codex', 'chatgpt', 'orchestrator'].includes(task.adapter))
110
111
  throw new Error(`Initial task ${task.id} requires a supported adapter`);
111
- return { id: task.id, parentTaskId: task.parentTaskId, input: task.input, priority: task.priority, storySize: task.storySize, projectName: task.projectName, agentName: task.agentName, agentGraph: task.agentGraph, review: task.review, dependencies: task.dependencies, capabilities: task.capabilities, workerRequirements: task.workerRequirements, adapter: task.adapter, output: task.output ?? { kind: 'task_pr' }, routing: task.routing, continuation: task.continuation, maxAttempts: task.maxAttempts, timeoutMs: task.timeoutMs };
112
+ return { id: task.id, parentTaskId: task.parentTaskId, input: task.input, priority: task.priority, storySize: task.storySize, projectName: task.projectName, agentName: task.agentName, agentGraph: task.agentGraph, review: task.review, dependencies: task.dependencies, capabilities: task.capabilities, workerRequirements: task.workerRequirements, testInjection: task.testInjection, adapter: task.adapter, output: task.output ?? { kind: 'task_pr' }, routing: task.routing, continuation: task.continuation, maxAttempts: task.maxAttempts, timeoutMs: task.timeoutMs };
112
113
  }
113
114
  function record(value, field = 'result') {
114
115
  if (!value || typeof value !== 'object' || Array.isArray(value))
@@ -286,12 +287,44 @@ export function parsePlannerResult(value) {
286
287
  id, parentTaskId, input: task.input, priority, storySize,
287
288
  projectName: optionalString(task.projectName, 'projectName'), agentName: optionalString(task.agentName, 'agentName'), agentGraph, review,
288
289
  dependencies: stringArray(task.dependencies, 'dependencies'), capabilities: stringArray(task.capabilities, 'capabilities'), workerRequirements: requirements,
290
+ testInjection: parseTestInjection(task.testInjection ?? task['--test-injection']) ?? optionalString(task.testInjection ?? task['--test-injection'], `next_tasks[${index}].--test-injection`),
289
291
  adapter: adapter, output: output, routing: route,
290
292
  continuation: task.continuation, maxAttempts, timeoutMs
291
293
  };
292
294
  });
293
295
  return { objective_status: status, assessment: root.assessment, next_tasks, unblocks };
294
296
  }
297
+ export function validatePlannerProgress(job, result) {
298
+ if (result.objective_status !== 'in_progress' || result.next_tasks.length > 0 || result.unblocks.length > 0)
299
+ return;
300
+ const previousReviewFinished = job.tasks.some(task => task.agentName === 'reviewer' && task.state === 'SUCCEEDED');
301
+ if (previousReviewFinished)
302
+ return;
303
+ const liveWork = job.tasks.some(task => task.kind !== 'planner' && !['SUCCEEDED', 'FAILED', 'CANCELLED'].includes(task.state));
304
+ if (liveWork)
305
+ return;
306
+ throw new Error('Planner returned in_progress with no executable tasks or unblocks while no live work remains; return satisfied when only Relay-owned review/merge/completion effects remain');
307
+ }
308
+ export function validatePlannerLifecycleOwnership(job, specs) {
309
+ if (job.prNumber !== 0 || !job.localMerge)
310
+ return;
311
+ for (const spec of specs) {
312
+ // Restrict ownership detection to an individual imperative clause. A long
313
+ // implementation brief may describe Relay-owned completion as context.
314
+ const clauses = spec.input.toLowerCase().split(/[.!?\n]+/);
315
+ for (const clause of clauses) {
316
+ if (/\b(?:relay|graph|orchestrator)\s+(?:must|will|should|owns?)\b/.test(clause))
317
+ continue;
318
+ const localMerge = /\blocal[ -]merge\b|\blocal merge\b/.test(clause);
319
+ const jobCompletion = /\bjob\b.{0,80}\bcompleted\b|\bcompleted\b.{0,80}\bjob\b/.test(clause);
320
+ const externalMerge = /\b(?:external|github|projected)\b.{0,80}\bpr\b.{0,80}\bmerged\b|\bpr\b.{0,80}\bmerged\b/.test(clause);
321
+ const ownsLifecycle = /^\s*(?:perform|execute|complete|trigger|drive|merge|transition|finish|finalize)\b/.test(clause);
322
+ if (ownsLifecycle && [localMerge, jobCompletion, externalMerge].filter(Boolean).length >= 2) {
323
+ throw new Error(`Planner next task ${spec.id} attempts to own Graph lifecycle delivery; local merge, Job COMPLETED, and projected PR merge are Relay-owned effects and must not be worker Tasks`);
324
+ }
325
+ }
326
+ }
327
+ }
295
328
  export function validatePlannerHierarchy(existing, specs) {
296
329
  const existingById = new Map(existing.map(task => [task.id, task]));
297
330
  const specsById = new Map(specs.map(spec => [spec.id, spec]));
package/dist/pool.js CHANGED
@@ -4,6 +4,7 @@ import { runScheduledBucketFromStore } from './scheduled-jobs.js';
4
4
  import { scheduleBucket } from './scheduler.js';
5
5
  import { ReconcileCoordinator } from './reconcile-coordinator.js';
6
6
  import { errorMessage, observability, withObservabilityCorrelation } from './observability.js';
7
+ import { jobHasActiveExecution } from './job-execution-slot.js';
7
8
  const RANK = { P0: 0, P1: 1, P2: 2, P3: 3 };
8
9
  export function compareJobPriority(a, b) { return RANK[a.priority ?? 'P2'] - RANK[b.priority ?? 'P2'] || a.createdAt.localeCompare(b.createdAt) || a.id.localeCompare(b.id); }
9
10
  export function runnableManagedJobs(items) { return items.filter(item => item.job.state === 'ACTIVE').map(item => item.job).sort(compareJobPriority); }
@@ -31,6 +32,7 @@ export class RepositoryWorkerPool {
31
32
  reconcileLocks = new Map();
32
33
  coordinator;
33
34
  scheduleSignalCoordinator;
35
+ dispatchTail = Promise.resolve();
34
36
  watchedBus;
35
37
  constructor(concurrency, makeReconciler, localInventory, resolveLocalJob) {
36
38
  this.concurrency = concurrency;
@@ -57,6 +59,18 @@ export class RepositoryWorkerPool {
57
59
  throw new Error('Authoritative SQLite Job is not registered: ' + jobId);
58
60
  return resolved.store;
59
61
  }
62
+ async withDispatchGate(work) {
63
+ const previous = this.dispatchTail;
64
+ let release;
65
+ this.dispatchTail = new Promise(resolve => { release = resolve; });
66
+ await previous;
67
+ try {
68
+ await work();
69
+ }
70
+ finally {
71
+ release();
72
+ }
73
+ }
60
74
  async reconcileExclusive(jobId, work) {
61
75
  while (this.reconcileLocks.has(jobId))
62
76
  await this.reconcileLocks.get(jobId);
@@ -98,9 +112,11 @@ export class RepositoryWorkerPool {
98
112
  const target = await this.localTarget(jobId);
99
113
  if (!target)
100
114
  throw new Error('Local job not found: ' + jobId);
101
- const reconciler = this.makeReconciler(target.store, Math.max(1, this.concurrency));
102
- await this.reconcileExclusive(jobId, async () => {
103
- await withObservabilityCorrelation({ jobId, repository: target.job.repository }, () => reconciler.reconcile(jobId));
115
+ await this.withDispatchGate(async () => {
116
+ await this.reconcileExclusive(jobId, async () => {
117
+ await withObservabilityCorrelation({ jobId, repository: target.job.repository }, () => this.makeReconciler(target.store, 0).reconcile(jobId));
118
+ });
119
+ await this.reconcileDiscovered(await this.localInventory());
104
120
  });
105
121
  void reason;
106
122
  }
@@ -125,7 +141,7 @@ export class RepositoryWorkerPool {
125
141
  }
126
142
  async reconcileDiscovered(discovered) {
127
143
  const jobs = runnableManagedJobs(discovered);
128
- let capacity = Math.max(1, this.concurrency);
144
+ let capacity = Math.max(0, this.concurrency - jobs.filter(job => jobHasActiveExecution(job)).length);
129
145
  for (const job of jobs) {
130
146
  if (capacity <= 0)
131
147
  break;
@@ -170,7 +186,7 @@ export class RepositoryWorkerPool {
170
186
  try {
171
187
  do {
172
188
  this.pending = false;
173
- await this.reconcileDiscovered(await this.localInventory());
189
+ await this.withDispatchGate(async () => this.reconcileDiscovered(await this.localInventory()));
174
190
  } while (this.pending);
175
191
  }
176
192
  finally {
@@ -200,7 +216,7 @@ export class RepositoryWorkerPool {
200
216
  // GitHub transport is observational. It must never wake lifecycle execution.
201
217
  if (event.type === 'github.webhook')
202
218
  return;
203
- const wake = event.type === 'job.wake' || event.type === 'review.created';
219
+ const wake = event.type === 'job.wake' || event.type === 'review.created' || event.type === 'planner.completed';
204
220
  if (!lifecycle && !wake)
205
221
  return;
206
222
  await this.coordinator.acceptTransport({
@@ -0,0 +1,21 @@
1
+ import { applyTaskCancelAction } from './task-actions.js';
2
+ export async function cancelActiveTasks(store, jobId, reason, cancelPlanner, cancelExecutionGeneration) {
3
+ const job = await store.load(jobId);
4
+ for (const plan of (job.plans ?? []).filter(item => item.status === 'PLANNING')) {
5
+ cancelPlanner(plan.id);
6
+ plan.status = 'FAILED';
7
+ plan.error = reason;
8
+ plan.assessment = `Planning failed: ${reason}`;
9
+ plan.leaseOwner = null;
10
+ plan.leaseExpiresAt = null;
11
+ plan.executionId = null;
12
+ plan.updatedAt = new Date().toISOString();
13
+ await store.savePlan(plan);
14
+ }
15
+ for (const task of job.tasks.filter(item => item.kind !== 'planner' && !['SUCCEEDED', 'FAILED', 'UNBLOCKED', 'CANCELLED'].includes(item.state))) {
16
+ cancelExecutionGeneration(job.id, task.id, task.executionId ?? task.executionHistory?.at(-1)?.id);
17
+ cancelPlanner(task.id);
18
+ await applyTaskCancelAction(task, reason);
19
+ await store.saveTask(task);
20
+ }
21
+ }