agents-relay 2.0.67 → 2.0.69
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 +56 -20
- package/dist/adapters.js +39 -3
- package/dist/blockers.js +17 -4
- package/dist/cli-blocker.js +12 -30
- package/dist/cli-help.js +22 -17
- package/dist/cli-job-create.js +77 -0
- package/dist/cli-local-merge-config.js +11 -0
- package/dist/cli-projection.js +23 -55
- package/dist/cli-review.js +7 -24
- package/dist/cli-support.js +81 -8
- package/dist/cli-worker-task.js +63 -0
- package/dist/cli.js +223 -185
- package/dist/dashboard-html.js +15 -12
- package/dist/dashboard.js +27 -2
- package/dist/e2e-injection.js +76 -0
- package/dist/github-auth.js +4 -3
- package/dist/github-binding-order.js +15 -0
- package/dist/github-client.js +7 -2
- package/dist/github-pr-archive.js +22 -0
- package/dist/github-webhook.js +43 -10
- package/dist/job-actions.js +29 -0
- package/dist/local-authority-store.js +288 -0
- package/dist/local-commit-events.js +2 -0
- package/dist/local-git-merge.js +124 -0
- package/dist/local-job-path.js +7 -0
- package/dist/local-job-registry.js +29 -0
- package/dist/local-managed-store.js +21 -0
- package/dist/local-merge-delivery.js +53 -0
- package/dist/local-merge-lock.js +103 -0
- package/dist/local-merge-projection.js +60 -0
- package/dist/managed-worker-input.js +13 -6
- package/dist/manual-plan.js +29 -7
- package/dist/no-github.js +11 -0
- package/dist/planned-review-head.js +7 -8
- package/dist/planner-runtime.js +182 -41
- package/dist/planner.js +35 -2
- package/dist/pool.js +89 -136
- package/dist/projected-store.js +20 -3
- package/dist/projection-outbox.js +3 -1
- package/dist/publication-store.js +37 -0
- package/dist/reconciler-cancellation.js +21 -0
- package/dist/reconciler-review.js +99 -29
- package/dist/reconciler-runtime.js +72 -35
- package/dist/reconciler.js +124 -85
- package/dist/relay-store.js +108 -6
- package/dist/relayd-control.js +184 -0
- package/dist/relayd-dashboard-actions.js +108 -0
- package/dist/relayd-utilities.js +107 -0
- package/dist/relayd.js +304 -299
- package/dist/review-generation-fence.js +12 -0
- package/dist/runtime-status-events.js +21 -0
- package/dist/runtime-status.js +21 -7
- package/dist/scheduled-jobs.js +43 -36
- package/dist/service-external-actions.js +27 -0
- package/dist/service-publication.js +29 -0
- package/dist/store-github.js +17 -6
- package/dist/task-actions.js +59 -1
- package/dist/task-update.js +1 -1
- package/dist/troubleshooter.js +21 -2
- package/dist/unblock.js +47 -15
- package/dist/worker-rejection.js +11 -0
- package/dist/workflow/contracts.js +10 -1
- package/dist/workflow/definitions-v2.js +2 -0
- package/dist/workflow/job-reconcile-v2.js +9 -60
- package/dist/workflow/runtime.js +18 -3
- package/dist/workflow-effect-failures.js +30 -0
- package/dist/workspace.js +95 -6
- package/package.json +6 -3
- package/skills/agents-relay/agents/planner.agent.md +3 -0
- package/skills/agents-relay/contracts/orchestrator-workflows.md +4 -7
- package/workflow/job-lifecycle.mmd +19 -60
- package/workflow/review-lifecycle.mmd +5 -8
|
@@ -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
|
-
|
|
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/${
|
|
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
|
-
|
|
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}
|
package/dist/manual-plan.js
CHANGED
|
@@ -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
|
-
|
|
11
|
-
if (
|
|
12
|
-
await store.
|
|
13
|
-
|
|
14
|
-
|
|
15
|
-
|
|
16
|
-
|
|
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)
|
|
@@ -0,0 +1,11 @@
|
|
|
1
|
+
import { GhApiClient } from './github-client.js';
|
|
2
|
+
/** No GitHub transport can escape the disabled adapter. */
|
|
3
|
+
export class DisabledGitHubClient extends GhApiClient {
|
|
4
|
+
async request() { throw new Error('GitHub disabled by --no-github'); }
|
|
5
|
+
async graphql() { throw new Error('GitHub disabled by --no-github'); }
|
|
6
|
+
async rateLimitSnapshot() { throw new Error('GitHub disabled by --no-github'); }
|
|
7
|
+
async currentLogin() { throw new Error('GitHub disabled by --no-github'); }
|
|
8
|
+
}
|
|
9
|
+
export function noGitHubMode(args = process.argv.slice(2)) {
|
|
10
|
+
return args.includes('--no-github') || process.argv.includes('--no-github') || process.env.AGENTS_RELAY_NO_GITHUB === '1';
|
|
11
|
+
}
|
|
@@ -1,14 +1,13 @@
|
|
|
1
1
|
/** A planner-created dependent review must freeze its head only after implementation completes. */
|
|
2
|
-
export async function bindPlannedReviewHead(store, task) {
|
|
3
|
-
|
|
4
|
-
|
|
5
|
-
|
|
6
|
-
task.reviewHeadSha = current.sha;
|
|
2
|
+
export async function bindPlannedReviewHead(store, task, localHeadSha) {
|
|
3
|
+
if (!localHeadSha)
|
|
4
|
+
throw new Error(`Review ${task.id} requires current local Git head SHA`);
|
|
5
|
+
task.reviewHeadSha = localHeadSha;
|
|
7
6
|
const rule = [
|
|
8
|
-
'Review the current immutable
|
|
9
|
-
'Distinguish code added by this implementation from pre-existing
|
|
7
|
+
'Review the current immutable local source head as a PRE-MERGE approval gate.',
|
|
8
|
+
'Distinguish code added by this implementation from pre-existing local baseline/seed files present before the Task started.',
|
|
10
9
|
'Preserved baseline files are not automatically extra changes merely because they differ from main; still review them for real risks.',
|
|
11
|
-
'Do not require PR MERGED, Job COMPLETED, or post-merge E2E evidence before review approval:',
|
|
10
|
+
'Do not require a remote PR MERGED, Job COMPLETED, or post-merge E2E evidence before review approval:',
|
|
12
11
|
'those events must follow approval and are verified by the independent E2E runner.',
|
|
13
12
|
].join(' ');
|
|
14
13
|
if (!task.input.includes(rule))
|
package/dist/planner-runtime.js
CHANGED
|
@@ -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 ||
|
|
59
|
+
if (!plan || plan.status !== "PLANNING" || plan.executionId !== executionId) {
|
|
17
60
|
releaseLive();
|
|
18
61
|
return;
|
|
19
62
|
}
|
|
20
63
|
const failOrRetry = async (message) => {
|
|
21
|
-
|
|
22
|
-
|
|
23
|
-
const
|
|
24
|
-
|
|
25
|
-
|
|
26
|
-
|
|
27
|
-
|
|
28
|
-
|
|
29
|
-
|
|
30
|
-
|
|
31
|
-
|
|
32
|
-
|
|
33
|
-
|
|
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,
|
|
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
|
-
|
|
58
|
-
|
|
59
|
-
|
|
60
|
-
|
|
61
|
-
|
|
62
|
-
|
|
63
|
-
|
|
64
|
-
|
|
65
|
-
|
|
66
|
-
|
|
67
|
-
|
|
68
|
-
|
|
69
|
-
|
|
70
|
-
|
|
71
|
-
|
|
72
|
-
|
|
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
|
-
|
|
75
|
-
|
|
76
|
-
|
|
77
|
-
|
|
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
|
-
|
|
92
|
-
|
|
93
|
-
|
|
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]));
|