agents-relay 2.0.64 → 2.0.65
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/dist/job-execution-slot.js +12 -0
- package/dist/job-worktree-cleanup.js +10 -0
- package/dist/managed-worker-input.js +62 -0
- package/dist/planner-runtime.js +5 -1
- package/dist/reconciler.js +14 -65
- package/dist/workflow/contracts.js +8 -0
- package/dist/workspace.js +81 -6
- package/package.json +1 -1
- package/workflow/job-lifecycle.mmd +6 -0
|
@@ -0,0 +1,12 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* One active execution per Job, irrespective of adapter or current reconciler.
|
|
3
|
+
* A claimed QUEUED execution also occupies the slot; synchronous P0/self work
|
|
4
|
+
* occupies it by its RUNNING state even though it has no execution UUID.
|
|
5
|
+
*/
|
|
6
|
+
export function jobHasActiveExecution(job, except = {}) {
|
|
7
|
+
const worker = job.tasks.some(task => task.id !== except.taskId
|
|
8
|
+
&& (task.state === 'RUNNING' || (task.executionId !== null && !['SUCCEEDED', 'FAILED', 'CANCELLED'].includes(task.state))));
|
|
9
|
+
const planner = (job.plans ?? []).some(plan => plan.id !== except.planId
|
|
10
|
+
&& plan.executionId !== null && plan.status === 'PLANNING');
|
|
11
|
+
return worker || planner;
|
|
12
|
+
}
|
|
@@ -0,0 +1,10 @@
|
|
|
1
|
+
import { cleanupCompletedJobWorktree } from './workspace.js';
|
|
2
|
+
/** Mechanical effect selected by job-lifecycle Graph on a completed PR. */
|
|
3
|
+
export async function cleanupMergedJobWorktree(job, projectPathForJob) {
|
|
4
|
+
if (!projectPathForJob)
|
|
5
|
+
throw new Error('No canonical project resolver for Worktree cleanup');
|
|
6
|
+
const result = await cleanupCompletedJobWorktree(await projectPathForJob(job), job);
|
|
7
|
+
// A blocked cleanup must be retried on a subsequent completed-Job reconcile.
|
|
8
|
+
if (result.status === 'blocked')
|
|
9
|
+
throw new Error(result.reason ?? 'Unsafe Worktree cleanup');
|
|
10
|
+
}
|
|
@@ -0,0 +1,62 @@
|
|
|
1
|
+
import { agentContractPrompt, executionContextPrompt } from './execution-context.js';
|
|
2
|
+
import { agentGraphPrompt } from './agent-graph.js';
|
|
3
|
+
import { createReviewChairExecutionPlan } from './review-chair.js';
|
|
4
|
+
import { taskTagWorkerPrompt } from './task-tag.js';
|
|
5
|
+
import { executionEventSource } from './reconciler-runtime.js';
|
|
6
|
+
export async function managedWorkerInput(job, task, context) {
|
|
7
|
+
if (!['codex', 'chatgpt'].includes(task.adapter) || !job.repository || job.prNumber <= 0)
|
|
8
|
+
return task.input;
|
|
9
|
+
const selectedAgent = task.agentName && context ? await agentContractPrompt(context, task.agentName) : '';
|
|
10
|
+
const selectedGraph = task.agentGraph && context ? await agentGraphPrompt(context, task.agentGraph) : '';
|
|
11
|
+
const chairPlan = task.review?.role === 'chair' && context
|
|
12
|
+
? createReviewChairExecutionPlan(context.agents, task.review.target)
|
|
13
|
+
: null;
|
|
14
|
+
if (!task.output)
|
|
15
|
+
throw new Error(`Task ${task.id} requires an explicit output contract`);
|
|
16
|
+
if (!task.executionId)
|
|
17
|
+
throw new Error(`Task ${task.id} requires an executionId before worker input is built`);
|
|
18
|
+
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}`;
|
|
21
|
+
const output = task.output.kind === 'file'
|
|
22
|
+
? `file\nPath: ${task.output.path}`
|
|
23
|
+
: `task/PR\nPR: ${prUrl}\nTask: ${task.id}`;
|
|
24
|
+
const taskTag = taskTagWorkerPrompt(job);
|
|
25
|
+
return `[Agents Relay managed task]
|
|
26
|
+
Job URL: ${prUrl}
|
|
27
|
+
Job ID: ${job.id}
|
|
28
|
+
Task URL: ${taskUrl}
|
|
29
|
+
Task ID: ${task.id}
|
|
30
|
+
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.
|
|
32
|
+
${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
|
+
[Output contract]
|
|
34
|
+
${output}
|
|
35
|
+
${taskTag}[Execution event contract — mandatory]
|
|
36
|
+
Use the canonical Neo events-bus with exactly Job ${job.id} and Task ${task.id}.
|
|
37
|
+
Your exact event source identity is: ${sourceId}
|
|
38
|
+
Every lifecycle event for this execution MUST set source.id to that exact string. Do not derive, rename, or replace it.
|
|
39
|
+
Follow the events-bus skill/protocol. For sandboxed Codex or hosted ChatGPT,
|
|
40
|
+
publish through the federated MCP tool events__publish; do not open NATS directly.
|
|
41
|
+
For a direct non-sandbox local worker, use NEO_EVENTS_EMIT when the caller provides it.
|
|
42
|
+
As your FIRST execution action, before reading files, editing code, running commands, or doing other work,
|
|
43
|
+
you MUST publish exactly one canonical task.started event for this exact Job/Task.
|
|
44
|
+
|
|
45
|
+
${task.adapter === 'chatgpt' ? `Because ChatGPT execution continues asynchronously after the submit process exits, you MUST keep reporting liveness while the task is active. Publish meaningful progress/milestone events when there is real progress to report. If no meaningful milestone has been published for 30 seconds during active work, publish a non-terminal task.heartbeat event with status running, visibility orchestrator, this exact source.id, and a concise description of the current operation. Continue heartbeats after each further 30 seconds of active silence until a terminal task event is published. Do not invent percentage progress.` : `You MAY publish progress events while working. For long silent active work, follow the events-bus heartbeat policy.`}
|
|
46
|
+
Before returning or allowing the worker process to exit, you MUST publish exactly one canonical terminal event:
|
|
47
|
+
- task.completed only after the declared output is durable;
|
|
48
|
+
- task.failed for implementation, tool, command, test, patch, commit, push, runtime, or other execution failure that prevents completion;
|
|
49
|
+
- task.blocked only when genuine human/external action is required;
|
|
50
|
+
- task.cancelled only when execution was cancelled.
|
|
51
|
+
|
|
52
|
+
If any execution step fails and you cannot continue, publish task.failed with the concrete reason BEFORE exiting.
|
|
53
|
+
Never exit after task.started without publishing exactly one terminal task event.
|
|
54
|
+
Do not publish duplicate terminal task events.
|
|
55
|
+
The worker owns task.started and task terminal events. Relay-owned task.process.launched, task.process.exited,
|
|
56
|
+
and task.process.async_exited are process lifecycle only and NEVER replace the worker task lifecycle.
|
|
57
|
+
The ChatGPT/Codex conversation or process exit is never the task result.
|
|
58
|
+
|
|
59
|
+
[Final event gate]
|
|
60
|
+
Before your final return, verify that a terminal event for this exact Job/Task was published.
|
|
61
|
+
If no terminal event has been published, publish task.failed before exiting.`;
|
|
62
|
+
}
|
package/dist/planner-runtime.js
CHANGED
|
@@ -1,4 +1,5 @@
|
|
|
1
1
|
import { randomUUID } from 'node:crypto';
|
|
2
|
+
import { jobHasActiveExecution } from './job-execution-slot.js';
|
|
2
3
|
import { eventFor } from './events.js';
|
|
3
4
|
import { materializePlannerChild, parsePlannerResult, validatePlannerHierarchy } from './planner.js';
|
|
4
5
|
import { samePlannerChildDefinition } from './planner-child.js';
|
|
@@ -92,11 +93,14 @@ export async function launchPlanExecution(ports, job, plan) {
|
|
|
92
93
|
await ports.emit(eventFor(job.id, plan.id, null, 'planner.failed', 'failed', `Planner ${plan.id}: Planner unavailable`, 'user'));
|
|
93
94
|
return;
|
|
94
95
|
}
|
|
95
|
-
const claimed = await withExecutionBoundary(job.id,
|
|
96
|
+
const claimed = await withExecutionBoundary(job.id, '__job_execution_slot__', async () => {
|
|
96
97
|
const freshJob = await ports.store.load(job.id);
|
|
97
98
|
const fresh = (freshJob.plans ?? []).find(item => item.id === plan.id);
|
|
98
99
|
if (!fresh || fresh.status !== 'PLANNING')
|
|
99
100
|
return null;
|
|
101
|
+
// A Planner is an execution too: no overlapping worker or second Planner.
|
|
102
|
+
if (jobHasActiveExecution(freshJob, { planId: fresh.id }))
|
|
103
|
+
return null;
|
|
100
104
|
const live = ports.live.get(fresh.id);
|
|
101
105
|
if (fresh.executionId && fresh.leaseOwner === ports.options.owner && live?.executionId === fresh.executionId)
|
|
102
106
|
return null;
|
package/dist/reconciler.js
CHANGED
|
@@ -7,17 +7,18 @@ import { LaunchFailure, StartAckTimeout } from './adapters.js';
|
|
|
7
7
|
import { eventFor } from './events.js';
|
|
8
8
|
import { createPlanRound, materializePlannerChild, pendingPlan } from './planner.js';
|
|
9
9
|
import { dispatchPlannerOrchestration, plannerOrchestrationFacts } from './workflow/planner-orchestration-v2.js';
|
|
10
|
-
import {
|
|
11
|
-
import { agentGraphPrompt } from './agent-graph.js';
|
|
10
|
+
import { resolveExecutionContext } from './execution-context.js';
|
|
12
11
|
import { ReconcilerReviewBase } from './reconciler-review.js';
|
|
13
12
|
import { executeManualPlan } from './manual-plan.js';
|
|
14
13
|
import { executionEventSource, withExecutionBoundary } from './reconciler-runtime.js';
|
|
15
14
|
export { executionEventSource } from './reconciler-runtime.js';
|
|
16
15
|
import { CallbackWorkflowEffectHandler } from './workflow/effects.js';
|
|
17
16
|
import { pendingTroubleshooterTasks } from './troubleshooter.js';
|
|
18
|
-
import { prepareManagedExecutionWorktree, taskUsesCanonicalProjectRun } from './workspace.js';
|
|
19
|
-
import {
|
|
20
|
-
import {
|
|
17
|
+
import { assertCleanWorktreeAtHead, prepareManagedExecutionWorktree, taskUsesCanonicalProjectRun } from './workspace.js';
|
|
18
|
+
import { cleanupMergedJobWorktree } from './job-worktree-cleanup.js';
|
|
19
|
+
import { jobHasActiveExecution } from './job-execution-slot.js';
|
|
20
|
+
import { managedWorkerInput } from './managed-worker-input.js';
|
|
21
|
+
export { managedWorkerInput } from './managed-worker-input.js';
|
|
21
22
|
import { recoverExpiredExecutions, recoverStartupOrphans as recoverStartupExecutionOrphans } from './execution-recovery.js';
|
|
22
23
|
import { dispatchJobReconcile } from './workflow/job-reconcile-v2.js';
|
|
23
24
|
import { applyOrchestratorRescue } from './workflow/orchestrator-rescue.js';
|
|
@@ -25,63 +26,6 @@ import { launchPlanExecution } from './planner-runtime.js';
|
|
|
25
26
|
import { classifyFailure, errorMessage, observability, withObservabilityCorrelation } from './observability.js';
|
|
26
27
|
const PRIORITY_RANK = { P0: 0, P1: 1, P2: 2, P3: 3 };
|
|
27
28
|
export function compareTaskPriority(a, b, jobPriority = 'P2') { const ar = PRIORITY_RANK[a.priority ?? jobPriority]; const br = PRIORITY_RANK[b.priority ?? jobPriority]; return ar - br || a.createdAt.localeCompare(b.createdAt) || a.id.localeCompare(b.id); }
|
|
28
|
-
export async function managedWorkerInput(job, task, context) {
|
|
29
|
-
if (!['codex', 'chatgpt'].includes(task.adapter) || !job.repository || job.prNumber <= 0)
|
|
30
|
-
return task.input;
|
|
31
|
-
const selectedAgent = task.agentName && context ? await agentContractPrompt(context, task.agentName) : '';
|
|
32
|
-
const selectedGraph = task.agentGraph && context ? await agentGraphPrompt(context, task.agentGraph) : '';
|
|
33
|
-
const chairPlan = task.review?.role === 'chair' && context
|
|
34
|
-
? createReviewChairExecutionPlan(context.agents, task.review.target)
|
|
35
|
-
: null;
|
|
36
|
-
if (!task.output)
|
|
37
|
-
throw new Error(`Task ${task.id} requires an explicit output contract`);
|
|
38
|
-
if (!task.executionId)
|
|
39
|
-
throw new Error(`Task ${task.id} requires an executionId before worker input is built`);
|
|
40
|
-
const sourceId = executionEventSource(job.id, task.id, task.executionId);
|
|
41
|
-
const prUrl = `https://github.com/${job.repository}/pull/${job.prNumber}`;
|
|
42
|
-
const taskUrl = task.taskUrl ?? `${prUrl}#issuecomment-${task.id}`;
|
|
43
|
-
const output = task.output.kind === 'file'
|
|
44
|
-
? `file\nPath: ${task.output.path}`
|
|
45
|
-
: `task/PR\nPR: ${prUrl}\nTask: ${task.id}`;
|
|
46
|
-
const taskTag = taskTagWorkerPrompt(job);
|
|
47
|
-
return `[Agents Relay managed task]
|
|
48
|
-
Job URL: ${prUrl}
|
|
49
|
-
Job ID: ${job.id}
|
|
50
|
-
Task URL: ${taskUrl}
|
|
51
|
-
Task ID: ${task.id}
|
|
52
|
-
Project: ${task.projectName ?? context?.project.name ?? 'unspecified'}
|
|
53
|
-
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.
|
|
54
|
-
${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.
|
|
55
|
-
[Output contract]
|
|
56
|
-
${output}
|
|
57
|
-
${taskTag}[Execution event contract — mandatory]
|
|
58
|
-
Use the canonical Neo events-bus with exactly Job ${job.id} and Task ${task.id}.
|
|
59
|
-
Your exact event source identity is: ${sourceId}
|
|
60
|
-
Every lifecycle event for this execution MUST set source.id to that exact string. Do not derive, rename, or replace it.
|
|
61
|
-
Follow the events-bus skill/protocol. For sandboxed Codex or hosted ChatGPT,
|
|
62
|
-
publish through the federated MCP tool events__publish; do not open NATS directly.
|
|
63
|
-
For a direct non-sandbox local worker, use NEO_EVENTS_EMIT when the caller provides it.
|
|
64
|
-
As your FIRST execution action, before reading files, editing code, running commands, or doing other work,
|
|
65
|
-
you MUST publish exactly one canonical task.started event for this exact Job/Task.
|
|
66
|
-
|
|
67
|
-
${task.adapter === 'chatgpt' ? `Because ChatGPT execution continues asynchronously after the submit process exits, you MUST keep reporting liveness while the task is active. Publish meaningful progress/milestone events when there is real progress to report. If no meaningful milestone has been published for 30 seconds during active work, publish a non-terminal task.heartbeat event with status running, visibility orchestrator, this exact source.id, and a concise description of the current operation. Continue heartbeats after each further 30 seconds of active silence until a terminal task event is published. Do not invent percentage progress.` : `You MAY publish progress events while working. For long silent active work, follow the events-bus heartbeat policy.`}
|
|
68
|
-
Before returning or allowing the worker process to exit, you MUST publish exactly one canonical terminal event:
|
|
69
|
-
- task.completed only after the declared output is durable;
|
|
70
|
-
- task.failed for implementation, tool, command, test, patch, commit, push, runtime, or other execution failure that prevents completion;
|
|
71
|
-
- task.blocked only when genuine human/external action is required;
|
|
72
|
-
- task.cancelled only when execution was cancelled.
|
|
73
|
-
|
|
74
|
-
If any execution step fails and you cannot continue, publish task.failed with the concrete reason BEFORE exiting.
|
|
75
|
-
Never exit after task.started without publishing exactly one terminal task event.
|
|
76
|
-
Do not publish duplicate terminal task events.
|
|
77
|
-
The worker owns task.started and task terminal events. Relay-owned task.process.launched, task.process.exited,
|
|
78
|
-
and task.process.async_exited are process lifecycle only and NEVER replace the worker task lifecycle.
|
|
79
|
-
The ChatGPT/Codex conversation or process exit is never the task result.
|
|
80
|
-
|
|
81
|
-
[Final event gate]
|
|
82
|
-
Before your final return, verify that a terminal event for this exact Job/Task was published.
|
|
83
|
-
If no terminal event has been published, publish task.failed before exiting.`;
|
|
84
|
-
}
|
|
85
29
|
export class Reconciler extends ReconcilerReviewBase {
|
|
86
30
|
constructor(store, options) {
|
|
87
31
|
super(store, options);
|
|
@@ -94,6 +38,7 @@ export class Reconciler extends ReconcilerReviewBase {
|
|
|
94
38
|
await this.launch(job, task);
|
|
95
39
|
}, context => `effect.launchTask:${context.jobId}:${context.taskId ?? 'task'}:${String(context.data?.attempt ?? 0)}`));
|
|
96
40
|
this.workflowEffects.register(new CallbackWorkflowEffectHandler('effect.cancelActiveTasks', async (context) => this.cancelActiveTasks(context.jobId, String(context.data?.reason ?? 'Job terminated'))));
|
|
41
|
+
this.workflowEffects.register(new CallbackWorkflowEffectHandler('effect.cleanupJobWorktree', async (context) => cleanupMergedJobWorktree(await this.store.load(context.jobId), this.options.projectPathForJob), context => `effect.cleanupJobWorktree:${context.jobId}`));
|
|
97
42
|
this.workflowEffects.register(new CallbackWorkflowEffectHandler('effect.mergePullRequest', async (context) => {
|
|
98
43
|
const headSha = typeof context.data?.headSha === 'string' ? context.data.headSha : null;
|
|
99
44
|
if (!headSha || !this.store.mergePullRequest)
|
|
@@ -107,14 +52,14 @@ export class Reconciler extends ReconcilerReviewBase {
|
|
|
107
52
|
this.detach(this.deliverContinuation(scheduled, task), scheduled.id, task.id, 'continuation');
|
|
108
53
|
}
|
|
109
54
|
const capacity = Math.max(0, (this.options.maxConcurrent ?? 4) - this.live.size - this.plannerLive.size);
|
|
110
|
-
const dispatchable = scheduled.tasks
|
|
55
|
+
const dispatchable = (jobHasActiveExecution(scheduled) ? [] : scheduled.tasks)
|
|
111
56
|
.filter(task => task.state === 'QUEUED'
|
|
112
57
|
&& task.executionId === null
|
|
113
58
|
&& task.kind !== 'planner'
|
|
114
59
|
&& task.adapter !== 'orchestrator'
|
|
115
60
|
&& dependencyReady(task, new Map(scheduled.tasks.map(item => [item.id, item]))))
|
|
116
61
|
.sort((a, b) => compareTaskPriority(a, b, scheduled.priority ?? 'P2'))
|
|
117
|
-
.slice(0, capacity);
|
|
62
|
+
.slice(0, Math.min(capacity, 1));
|
|
118
63
|
for (const task of dispatchable) {
|
|
119
64
|
await transitionTaskEvent(task, 'dispatch', { dispatchable: true }, async (actionId) => {
|
|
120
65
|
const outcomes = await this.workflowEffects.execute([actionId], { jobId: scheduled.id, taskId: task.id, transitionKey: `dispatch:${task.attempt}`, data: { attempt: task.attempt } });
|
|
@@ -381,13 +326,15 @@ export class Reconciler extends ReconcilerReviewBase {
|
|
|
381
326
|
await this.store.saveTask(task);
|
|
382
327
|
return;
|
|
383
328
|
}
|
|
384
|
-
const claimedTask = await withExecutionBoundary(job.id,
|
|
329
|
+
const claimedTask = await withExecutionBoundary(job.id, '__job_execution_slot__', async () => {
|
|
385
330
|
const freshJob = await this.store.load(job.id);
|
|
386
331
|
let freshTask = freshJob.tasks.find(item => item.id === task.id);
|
|
387
332
|
if (!freshTask || freshTask.state !== 'QUEUED' || !dependencyReady(freshTask, new Map(freshJob.tasks.map(item => [item.id, item]))))
|
|
388
333
|
return;
|
|
389
334
|
if (freshTask.executionId !== null)
|
|
390
335
|
return;
|
|
336
|
+
if (jobHasActiveExecution(freshJob, { taskId: freshTask.id }))
|
|
337
|
+
return;
|
|
391
338
|
if (freshTask.agentName === 'reviewer' && freshTask.planningDecisionId && freshTask.dependencies.length)
|
|
392
339
|
await bindPlannedReviewHead(this.store, freshTask);
|
|
393
340
|
if (!freshTask.routing && freshTask.adapter !== 'orchestrator') {
|
|
@@ -429,6 +376,8 @@ export class Reconciler extends ReconcilerReviewBase {
|
|
|
429
376
|
if (!managedHead)
|
|
430
377
|
throw new Error(`PR #${job.prNumber} has no resolvable head ref`);
|
|
431
378
|
projectPath = await prepareManagedExecutionWorktree(projectPath, job.repository, job.prNumber, managedHead, task.executionId);
|
|
379
|
+
if (role === 'reviewer')
|
|
380
|
+
await assertCleanWorktreeAtHead(projectPath, managedHead.sha);
|
|
432
381
|
}
|
|
433
382
|
executionContext = await this.resolvedContext(job, task, role, projectPath);
|
|
434
383
|
}
|
|
@@ -4,6 +4,7 @@ export const WORKFLOW_EVENT_NAMES = [
|
|
|
4
4
|
'cancel',
|
|
5
5
|
'dispatch',
|
|
6
6
|
'effect.cancelActiveTasks.failed',
|
|
7
|
+
'effect.cleanupJobWorktree.failed',
|
|
7
8
|
'effect.launchTask.failed',
|
|
8
9
|
'effect.mergePullRequest.failed',
|
|
9
10
|
'execution.orphaned',
|
|
@@ -66,6 +67,7 @@ export const WORKFLOW_EFFECT_NAMES = [
|
|
|
66
67
|
'effect.launchTask',
|
|
67
68
|
'effect.cancelActiveTasks',
|
|
68
69
|
'effect.mergePullRequest',
|
|
70
|
+
'effect.cleanupJobWorktree',
|
|
69
71
|
];
|
|
70
72
|
export const WORKFLOW_EFFECT_POLICIES = {
|
|
71
73
|
'effect.launchTask': {
|
|
@@ -80,6 +82,12 @@ export const WORKFLOW_EFFECT_POLICIES = {
|
|
|
80
82
|
retry: 'workflow',
|
|
81
83
|
observability: 'workflow.effect',
|
|
82
84
|
},
|
|
85
|
+
'effect.cleanupJobWorktree': {
|
|
86
|
+
idempotency: 'job-completed',
|
|
87
|
+
failureEvent: 'effect.cleanupJobWorktree.failed',
|
|
88
|
+
retry: 'workflow',
|
|
89
|
+
observability: 'workflow.effect',
|
|
90
|
+
},
|
|
83
91
|
'effect.mergePullRequest': {
|
|
84
92
|
idempotency: 'job-head-sha',
|
|
85
93
|
failureEvent: 'effect.mergePullRequest.failed',
|
package/dist/workspace.js
CHANGED
|
@@ -1,4 +1,4 @@
|
|
|
1
|
-
import { mkdir, readdir, stat } from 'node:fs/promises';
|
|
1
|
+
import { lstat, mkdir, readdir, realpath, stat } from 'node:fs/promises';
|
|
2
2
|
import { basename, dirname, isAbsolute, join, relative, resolve, sep } from 'node:path';
|
|
3
3
|
import { homedir } from 'node:os';
|
|
4
4
|
import { spawnOwnedProcess } from './owned-process.js';
|
|
@@ -122,7 +122,7 @@ export function taskUsesCanonicalProjectRun(task, tasks) {
|
|
|
122
122
|
async function git(path, args) {
|
|
123
123
|
return (await gitOutput(['-C', path, ...args])).trim();
|
|
124
124
|
}
|
|
125
|
-
export async function prepareManagedExecutionWorktree(projectPath, repository, prNumber, head,
|
|
125
|
+
export async function prepareManagedExecutionWorktree(projectPath, repository, prNumber, head, _executionId, workspaceRoot = defaultWorkspaceRoot()) {
|
|
126
126
|
const primary = await git(projectPath, ['rev-parse', '--show-toplevel']);
|
|
127
127
|
const projectRelative = (await git(projectPath, ['rev-parse', '--show-prefix'])).replace(/\/$/, '');
|
|
128
128
|
const remote = await git(primary, ['config', '--get', 'remote.origin.url']);
|
|
@@ -133,14 +133,25 @@ export async function prepareManagedExecutionWorktree(projectPath, repository, p
|
|
|
133
133
|
if (fetched !== head.sha)
|
|
134
134
|
throw new Error(`PR #${prNumber} head changed while preparing execution checkout (${head.sha} -> ${fetched})`);
|
|
135
135
|
const safeRepo = repository.replace(/[^A-Za-z0-9_.-]+/g, '-');
|
|
136
|
-
|
|
137
|
-
const worktree =
|
|
136
|
+
// One stable checkout per Job; retries and reviewers share the same directory.
|
|
137
|
+
const worktree = managedJobWorktreePath(repository, prNumber, workspaceRoot);
|
|
138
138
|
await mkdir(dirname(worktree), { recursive: true });
|
|
139
139
|
try {
|
|
140
140
|
const existingRoot = await git(worktree, ['rev-parse', '--show-toplevel']);
|
|
141
141
|
const existingHead = await git(worktree, ['rev-parse', 'HEAD']);
|
|
142
|
-
if (
|
|
143
|
-
throw new Error(`
|
|
142
|
+
if (await realpath(existingRoot) !== await realpath(worktree))
|
|
143
|
+
throw new Error(`Worktree ownership mismatch: ${worktree}`);
|
|
144
|
+
if (existingHead !== head.sha) {
|
|
145
|
+
// Never wipe dirty files or commits unique to this Job checkout. A clean
|
|
146
|
+
// ancestor can safely advance to the remote PR head without reset --hard.
|
|
147
|
+
const dirty = await git(worktree, ['status', '--porcelain']);
|
|
148
|
+
if (dirty)
|
|
149
|
+
throw new Error(`Job worktree has changes while PR head advanced: ${worktree}`);
|
|
150
|
+
const ancestor = await gitOutput(['-C', worktree, 'merge-base', '--is-ancestor', existingHead, head.sha]).then(() => true, () => false);
|
|
151
|
+
if (!ancestor)
|
|
152
|
+
throw new Error(`Job worktree has non-fast-forward history; preserve commits: ${worktree}`);
|
|
153
|
+
await git(worktree, ['merge', '--ff-only', head.sha]);
|
|
154
|
+
}
|
|
144
155
|
return projectRelative ? join(worktree, projectRelative) : worktree;
|
|
145
156
|
}
|
|
146
157
|
catch (error) {
|
|
@@ -164,3 +175,67 @@ function repositoryPathRank(path, root) {
|
|
|
164
175
|
return 3;
|
|
165
176
|
return 1;
|
|
166
177
|
}
|
|
178
|
+
/** Stable checkout identity for the entire managed PR/Job lifecycle. */
|
|
179
|
+
export function managedJobWorktreePath(repository, prNumber, workspaceRoot = defaultWorkspaceRoot()) {
|
|
180
|
+
if (!/^[A-Za-z0-9_.-]+\/[A-Za-z0-9_.-]+$/.test(repository) || !Number.isSafeInteger(prNumber) || prNumber <= 0)
|
|
181
|
+
throw new Error('Invalid managed Job checkout identity');
|
|
182
|
+
return join(workspaceRoot, '.worktrees', 'agents-relay', `${repository.replace('/', '-')}-pr-${prNumber}`, 'job');
|
|
183
|
+
}
|
|
184
|
+
/**
|
|
185
|
+
* Graph-owned eligibility: called only after authoritative MERGED -> COMPLETED.
|
|
186
|
+
* This function only performs the mechanical, conservative cleanup effect.
|
|
187
|
+
*/
|
|
188
|
+
export async function cleanupCompletedJobWorktree(projectPath, job, workspaceRoot = defaultWorkspaceRoot()) {
|
|
189
|
+
if (job.state !== 'COMPLETED')
|
|
190
|
+
return { status: 'blocked', reason: 'Job is not completed' };
|
|
191
|
+
if (!job.headSha)
|
|
192
|
+
return { status: 'blocked', reason: 'No durable PR head SHA' };
|
|
193
|
+
if (job.tasks.some(task => task.executionId !== null || task.leaseOwner !== null))
|
|
194
|
+
return { status: 'blocked', reason: 'Task still owns an execution or lease' };
|
|
195
|
+
if ((job.plans ?? []).some(plan => plan.executionId !== null || plan.leaseOwner !== null))
|
|
196
|
+
return { status: 'blocked', reason: 'Planner still owns an execution or lease' };
|
|
197
|
+
const worktree = managedJobWorktreePath(job.repository, job.prNumber, workspaceRoot);
|
|
198
|
+
let metadata;
|
|
199
|
+
try {
|
|
200
|
+
metadata = await lstat(worktree);
|
|
201
|
+
}
|
|
202
|
+
catch (error) {
|
|
203
|
+
if (error.code === 'ENOENT')
|
|
204
|
+
return { status: 'absent' };
|
|
205
|
+
throw error;
|
|
206
|
+
}
|
|
207
|
+
if (!metadata.isDirectory() || metadata.isSymbolicLink())
|
|
208
|
+
return { status: 'blocked', reason: 'Worktree path is not an owned directory' };
|
|
209
|
+
const primary = await git(projectPath, ['rev-parse', '--show-toplevel']);
|
|
210
|
+
const origin = await git(primary, ['config', '--get', 'remote.origin.url']);
|
|
211
|
+
if (githubRepositoryFromRemote(origin) !== job.repository)
|
|
212
|
+
return { status: 'blocked', reason: 'Canonical repository identity mismatch' };
|
|
213
|
+
const registered = await git(primary, ['worktree', 'list', '--porcelain']);
|
|
214
|
+
const ownedRoot = await realpath(worktree);
|
|
215
|
+
const registeredPaths = registered.split('\n').filter(line => line.startsWith('worktree '));
|
|
216
|
+
const matching = await Promise.all(registeredPaths.map(async (line) => {
|
|
217
|
+
try {
|
|
218
|
+
return await realpath(line.slice('worktree '.length)) === ownedRoot;
|
|
219
|
+
}
|
|
220
|
+
catch {
|
|
221
|
+
return false;
|
|
222
|
+
}
|
|
223
|
+
}));
|
|
224
|
+
if (!matching.includes(true))
|
|
225
|
+
return { status: 'blocked', reason: 'Worktree is not registered with the canonical repository' };
|
|
226
|
+
const head = await git(worktree, ['rev-parse', 'HEAD']);
|
|
227
|
+
if (head !== job.headSha)
|
|
228
|
+
return { status: 'blocked', reason: 'Worktree contains unique or stale commits' };
|
|
229
|
+
if (await git(worktree, ['status', '--porcelain']))
|
|
230
|
+
return { status: 'blocked', reason: 'Worktree has uncommitted or untracked changes' };
|
|
231
|
+
await git(primary, ['worktree', 'remove', worktree]);
|
|
232
|
+
return { status: 'removed' };
|
|
233
|
+
}
|
|
234
|
+
/** Reviewer must never inspect local, uncommitted content as an immutable SHA. */
|
|
235
|
+
export async function assertCleanWorktreeAtHead(projectPath, headSha) {
|
|
236
|
+
const actual = await git(projectPath, ['rev-parse', 'HEAD']);
|
|
237
|
+
if (actual !== headSha)
|
|
238
|
+
throw new Error(`Review checkout has stale HEAD: ${actual}, expected ${headSha}`);
|
|
239
|
+
if (await git(projectPath, ['status', '--porcelain']))
|
|
240
|
+
throw new Error(`Review checkout has uncommitted changes: ${projectPath}`);
|
|
241
|
+
}
|
package/package.json
CHANGED
|
@@ -68,3 +68,9 @@ cancelled -->|on: pr.merged| cancelCancelledMerge
|
|
|
68
68
|
cancelCancelledMerge --> completed
|
|
69
69
|
cancelled -->|on: effect.cancelActiveTasks.failed| cancelled
|
|
70
70
|
completed -->|on: effect.cancelActiveTasks.failed| completed
|
|
71
|
+
|
|
72
|
+
%% Cleanup eligibility belongs to the lifecycle graph and is retryable after restart.
|
|
73
|
+
cleanupCompletedWorktree["action: effect.cleanupJobWorktree"]
|
|
74
|
+
completed -->|on: pr.merged| cleanupCompletedWorktree
|
|
75
|
+
cleanupCompletedWorktree --> completed
|
|
76
|
+
completed -->|on: effect.cleanupJobWorktree.failed| completed
|