agents-relay 2.0.80 → 2.0.82
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 +3 -3
- package/dist/cli-help.js +5 -8
- package/dist/cli-review.js +1 -1
- package/dist/cli-support.js +2 -22
- package/dist/cli-worker-task.js +7 -6
- package/dist/cli.js +22 -59
- package/dist/dashboard-actions.js +2 -1
- package/dist/dashboard-html.js +5 -5
- package/dist/dashboard.js +0 -4
- package/dist/github-binding-order.js +1 -1
- package/dist/job-pr-number-migration.js +24 -0
- package/dist/job-task-id-sequence.js +16 -9
- package/dist/legacy-orchestrator-repair.js +51 -0
- package/dist/local-job-registry.js +2 -3
- package/dist/local-merge-delivery.js +1 -1
- package/dist/managed-worker-input.js +3 -4
- package/dist/planner.js +14 -8
- package/dist/pool.js +3 -1
- package/dist/reconciler-review.js +7 -4
- package/dist/reconciler-runtime.js +7 -0
- package/dist/reconciler.js +2 -8
- package/dist/relay-store.js +6 -4
- package/dist/relayd-control.js +10 -0
- package/dist/relayd-dashboard-actions.js +3 -33
- package/dist/relayd.js +17 -20
- package/dist/review-generation-fence.js +8 -0
- package/dist/review-terminal-normalization.js +27 -0
- package/dist/reviewer-thread.js +1 -1
- package/dist/runtime-status.js +2 -1
- package/dist/task-actions.js +47 -42
- package/dist/task-title.js +5 -0
- package/dist/troubleshooter.js +2 -2
- package/dist/worker-routing.js +1 -1
- package/dist/workflow/entity-creation.js +11 -1
- package/dist/workflow/orchestrator-rescue.js +62 -18
- package/dist/workflow/troubleshooter-v2.js +1 -1
- package/dist/workspace.js +5 -4
- package/package.json +1 -1
- package/skills/agents-relay/contracts/orchestrator-workflows.md +13 -12
- package/workflow/orchestrator-rescue.mmd +16 -1
- package/workflow/troubleshooter.mmd +4 -4
package/dist/task-actions.js
CHANGED
|
@@ -7,8 +7,6 @@ export const TASK_UNBLOCK_RETRY_ACTION = 'task.unblock.retry';
|
|
|
7
7
|
export const TASK_CANCEL_ACTION = 'task.cancel';
|
|
8
8
|
export const TASK_RELEASE_ACTION = 'task.release';
|
|
9
9
|
export const TASK_HOLD_ACTION = 'task.hold';
|
|
10
|
-
export const TASK_START_ACTION = 'task.start';
|
|
11
|
-
export const TASK_COMPLETE_ACTION = 'task.complete';
|
|
12
10
|
export const TASK_QUEUE_ACTION = 'task.queue';
|
|
13
11
|
function lifecycleRejection(task, action) {
|
|
14
12
|
return new Error(`Task ${task.id} is not ${action} (${task.state})`);
|
|
@@ -100,42 +98,6 @@ export async function applyTaskQueueAction(job, task) {
|
|
|
100
98
|
clearExecution(task);
|
|
101
99
|
return task;
|
|
102
100
|
}
|
|
103
|
-
export async function applyOrchestratorStartAction(task) {
|
|
104
|
-
if (task.adapter !== 'orchestrator')
|
|
105
|
-
throw new Error('task start is only valid for adapter=orchestrator self-tasks');
|
|
106
|
-
const originalState = task.state;
|
|
107
|
-
try {
|
|
108
|
-
await transitionTaskEvent(task, 'task.started');
|
|
109
|
-
}
|
|
110
|
-
catch (error) {
|
|
111
|
-
if (error instanceof Error && error.message.startsWith('No enabled workflow transition')) {
|
|
112
|
-
throw new Error(`Task ${task.id} is ${originalState}; only QUEUED orchestrator tasks can be started`);
|
|
113
|
-
}
|
|
114
|
-
throw error;
|
|
115
|
-
}
|
|
116
|
-
task.attempt = Math.max(task.attempt, 1);
|
|
117
|
-
task.updatedAt = new Date().toISOString();
|
|
118
|
-
return task;
|
|
119
|
-
}
|
|
120
|
-
export async function applyOrchestratorCompleteAction(task, summary) {
|
|
121
|
-
if (task.adapter !== 'orchestrator')
|
|
122
|
-
throw new Error('task complete is only valid for adapter=orchestrator self-tasks');
|
|
123
|
-
const originalState = task.state;
|
|
124
|
-
if (task.state !== 'RUNNING')
|
|
125
|
-
throw new Error(`Task ${task.id} is ${originalState}; orchestrator task must be started before completion`);
|
|
126
|
-
task.result = { summary };
|
|
127
|
-
clearExecution(task);
|
|
128
|
-
try {
|
|
129
|
-
await transitionTaskEvent(task, 'task.completed');
|
|
130
|
-
}
|
|
131
|
-
catch (error) {
|
|
132
|
-
if (error instanceof Error && error.message.startsWith('No enabled workflow transition')) {
|
|
133
|
-
throw new Error(`Task ${task.id} is ${originalState}; only RUNNING orchestrator tasks can be completed`);
|
|
134
|
-
}
|
|
135
|
-
throw error;
|
|
136
|
-
}
|
|
137
|
-
return task;
|
|
138
|
-
}
|
|
139
101
|
async function applySelectedRelatedTaskMutations(job, mutations) {
|
|
140
102
|
const changed = [];
|
|
141
103
|
for (const mutation of mutations) {
|
|
@@ -148,6 +110,45 @@ async function applySelectedRelatedTaskMutations(job, mutations) {
|
|
|
148
110
|
}
|
|
149
111
|
return changed;
|
|
150
112
|
}
|
|
113
|
+
function markCancelledTaskSuperseded(task, reason) {
|
|
114
|
+
if (task.kind === 'planner')
|
|
115
|
+
throw new Error(`Legacy planner Task ${task.id} is read-only`);
|
|
116
|
+
if (task.state !== 'CANCELLED')
|
|
117
|
+
throw new Error(`Task ${task.id} must already be CANCELLED before supersede annotation`);
|
|
118
|
+
if (!reason.trim())
|
|
119
|
+
throw new Error('task supersede requires a reason');
|
|
120
|
+
const proposal = { path: 'cancel_supersede', targetTaskId: task.id, rootCause: reason.trim(), solution: 'Superseded by later corrected durable work' };
|
|
121
|
+
const decision = dispatchTaskResolutionSync(task, proposal, 'external', { cancelSupersede: () => {
|
|
122
|
+
task.resolution = { ...materializeResolution(task, proposal, 'external'), state: 'resolved' };
|
|
123
|
+
task.error = `Superseded ${reason.trim()}`;
|
|
124
|
+
task.updatedAt = new Date().toISOString();
|
|
125
|
+
} });
|
|
126
|
+
if (!decision.accepted)
|
|
127
|
+
throw new Error(`Task resolution graph rejected cancellation supersede for ${task.id}`);
|
|
128
|
+
}
|
|
129
|
+
export async function supersedeCancelledTask(store, jobId, taskId, reason) {
|
|
130
|
+
if (store.mutateJob) {
|
|
131
|
+
let updatedTask;
|
|
132
|
+
const updated = await store.mutateJob(jobId, current => {
|
|
133
|
+
const task = current.tasks.find(item => item.id === taskId);
|
|
134
|
+
if (!task)
|
|
135
|
+
throw new Error(`Task ${taskId} not found`);
|
|
136
|
+
markCancelledTaskSuperseded(task, reason);
|
|
137
|
+
updatedTask = structuredClone(task);
|
|
138
|
+
return current;
|
|
139
|
+
});
|
|
140
|
+
if (!updatedTask)
|
|
141
|
+
throw new Error(`Task ${taskId} was not updated`);
|
|
142
|
+
return { job: updated, task: updatedTask };
|
|
143
|
+
}
|
|
144
|
+
const job = await store.load(jobId);
|
|
145
|
+
const task = job.tasks.find(item => item.id === taskId);
|
|
146
|
+
if (!task)
|
|
147
|
+
throw new Error(`Task ${taskId} not found`);
|
|
148
|
+
markCancelledTaskSuperseded(task, reason);
|
|
149
|
+
await store.saveTask(task);
|
|
150
|
+
return { job: await store.load(jobId), task };
|
|
151
|
+
}
|
|
151
152
|
/** Compatibility bridge: domain mutation only; adapters own wake/reconcile/output behavior. */
|
|
152
153
|
export async function dispatchTaskAction(store, request) {
|
|
153
154
|
if (store.mutateJob && [TASK_RETRY_ACTION, TASK_RECOVER_ACTION, TASK_CANCEL_ACTION].includes(request.id)) {
|
|
@@ -162,8 +163,11 @@ export async function dispatchTaskAction(store, request) {
|
|
|
162
163
|
throw new Error(`task recover requires an active Troubleshooter recovery for ${task.id}`);
|
|
163
164
|
}
|
|
164
165
|
const relatedMutations = request.id === TASK_RECOVER_ACTION ? [] : selectRelatedTaskMutations(current, task, request.id);
|
|
165
|
-
if (request.id === TASK_RETRY_ACTION)
|
|
166
|
+
if (request.id === TASK_RETRY_ACTION) {
|
|
166
167
|
applyTaskRetrySnapshot(task, { extendBudget: true });
|
|
168
|
+
task.routing = undefined;
|
|
169
|
+
task.workerSelection = undefined;
|
|
170
|
+
}
|
|
167
171
|
else if (request.id === TASK_RECOVER_ACTION) {
|
|
168
172
|
const proposal = { path: 'exact_original_retry', targetTaskId: task.id, rootCause: task.failureReason ?? task.error ?? 'durable failure', solution: `Recover exact original Task via ${request.recoveryTaskId}` };
|
|
169
173
|
const decision = dispatchTaskResolutionSync(task, proposal, 'troubleshooter', { activateRecovery: () => { applyTaskRetrySnapshot(task, { extendBudget: true }); task.resolution = { ...materializeResolution(task, proposal, 'troubleshooter'), state: 'resolved' }; } });
|
|
@@ -190,8 +194,11 @@ export async function dispatchTaskAction(store, request) {
|
|
|
190
194
|
if (!task)
|
|
191
195
|
throw new Error(`Task ${request.taskId} not found`);
|
|
192
196
|
const relatedMutations = request.id === TASK_RECOVER_ACTION ? [] : selectRelatedTaskMutations(job, task, request.id);
|
|
193
|
-
if (request.id === TASK_RETRY_ACTION)
|
|
197
|
+
if (request.id === TASK_RETRY_ACTION) {
|
|
194
198
|
await applyTaskRetryAction(task, { extendBudget: true });
|
|
199
|
+
task.routing = undefined;
|
|
200
|
+
task.workerSelection = undefined;
|
|
201
|
+
}
|
|
195
202
|
else if (request.id === TASK_RECOVER_ACTION) {
|
|
196
203
|
const recovery = request.recoveryTaskId ? job.tasks.find(item => item.id === request.recoveryTaskId) : undefined;
|
|
197
204
|
if (!recovery || recovery.agentName !== 'troubleshooter' || recovery.parentTaskId !== task.id || !['QUEUED', 'RUNNING'].includes(recovery.state))
|
|
@@ -209,8 +216,6 @@ export async function dispatchTaskAction(store, request) {
|
|
|
209
216
|
await applyTaskReleaseAction(task);
|
|
210
217
|
else if (request.id === TASK_HOLD_ACTION)
|
|
211
218
|
await applyTaskHoldAction(task);
|
|
212
|
-
else if (request.id === TASK_START_ACTION)
|
|
213
|
-
await applyOrchestratorStartAction(task);
|
|
214
219
|
else if (request.id === TASK_QUEUE_ACTION)
|
|
215
220
|
await applyTaskQueueAction(job, task);
|
|
216
221
|
else
|
|
@@ -0,0 +1,5 @@
|
|
|
1
|
+
export function deriveTaskTitle(input, fallback = 'Task') {
|
|
2
|
+
const first = String(input ?? '').split(/\r?\n/).map(line => line.trim()).find(Boolean) ?? fallback;
|
|
3
|
+
const cleaned = first.replace(/^#{1,6}\s+/, '').replace(/^[-*+]\s+/, '').replace(/^\d+[.)]\s+/, '').replace(/\s+/g, ' ').trim() || fallback;
|
|
4
|
+
return cleaned.length <= 96 ? cleaned : cleaned.slice(0, 93).trimEnd() + '...';
|
|
5
|
+
}
|
package/dist/troubleshooter.js
CHANGED
|
@@ -51,10 +51,10 @@ export function pendingTroubleshooterTasks(job) {
|
|
|
51
51
|
const id = troubleshooterTaskId(task);
|
|
52
52
|
let create = false;
|
|
53
53
|
const decision = dispatchTroubleshooter({
|
|
54
|
-
|
|
54
|
+
failedWork: task.state === 'FAILED',
|
|
55
55
|
ordinaryWork: task.kind !== 'planner' && task.agentName !== 'troubleshooter',
|
|
56
56
|
currentRun: belongsToCurrentRun(job, task),
|
|
57
|
-
needsRecovery:
|
|
57
|
+
needsRecovery: true,
|
|
58
58
|
idUnused: !existing.has(id),
|
|
59
59
|
noRecoveryBarrier: !hasRecoveryBarrier(job, task),
|
|
60
60
|
}, () => { create = true; });
|
package/dist/worker-routing.js
CHANGED
|
@@ -48,7 +48,7 @@ export function usageForProvider(snapshot, provider, now = Date.now()) {
|
|
|
48
48
|
return {};
|
|
49
49
|
}
|
|
50
50
|
export function workerCandidates(adapters, registered, snapshot) {
|
|
51
|
-
return adapters.
|
|
51
|
+
return adapters.flatMap(adapter => {
|
|
52
52
|
const workers = registered.filter(worker => worker.runtime.adapter === adapter.name);
|
|
53
53
|
return (workers.length > 0 ? workers : [undefined]).map(worker => {
|
|
54
54
|
const provider = worker?.runtime.provider ?? (adapter.name === 'chatgpt' ? 'chatgpt' : undefined);
|
|
@@ -1,17 +1,27 @@
|
|
|
1
1
|
import { dispatchJobLifecycleSync, dispatchTaskLifecycleSync } from './lifecycle.js';
|
|
2
|
+
import { deriveTaskTitle } from '../task-title.js';
|
|
2
3
|
function dependenciesSatisfied(job, dependencies) {
|
|
3
4
|
if (dependencies.length === 0)
|
|
4
5
|
return true;
|
|
5
6
|
const byId = new Map(job.tasks.map(task => [task.id, task]));
|
|
6
7
|
return dependencies.every(id => byId.get(id)?.state === 'SUCCEEDED');
|
|
7
8
|
}
|
|
9
|
+
function validateTaskRequirements(value) {
|
|
10
|
+
if (value.adapter === 'orchestrator') {
|
|
11
|
+
throw new Error('New Tasks cannot use the Relay-internal orchestrator adapter; declare worker requirements and let Execution Router select the route');
|
|
12
|
+
}
|
|
13
|
+
if (value.workerRequirements && !Array.isArray(value.workerRequirements.capabilities)) {
|
|
14
|
+
throw new Error(`Task ${value.id} workerRequirements.capabilities must be an array`);
|
|
15
|
+
}
|
|
16
|
+
}
|
|
8
17
|
/**
|
|
9
18
|
* Canonical creation boundary for new Task entities.
|
|
10
19
|
* Constructors provide metadata/facts only; the Task lifecycle graph derives
|
|
11
20
|
* whether a fresh Task remains DRAFT or becomes QUEUED.
|
|
12
21
|
*/
|
|
13
22
|
export function createTaskEntity(job, value, options = {}) {
|
|
14
|
-
|
|
23
|
+
validateTaskRequirements(value);
|
|
24
|
+
const task = { ...value, title: value.title?.trim() || deriveTaskTitle(value.input, value.agentName ? `${value.agentName} task` : 'Task'), state: 'DRAFT' };
|
|
15
25
|
if (!options.defer) {
|
|
16
26
|
dispatchTaskLifecycleSync(task, 'reconcile', {
|
|
17
27
|
dependenciesSatisfied: dependenciesSatisfied(job, task.dependencies),
|
|
@@ -1,42 +1,86 @@
|
|
|
1
1
|
import { readFileSync } from 'node:fs';
|
|
2
2
|
import { dispatchWorkflowGraphSync } from './dispatcher.js';
|
|
3
3
|
import { GraphRuntime } from './graph-v2.js';
|
|
4
|
+
import { tryTransitionTaskEvent } from '../scheduler.js';
|
|
5
|
+
import { taskOverallTimeoutMs } from '../types.js';
|
|
4
6
|
export const STALE_ORCHESTRATOR_TASK_MS = 30 * 60 * 1000;
|
|
5
7
|
const source = readFileSync(new URL('../../workflow/orchestrator-rescue.mmd', import.meta.url), 'utf8');
|
|
6
8
|
const boolGuard = (name) => (facts) => facts.durableFacts[name] === true;
|
|
7
9
|
export const orchestratorRescueV2 = new GraphRuntime(source, {
|
|
8
|
-
states: new Set(['idle', 'check', 'skip', 'rescued']),
|
|
9
|
-
guards: {
|
|
10
|
-
|
|
10
|
+
states: new Set(['idle', 'check', 'external_completed', 'blocked', 'skip', 'rescued']),
|
|
11
|
+
guards: {
|
|
12
|
+
legacyActive: boolGuard('legacyActive'),
|
|
13
|
+
verifiedExternalCompletion: boolGuard('verifiedExternalCompletion'),
|
|
14
|
+
unknownStartedOutcome: boolGuard('unknownStartedOutcome'),
|
|
15
|
+
rescuable: boolGuard('rescuable'),
|
|
16
|
+
},
|
|
17
|
+
actions: {
|
|
18
|
+
'task.applyExternalCompletion': { id: 'task.applyExternalCompletion' },
|
|
19
|
+
'task.blockLegacyOrchestrator': { id: 'task.blockLegacyOrchestrator' },
|
|
20
|
+
'task.rescueOrchestrator': { id: 'task.rescueOrchestrator' },
|
|
21
|
+
},
|
|
11
22
|
events: { 'orchestrator.rescue.reconcile': { id: 'orchestrator.rescue.reconcile' } },
|
|
12
23
|
});
|
|
13
24
|
export function orchestratorRescueFacts(task, now) {
|
|
14
|
-
const
|
|
15
|
-
|
|
16
|
-
const
|
|
17
|
-
|
|
18
|
-
|
|
25
|
+
const legacyActive = task.kind !== 'planner' && task.adapter === 'orchestrator'
|
|
26
|
+
&& (task.state === 'QUEUED' || task.state === 'RUNNING');
|
|
27
|
+
const verifiedExternalCompletion = legacyActive && task.result !== null
|
|
28
|
+
&& task.result.data?.externalCompletionVerified === true;
|
|
29
|
+
return {
|
|
30
|
+
legacyActive,
|
|
31
|
+
verifiedExternalCompletion,
|
|
32
|
+
unknownStartedOutcome: legacyActive && task.state === 'RUNNING' && !verifiedExternalCompletion
|
|
33
|
+
&& (!task.leaseExpiresAt || Date.parse(task.leaseExpiresAt) <= now),
|
|
34
|
+
rescuable: legacyActive && task.state === 'QUEUED' && !verifiedExternalCompletion
|
|
35
|
+
&& (!task.leaseExpiresAt || Date.parse(task.leaseExpiresAt) <= now),
|
|
36
|
+
};
|
|
19
37
|
}
|
|
20
38
|
export function decideOrchestratorRescue(task, now) {
|
|
21
39
|
return dispatchWorkflowGraphSync(orchestratorRescueV2, {
|
|
22
40
|
initialState: 'idle', input: { transport: 'event', id: 'orchestrator.rescue.reconcile', payload: {} },
|
|
23
41
|
durableFacts: orchestratorRescueFacts(task, now), graphName: 'orchestrator-rescue',
|
|
24
|
-
isTransientState: state => state === 'check', maxSteps:
|
|
42
|
+
isTransientState: state => state === 'check', maxSteps: 4,
|
|
25
43
|
});
|
|
26
44
|
}
|
|
27
|
-
export function
|
|
45
|
+
export function prepareOrchestratorRepair(task, now) {
|
|
28
46
|
const decision = decideOrchestratorRescue(task, now);
|
|
29
|
-
const
|
|
47
|
+
const externallyCompleted = decision.accepted
|
|
48
|
+
&& decision.actions.some(action => action.id === 'task.applyExternalCompletion');
|
|
49
|
+
const blocked = decision.accepted
|
|
50
|
+
&& decision.actions.some(action => action.id === 'task.blockLegacyOrchestrator');
|
|
51
|
+
const rescued = externallyCompleted || blocked
|
|
52
|
+
|| (decision.accepted && decision.actions.some(action => action.id === 'task.rescueOrchestrator'));
|
|
30
53
|
const ageMs = now - Date.parse(task.createdAt);
|
|
54
|
+
if (decision.accepted && externallyCompleted) {
|
|
55
|
+
tryTransitionTaskEvent(task, 'task.completed');
|
|
56
|
+
task.leaseOwner = null;
|
|
57
|
+
task.leaseExpiresAt = null;
|
|
58
|
+
task.executionId = null;
|
|
59
|
+
task.threadId = null;
|
|
60
|
+
task.updatedAt = new Date(now).toISOString();
|
|
61
|
+
return { repaired: true, externallyCompleted: true, blocked: false, ageMs };
|
|
62
|
+
}
|
|
31
63
|
if (!rescued)
|
|
32
|
-
return {
|
|
33
|
-
|
|
34
|
-
|
|
35
|
-
|
|
36
|
-
|
|
64
|
+
return { repaired: false, externallyCompleted: false, blocked: false, ageMs };
|
|
65
|
+
if (blocked) {
|
|
66
|
+
tryTransitionTaskEvent(task, 'task.blocked');
|
|
67
|
+
task.error = 'Legacy orchestrator execution outcome is unknown; automatic replay is unsafe. Verify external effects and create replacement validation/recovery work before unblocking.';
|
|
68
|
+
task.leaseOwner = null;
|
|
69
|
+
task.leaseExpiresAt = null;
|
|
70
|
+
task.executionId = null;
|
|
71
|
+
task.threadId = null;
|
|
72
|
+
task.updatedAt = new Date(now).toISOString();
|
|
73
|
+
return { repaired: true, externallyCompleted: false, blocked: true, ageMs };
|
|
74
|
+
}
|
|
75
|
+
task.adapter = 'codex';
|
|
76
|
+
task.workerRequirements = { capabilities: [...task.capabilities] };
|
|
37
77
|
task.workerSelection = undefined;
|
|
38
78
|
task.routing = undefined;
|
|
39
|
-
task.
|
|
79
|
+
task.leaseOwner = null;
|
|
80
|
+
task.leaseExpiresAt = null;
|
|
81
|
+
task.executionId = null;
|
|
82
|
+
task.threadId = null;
|
|
83
|
+
task.timeoutMs = taskOverallTimeoutMs(task.storySize ?? 'M');
|
|
40
84
|
task.updatedAt = new Date(now).toISOString();
|
|
41
|
-
return {
|
|
85
|
+
return { repaired: true, externallyCompleted: false, blocked: false, ageMs };
|
|
42
86
|
}
|
|
@@ -6,7 +6,7 @@ const boolGuard = (name) => (facts) => facts.durableFacts[name] === true;
|
|
|
6
6
|
export const troubleshooterV2 = new GraphRuntime(source, {
|
|
7
7
|
states: new Set(['idle', 'check_ordinary', 'check_run', 'check_needs', 'check_id', 'check_barrier', 'skip', 'create']),
|
|
8
8
|
guards: {
|
|
9
|
-
|
|
9
|
+
failedWork: boolGuard('failedWork'),
|
|
10
10
|
ordinaryWork: boolGuard('ordinaryWork'),
|
|
11
11
|
currentRun: boolGuard('currentRun'),
|
|
12
12
|
needsRecovery: boolGuard('needsRecovery'),
|
package/dist/workspace.js
CHANGED
|
@@ -217,7 +217,7 @@ export async function ensureLocalJobSourceBranch(projectPath, job) {
|
|
|
217
217
|
return sourceRef;
|
|
218
218
|
}
|
|
219
219
|
export async function prepareLocalJobExecutionWorktree(projectPath, job, workspaceRoot = defaultWorkspaceRoot()) {
|
|
220
|
-
if (
|
|
220
|
+
if (!job.localMerge?.sourceRef)
|
|
221
221
|
return resolveRepositoryProjectPath(projectPath, job.projectPath);
|
|
222
222
|
const sourceRef = job.localMerge.sourceRef;
|
|
223
223
|
if (!/^refs\/heads\/[A-Za-z0-9][A-Za-z0-9._/-]*$/.test(sourceRef) || sourceRef.includes('..') || sourceRef.endsWith('.lock'))
|
|
@@ -285,14 +285,15 @@ export async function prepareLocalJobExecutionWorktree(projectPath, job, workspa
|
|
|
285
285
|
export async function cleanupCompletedJobWorktree(projectPath, job, workspaceRoot = defaultWorkspaceRoot()) {
|
|
286
286
|
if (job.state !== 'COMPLETED')
|
|
287
287
|
return { status: 'blocked', reason: 'Job is not completed' };
|
|
288
|
-
const
|
|
288
|
+
const isLocalJob = Boolean(job.localMerge?.sourceRef);
|
|
289
|
+
const expectedHead = isLocalJob ? (job.localMerge?.sourceSha ?? job.review?.headSha ?? job.headSha) : job.headSha;
|
|
289
290
|
if (!expectedHead)
|
|
290
291
|
return { status: 'blocked', reason: 'No durable Job head SHA' };
|
|
291
292
|
if (job.tasks.some(task => task.executionId !== null || task.leaseOwner !== null))
|
|
292
293
|
return { status: 'blocked', reason: 'Task still owns an execution or lease' };
|
|
293
294
|
if ((job.plans ?? []).some(plan => plan.executionId !== null || plan.leaseOwner !== null))
|
|
294
295
|
return { status: 'blocked', reason: 'Planner still owns an execution or lease' };
|
|
295
|
-
const worktree =
|
|
296
|
+
const worktree = isLocalJob ? managedLocalJobWorktreePath(job.repository, job.id, workspaceRoot) : managedJobWorktreePath(job.repository, job.prNumber, workspaceRoot);
|
|
296
297
|
let metadata;
|
|
297
298
|
try {
|
|
298
299
|
metadata = await lstat(worktree);
|
|
@@ -311,7 +312,7 @@ export async function cleanupCompletedJobWorktree(projectPath, job, workspaceRoo
|
|
|
311
312
|
}
|
|
312
313
|
catch { /* Local-only repositories may have no origin. */ }
|
|
313
314
|
const originRepository = origin ? githubRepositoryFromRemote(origin) : null;
|
|
314
|
-
if ((job.prNumber > 0 && originRepository !== job.repository) || (
|
|
315
|
+
if ((job.prNumber > 0 && !isLocalJob && originRepository !== job.repository) || (isLocalJob && originRepository && originRepository !== job.repository))
|
|
315
316
|
return { status: 'blocked', reason: 'Canonical repository identity mismatch' };
|
|
316
317
|
const registered = await git(primary, ['worktree', 'list', '--porcelain']);
|
|
317
318
|
const ownedRoot = await realpath(worktree);
|
package/package.json
CHANGED
|
@@ -24,18 +24,19 @@ The initial decomposition belongs to that durable Planner round. The orchestrato
|
|
|
24
24
|
|
|
25
25
|
## P0 / self job workflow
|
|
26
26
|
|
|
27
|
-
|
|
28
|
-
|
|
29
|
-
|
|
30
|
-
|
|
31
|
-
|
|
32
|
-
|
|
33
|
-
|
|
34
|
-
|
|
35
|
-
|
|
36
|
-
|
|
37
|
-
|
|
38
|
-
`job create`
|
|
27
|
+
P0/self work is ordinary durable Relay work. The orchestrator may choose normal
|
|
28
|
+
Task requirements, but every newly created work Task is executable by a
|
|
29
|
+
worker/model route selected by Execution Router. Relay has no privileged
|
|
30
|
+
orchestrator-worker lifecycle, and new Task creation rejects
|
|
31
|
+
`adapter: orchestrator`. P0/self jobs do not require the normal reviewer gate
|
|
32
|
+
unless the user explicitly asks for review; Relay-owned merge and completion
|
|
33
|
+
lifecycle remain in their canonical graphs.
|
|
34
|
+
|
|
35
|
+
Legacy recovery: existing `adapter: orchestrator` work Tasks may be repaired only
|
|
36
|
+
through the canonical rescue graph. The repair converts only never-started QUEUED records in place to declarative worker requirements and lets Execution Router select the route. A legacy RUNNING record with an unknown outcome is quarantined as BLOCKED instead of replayed; only explicit durable external-completion verification may complete it without execution. Process reclamation is attempted only after the record is proven eligible for this legacy path, so normal live workers are never touched by the migration scan.
|
|
37
|
+
|
|
38
|
+
`job create --initial-plan` remains the normal way to create durable planning
|
|
39
|
+
state and hand subsequent execution to Relay.
|
|
39
40
|
|
|
40
41
|
## Merge completion vs deployment evidence
|
|
41
42
|
|
|
@@ -2,12 +2,27 @@ flowchart LR
|
|
|
2
2
|
%% relay: initial idle
|
|
3
3
|
idle["state: idle"]
|
|
4
4
|
check["state: check"]
|
|
5
|
+
external["state: external_completed"]
|
|
6
|
+
blocked["state: blocked"]
|
|
5
7
|
skip["state: skip"]
|
|
6
8
|
rescued["state: rescued"]
|
|
9
|
+
legacyActive["guard: legacyActive"]
|
|
10
|
+
verifiedExternalCompletion["guard: verifiedExternalCompletion"]
|
|
11
|
+
unknownStartedOutcome["guard: unknownStartedOutcome"]
|
|
7
12
|
rescuable["guard: rescuable"]
|
|
8
13
|
rescue["action: task.rescueOrchestrator"]
|
|
14
|
+
externalCompletion["action: task.applyExternalCompletion"]
|
|
15
|
+
quarantine["action: task.blockLegacyOrchestrator"]
|
|
9
16
|
idle -->|on: orchestrator.rescue.reconcile| check
|
|
10
|
-
check -->|on: orchestrator.rescue.reconcile|
|
|
17
|
+
check -->|on: orchestrator.rescue.reconcile| legacyActive
|
|
18
|
+
legacyActive -->|false| skip
|
|
19
|
+
legacyActive -->|true| verifiedExternalCompletion
|
|
20
|
+
verifiedExternalCompletion -->|true| externalCompletion
|
|
21
|
+
externalCompletion -->|to: external_completed| external
|
|
22
|
+
verifiedExternalCompletion -->|false| unknownStartedOutcome
|
|
23
|
+
unknownStartedOutcome -->|true| quarantine
|
|
24
|
+
quarantine -->|to: blocked| blocked
|
|
25
|
+
unknownStartedOutcome -->|false| rescuable
|
|
11
26
|
rescuable -->|false| skip
|
|
12
27
|
rescuable -->|true| rescue
|
|
13
28
|
rescue -->|to: rescued| rescued
|
|
@@ -9,7 +9,7 @@ checkBarrier["state: check_barrier"]
|
|
|
9
9
|
skip["state: skip"]
|
|
10
10
|
create["state: create"]
|
|
11
11
|
|
|
12
|
-
|
|
12
|
+
failed["guard: failedWork"]
|
|
13
13
|
ordinary["guard: ordinaryWork"]
|
|
14
14
|
currentRun["guard: currentRun"]
|
|
15
15
|
needsRecovery["guard: needsRecovery"]
|
|
@@ -17,9 +17,9 @@ idUnused["guard: idUnused"]
|
|
|
17
17
|
noBarrier["guard: noRecoveryBarrier"]
|
|
18
18
|
createAction["action: troubleshooter.create"]
|
|
19
19
|
|
|
20
|
-
idle -->|on: troubleshooter.reconcile|
|
|
21
|
-
|
|
22
|
-
|
|
20
|
+
idle -->|on: troubleshooter.reconcile| failed
|
|
21
|
+
failed -->|false| skip
|
|
22
|
+
failed -->|true| checkOrdinary
|
|
23
23
|
|
|
24
24
|
checkOrdinary -->|on: troubleshooter.reconcile| ordinary
|
|
25
25
|
ordinary -->|false| skip
|