agents-relay 2.0.68 → 2.0.70
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/README.md +32 -1
- package/dist/adapters.js +39 -3
- package/dist/blockers.js +17 -4
- package/dist/cli-blocker.js +12 -28
- package/dist/cli-help.js +11 -8
- package/dist/cli-job-create.js +19 -13
- package/dist/cli-local-merge-config.js +4 -2
- package/dist/cli-projection.js +23 -55
- package/dist/cli-review.js +7 -24
- package/dist/cli-support.js +59 -4
- package/dist/cli-worker-task.js +63 -0
- package/dist/cli.js +179 -129
- package/dist/dashboard-html.js +15 -12
- package/dist/dashboard.js +26 -1
- package/dist/e2e-injection.js +76 -0
- 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 +31 -6
- package/dist/job-actions.js +29 -0
- package/dist/local-authority-store.js +10 -1
- package/dist/local-commit-events.js +2 -0
- package/dist/local-git-merge.js +17 -6
- package/dist/local-job-path.js +5 -0
- package/dist/local-job-registry.js +10 -4
- package/dist/local-managed-store.js +2 -4
- package/dist/local-merge-delivery.js +3 -2
- 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/planner-runtime.js +182 -41
- package/dist/planner.js +35 -2
- package/dist/pool.js +22 -6
- package/dist/reconciler-cancellation.js +21 -0
- package/dist/reconciler-review.js +74 -18
- package/dist/reconciler-runtime.js +69 -34
- package/dist/reconciler.js +92 -26
- package/dist/relay-store.js +1 -1
- 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 +220 -210
- package/dist/review-generation-fence.js +12 -0
- package/dist/runtime-status-events.js +21 -0
- package/dist/runtime-status.js +13 -0
- package/dist/service-publication.js +2 -4
- package/dist/task-actions.js +59 -1
- package/dist/troubleshooter.js +21 -2
- package/dist/unblock.js +47 -15
- package/dist/worker-rejection.js +11 -0
- package/dist/workflow-effect-failures.js +30 -0
- package/dist/workspace.js +95 -6
- package/package.json +5 -4
- package/skills/agents-relay/agents/planner.agent.md +3 -0
- package/skills/agents-relay/contracts/orchestrator-workflows.md +4 -7
- package/workflow/review-lifecycle.mmd +3 -0
|
@@ -1,9 +1,7 @@
|
|
|
1
|
-
import { homedir } from 'node:os';
|
|
2
|
-
import { join } from 'node:path';
|
|
3
1
|
import { eventFor } from './events.js';
|
|
4
|
-
import { SQLiteRelayStore, relayStorePath } from './relay-store.js';
|
|
2
|
+
import { SQLiteRelayStore, relayStoreDirectory, relayStorePath } from './relay-store.js';
|
|
5
3
|
/** Single-Job service uses the same durable publish facts as the repository daemon. */
|
|
6
|
-
export async function handleServicePublication(job, wake, bus, directory =
|
|
4
|
+
export async function handleServicePublication(job, wake, bus, directory = relayStoreDirectory()) {
|
|
7
5
|
if (!wake.publication && !wake.publishFailure)
|
|
8
6
|
return;
|
|
9
7
|
const sqlite = new SQLiteRelayStore(relayStorePath(directory, job.repository, job.prNumber));
|
package/dist/task-actions.js
CHANGED
|
@@ -1,6 +1,7 @@
|
|
|
1
1
|
import { extendRetryBudget, transitionTaskEvent, tryTransitionTaskEvent } from './scheduler.js';
|
|
2
2
|
import { selectRelatedTaskMutations } from './workflow/task-action-policy.js';
|
|
3
3
|
export const TASK_RETRY_ACTION = 'task.retry';
|
|
4
|
+
export const TASK_RECOVER_ACTION = 'task.recover';
|
|
4
5
|
export const TASK_UNBLOCK_RETRY_ACTION = 'task.unblock.retry';
|
|
5
6
|
export const TASK_CANCEL_ACTION = 'task.cancel';
|
|
6
7
|
export const TASK_RELEASE_ACTION = 'task.release';
|
|
@@ -28,6 +29,24 @@ function clearExecution(task) {
|
|
|
28
29
|
task.leaseExpiresAt = null;
|
|
29
30
|
task.executionId = null;
|
|
30
31
|
}
|
|
32
|
+
function applyTaskRetrySnapshot(task, policy = {}) {
|
|
33
|
+
if (task.kind === 'planner')
|
|
34
|
+
throw new Error(`Legacy planner Task ${task.id} is read-only; retry the Plan instead`);
|
|
35
|
+
const decision = tryTransitionTaskEvent(task, 'retry');
|
|
36
|
+
if (!decision.accepted)
|
|
37
|
+
throw lifecycleRejection(task, 'restartable');
|
|
38
|
+
if (policy.extendBudget)
|
|
39
|
+
task.maxAttempts = extendRetryBudget(task);
|
|
40
|
+
clearExecution(task);
|
|
41
|
+
}
|
|
42
|
+
/** Fixed-job authoritative unblock continuation, applied inside one Job snapshot mutation. */
|
|
43
|
+
export function applyTaskUnblockRetrySnapshot(task) {
|
|
44
|
+
if (!tryTransitionTaskEvent(task, 'retry').accepted)
|
|
45
|
+
throw lifecycleRejection(task, 'retryable after unblock');
|
|
46
|
+
task.maxAttempts = extendRetryBudget(task);
|
|
47
|
+
clearExecution(task);
|
|
48
|
+
delete task.blockedBy;
|
|
49
|
+
}
|
|
31
50
|
/** Shared task.retry mutation. The canonical lifecycle graph is the sole legality authority. */
|
|
32
51
|
export async function applyTaskRetryAction(task, policy = {}) {
|
|
33
52
|
if (task.kind === 'planner')
|
|
@@ -130,13 +149,48 @@ async function applySelectedRelatedTaskMutations(job, mutations) {
|
|
|
130
149
|
}
|
|
131
150
|
/** Compatibility bridge: domain mutation only; adapters own wake/reconcile/output behavior. */
|
|
132
151
|
export async function dispatchTaskAction(store, request) {
|
|
152
|
+
if (store.mutateJob && [TASK_RETRY_ACTION, TASK_RECOVER_ACTION, TASK_CANCEL_ACTION].includes(request.id)) {
|
|
153
|
+
let updatedTask;
|
|
154
|
+
const updated = await store.mutateJob(request.jobId, current => {
|
|
155
|
+
const task = current.tasks.find(item => item.id === request.taskId);
|
|
156
|
+
if (!task)
|
|
157
|
+
throw new Error(`Task ${request.taskId} not found`);
|
|
158
|
+
if (request.id === TASK_RECOVER_ACTION) {
|
|
159
|
+
const recovery = request.recoveryTaskId ? current.tasks.find(item => item.id === request.recoveryTaskId) : undefined;
|
|
160
|
+
if (!recovery || recovery.agentName !== 'troubleshooter' || recovery.parentTaskId !== task.id || !['QUEUED', 'RUNNING'].includes(recovery.state))
|
|
161
|
+
throw new Error(`task recover requires an active Troubleshooter recovery for ${task.id}`);
|
|
162
|
+
}
|
|
163
|
+
const relatedMutations = request.id === TASK_RECOVER_ACTION ? [] : selectRelatedTaskMutations(current, task, request.id);
|
|
164
|
+
if (request.id === TASK_RETRY_ACTION || request.id === TASK_RECOVER_ACTION)
|
|
165
|
+
applyTaskRetrySnapshot(task, { extendBudget: true });
|
|
166
|
+
else if (request.id === TASK_CANCEL_ACTION)
|
|
167
|
+
cancelTaskSnapshot(task);
|
|
168
|
+
for (const mutation of relatedMutations) {
|
|
169
|
+
const related = current.tasks.find(item => item.id === mutation.taskId);
|
|
170
|
+
if (!related)
|
|
171
|
+
throw new Error(`Related task ${mutation.taskId} not found`);
|
|
172
|
+
cancelTaskSnapshot(related, mutation.reason);
|
|
173
|
+
}
|
|
174
|
+
updatedTask = structuredClone(task);
|
|
175
|
+
return current;
|
|
176
|
+
});
|
|
177
|
+
if (!updatedTask)
|
|
178
|
+
throw new Error(`Task ${request.taskId} was not updated`);
|
|
179
|
+
return { job: updated, task: updatedTask };
|
|
180
|
+
}
|
|
133
181
|
const job = await store.load(request.jobId);
|
|
134
182
|
const task = job.tasks.find(item => item.id === request.taskId);
|
|
135
183
|
if (!task)
|
|
136
184
|
throw new Error(`Task ${request.taskId} not found`);
|
|
137
|
-
const relatedMutations = selectRelatedTaskMutations(job, task, request.id);
|
|
185
|
+
const relatedMutations = request.id === TASK_RECOVER_ACTION ? [] : selectRelatedTaskMutations(job, task, request.id);
|
|
138
186
|
if (request.id === TASK_RETRY_ACTION)
|
|
139
187
|
await applyTaskRetryAction(task, { extendBudget: true });
|
|
188
|
+
else if (request.id === TASK_RECOVER_ACTION) {
|
|
189
|
+
const recovery = request.recoveryTaskId ? job.tasks.find(item => item.id === request.recoveryTaskId) : undefined;
|
|
190
|
+
if (!recovery || recovery.agentName !== 'troubleshooter' || recovery.parentTaskId !== task.id || !['QUEUED', 'RUNNING'].includes(recovery.state))
|
|
191
|
+
throw new Error(`task recover requires an active Troubleshooter recovery for ${task.id}`);
|
|
192
|
+
await applyTaskRetryAction(task, { extendBudget: true });
|
|
193
|
+
}
|
|
140
194
|
else if (request.id === TASK_UNBLOCK_RETRY_ACTION)
|
|
141
195
|
await applyTaskUnblockRetryAction(task);
|
|
142
196
|
else if (request.id === TASK_CANCEL_ACTION)
|
|
@@ -157,3 +211,7 @@ export async function dispatchTaskAction(store, request) {
|
|
|
157
211
|
await store.saveTask(related);
|
|
158
212
|
return { job, task };
|
|
159
213
|
}
|
|
214
|
+
function cancelTaskSnapshot(task, reason = 'Cancelled by user') {
|
|
215
|
+
if (!tryApplyTaskCancelAction(task, reason))
|
|
216
|
+
throw lifecycleRejection(task, 'cancellable');
|
|
217
|
+
}
|
package/dist/troubleshooter.js
CHANGED
|
@@ -26,7 +26,7 @@ Retrieve this task's lifecycle event history before classifying the root cause o
|
|
|
26
26
|
GET http://127.0.0.1:8787/api/events?job=${encodeURIComponent(job.id)}&task=${encodeURIComponent(task.id)}&limit=200
|
|
27
27
|
Inspect event order, type/status, source, data/phase, duplicate events, missing expected lifecycle events, late events, and execution identifiers. If the event stream points to delivery/runtime failure, inspect the corresponding Relay/worker/tool logs too. Do not treat the final task.error string as the complete root cause.
|
|
28
28
|
|
|
29
|
-
Follow the selected Troubleshooter agent contract. Diagnose root cause from durable task state + event history + relevant runtime logs before retry. The final task.error string alone is never sufficient. Classify the evidence as model/provider/runtime, worker/tool/infrastructure, semantic task failure, or timeout/unknown. Only model/provider/runtime evidence may call the narrow model re-decision action; that action gathers fresh usage, reloads current policy, updates this exact Task's routing, and does not retry it. Troubleshooter must then reactivate/retry this exact original task and verify progress. Do not manually choose a provider/model or create a replacement task. If the blocker is a shared worker/runtime/tool defect, create and directly complete a P0/self-job in the owning repository, including required release/deploy verification. If diagnosis suggests a genuine human/external blocker, do not publish task.blocked directly and do not implement the result branch procedurally. Call agents-relay escalation with the original intent, blocker, and relevant context. Pass any caller-defined notification plan through --on-needs-user/--on-needs-user-file; do not hard-code a channel. The canonical escalation graph invokes Neo escalation and owns the resolved/failed/needs_user branch; follow its selected outcome. Retry this exact original task only after the blocker is removed, reconcile the original job, and verify durable progress.`;
|
|
29
|
+
Follow the selected Troubleshooter agent contract. Diagnose root cause from durable task state + event history + relevant runtime logs before retry. The final task.error string alone is never sufficient. Classify the evidence as model/provider/runtime, worker/tool/infrastructure, semantic task failure, or timeout/unknown. Only model/provider/runtime evidence may call the narrow model re-decision action; that action gathers fresh usage, reloads current policy, updates this exact Task's routing, and does not retry it. Troubleshooter must then reactivate/retry this exact original task and verify progress. Use the dedicated agents-relay task recover path with --task-id ${task.id} and --recovery-task-id ${troubleshooterTaskId(task)}; never use ordinary task retry from a Troubleshooter because manual retry intentionally supersedes active recovery. Do not manually choose a provider/model or create a replacement task. If the blocker is a shared worker/runtime/tool defect, create and directly complete a P0/self-job in the owning repository, including required release/deploy verification. If diagnosis suggests a genuine human/external blocker, do not publish task.blocked directly and do not implement the result branch procedurally. Call agents-relay escalation with the original intent, blocker, and relevant context. Pass any caller-defined notification plan through --on-needs-user/--on-needs-user-file; do not hard-code a channel. The canonical escalation graph invokes Neo escalation and owns the resolved/failed/needs_user branch; follow its selected outcome. Retry this exact original task only after the blocker is removed, reconcile the original job, and verify durable progress.`;
|
|
30
30
|
}
|
|
31
31
|
function belongsToCurrentRun(job, task) {
|
|
32
32
|
const slot = job.schedule?.currentRunSlot;
|
|
@@ -79,7 +79,7 @@ export function pendingTroubleshooterTasks(job) {
|
|
|
79
79
|
continuation: null,
|
|
80
80
|
continuationDeliveredAt: null,
|
|
81
81
|
attempt: 0,
|
|
82
|
-
maxAttempts:
|
|
82
|
+
maxAttempts: 3,
|
|
83
83
|
leaseOwner: null,
|
|
84
84
|
leaseExpiresAt: null,
|
|
85
85
|
executionId: null,
|
|
@@ -94,3 +94,22 @@ export function pendingTroubleshooterTasks(job) {
|
|
|
94
94
|
}
|
|
95
95
|
return recoveries;
|
|
96
96
|
}
|
|
97
|
+
export function exhaustedTroubleshooterRecoveries(job) {
|
|
98
|
+
const results = [];
|
|
99
|
+
for (const recovery of job.tasks) {
|
|
100
|
+
if (recovery.agentName !== 'troubleshooter' || recovery.state !== 'FAILED' || recovery.attempt < recovery.maxAttempts || !recovery.parentTaskId)
|
|
101
|
+
continue;
|
|
102
|
+
const original = job.tasks.find(task => task.id === recovery.parentTaskId);
|
|
103
|
+
if (!original || !['FAILED', 'BLOCKED'].includes(original.state))
|
|
104
|
+
continue;
|
|
105
|
+
const alreadyRecorded = job.recoveryFailures?.some(item => item.taskId === original.id && item.recoveryTaskId === recovery.id);
|
|
106
|
+
if (alreadyRecorded)
|
|
107
|
+
continue;
|
|
108
|
+
results.push({
|
|
109
|
+
original,
|
|
110
|
+
recovery,
|
|
111
|
+
reason: recovery.error ?? recovery.failureReason ?? `Troubleshooter ${recovery.id} ${recovery.state.toLowerCase()}`,
|
|
112
|
+
});
|
|
113
|
+
}
|
|
114
|
+
return results;
|
|
115
|
+
}
|
package/dist/unblock.js
CHANGED
|
@@ -1,5 +1,5 @@
|
|
|
1
|
-
import {
|
|
2
|
-
import {
|
|
1
|
+
import { applyTaskUnblockRetrySnapshot } from './task-actions.js';
|
|
2
|
+
import { tryTransitionTaskEvent } from './scheduler.js';
|
|
3
3
|
import { dispatchAuthoritativeUnblock } from './workflow/authoritative-unblock-v2.js';
|
|
4
4
|
export function isEffectivelyBlocked(task) { return task.kind !== 'planner' && task.state === 'BLOCKED'; }
|
|
5
5
|
function baselineFor(task) {
|
|
@@ -53,14 +53,29 @@ export async function unblockAutonomousTasks(options) {
|
|
|
53
53
|
const now = options.now ?? new Date().toISOString();
|
|
54
54
|
const currentHeadSha = await store.headSha?.() ?? job.headSha ?? null;
|
|
55
55
|
const facts = unblockFacts(job, tasks, currentHeadSha, trigger.reason);
|
|
56
|
-
|
|
57
|
-
const
|
|
58
|
-
|
|
59
|
-
|
|
60
|
-
|
|
61
|
-
|
|
62
|
-
|
|
63
|
-
|
|
56
|
+
const apply = (current) => {
|
|
57
|
+
for (const requested of tasks) {
|
|
58
|
+
const task = current.tasks.find(item => item.id === requested.id);
|
|
59
|
+
if (!task || !isEffectivelyBlocked(task))
|
|
60
|
+
throw new Error(`Task ${requested.id} changed before authoritative unblock`);
|
|
61
|
+
const baseline = baselineFor(task);
|
|
62
|
+
if (!tryTransitionTaskEvent(task, 'task.unblocked').accepted)
|
|
63
|
+
throw new Error(`Task ${task.id} cannot be authoritatively unblocked`);
|
|
64
|
+
task.blockedBaseline = baseline;
|
|
65
|
+
task.unblock = { at: now, source: trigger.source, reason: trigger.reason, baseline, currentHeadSha: currentHeadSha ?? undefined, facts, blockerJobId: trigger.blockerJobId, resolution: trigger.resolution };
|
|
66
|
+
task.updatedAt = now;
|
|
67
|
+
delete task.blockedBy;
|
|
68
|
+
}
|
|
69
|
+
current.updatedAt = now;
|
|
70
|
+
};
|
|
71
|
+
if (store.mutateJob) {
|
|
72
|
+
const committed = await store.mutateJob(job.id, current => { apply(current); return current; });
|
|
73
|
+
Object.assign(job, structuredClone(committed));
|
|
74
|
+
}
|
|
75
|
+
else {
|
|
76
|
+
apply(job);
|
|
77
|
+
for (const task of tasks)
|
|
78
|
+
await store.saveTask(task);
|
|
64
79
|
}
|
|
65
80
|
const planTrigger = unconsumedUnblockTrigger(job) ?? {
|
|
66
81
|
source: 'unblock', taskIds: tasks.map(task => task.id), reason: trigger.reason,
|
|
@@ -84,11 +99,28 @@ export async function unblockTask(options) {
|
|
|
84
99
|
options.task.blockedBaseline = baseline;
|
|
85
100
|
const facts = unblockFacts(options.job, [options.task], currentHeadSha, options.trigger.reason);
|
|
86
101
|
options.task.unblock = { at: now, source: options.trigger.source, reason: options.trigger.reason, baseline, currentHeadSha: currentHeadSha ?? undefined, facts, blockerJobId: options.trigger.blockerJobId, resolution: options.trigger.resolution };
|
|
87
|
-
|
|
88
|
-
|
|
89
|
-
|
|
90
|
-
|
|
91
|
-
|
|
102
|
+
const apply = (current) => {
|
|
103
|
+
const task = current.tasks.find(item => item.id === options.task.id);
|
|
104
|
+
if (!task || !isEffectivelyBlocked(task))
|
|
105
|
+
throw new Error(`Task ${options.task.id} changed before authoritative unblock`);
|
|
106
|
+
if (!tryTransitionTaskEvent(task, 'task.unblocked').accepted)
|
|
107
|
+
throw new Error(`Task ${task.id} cannot be authoritatively unblocked`);
|
|
108
|
+
const baseline = baselineFor(task);
|
|
109
|
+
task.blockedBaseline = baseline;
|
|
110
|
+
task.unblock = { at: now, source: options.trigger.source, reason: options.trigger.reason, baseline, currentHeadSha: currentHeadSha ?? undefined, facts, blockerJobId: options.trigger.blockerJobId, resolution: options.trigger.resolution };
|
|
111
|
+
applyTaskUnblockRetrySnapshot(task);
|
|
112
|
+
task.updatedAt = now;
|
|
113
|
+
delete task.blockedBy;
|
|
114
|
+
current.updatedAt = now;
|
|
115
|
+
};
|
|
116
|
+
if (options.store.mutateJob) {
|
|
117
|
+
const committed = await options.store.mutateJob(options.job.id, current => { apply(current); return current; });
|
|
118
|
+
Object.assign(options.job, structuredClone(committed));
|
|
119
|
+
}
|
|
120
|
+
else {
|
|
121
|
+
apply(options.job);
|
|
122
|
+
await options.store.saveTask(options.task);
|
|
123
|
+
}
|
|
92
124
|
result = { replanning: false };
|
|
93
125
|
return {};
|
|
94
126
|
}
|
|
@@ -0,0 +1,11 @@
|
|
|
1
|
+
/** Classify genuine worker-process rejection text, regardless of E2E mode. */
|
|
2
|
+
export function classifyWorkerRejection(message) {
|
|
3
|
+
if (/rate[_ .-]?limit[_ .-]?exceeded|usage limit reached|quota[_ .-]?exceeded|insufficient[_ .-]?quota/i.test(message))
|
|
4
|
+
return 'rate_limit_exceeded';
|
|
5
|
+
if (/model[_ .-]?not[_ .-]?found|model (?:is )?(?:unavailable|not found|unsupported)|unsupported model/i.test(message))
|
|
6
|
+
return 'model_not_found';
|
|
7
|
+
if (/provider[_ .-]?unavailable|provider (?:is )?(?:unavailable|refused)|service unavailable/i.test(message))
|
|
8
|
+
return 'provider_unavailable';
|
|
9
|
+
return 'worker_process_failed';
|
|
10
|
+
}
|
|
11
|
+
export const reroutableWorkerRejections = new Set(['rate_limit_exceeded', 'model_not_found', 'provider_unavailable']);
|
|
@@ -0,0 +1,30 @@
|
|
|
1
|
+
import { transitionJobEvent, transitionTaskEvent } from './scheduler.js';
|
|
2
|
+
import { workflowEffectFailureEvent } from './workflow/contracts.js';
|
|
3
|
+
import { eventFor } from './events.js';
|
|
4
|
+
export async function applyJobEffectOutcomes(store, emit, job, outcomes) {
|
|
5
|
+
for (const outcome of outcomes.filter(item => item.status === 'failed')) {
|
|
6
|
+
const eventName = workflowEffectFailureEvent(outcome.effect);
|
|
7
|
+
try {
|
|
8
|
+
await transitionJobEvent(job, eventName);
|
|
9
|
+
await store.saveJob(job);
|
|
10
|
+
}
|
|
11
|
+
catch {
|
|
12
|
+
// Some effects are observational for a given state; failure is still emitted below.
|
|
13
|
+
}
|
|
14
|
+
await emit(eventFor(job.id, 'job', null, 'workflow.effect.failed', 'failed', `${outcome.effect} failed: ${outcome.error ?? 'unknown error'}`, 'orchestrator', { effect: outcome.effect, idempotencyKey: outcome.key, workflowEvent: eventName }, { agent: 'relay', component: 'agents-relay' }));
|
|
15
|
+
}
|
|
16
|
+
}
|
|
17
|
+
export async function applyTaskEffectOutcomes(store, emit, job, task, outcomes) {
|
|
18
|
+
for (const outcome of outcomes.filter(item => item.status === 'failed')) {
|
|
19
|
+
const eventName = workflowEffectFailureEvent(outcome.effect);
|
|
20
|
+
try {
|
|
21
|
+
await transitionTaskEvent(task, eventName);
|
|
22
|
+
task.error = outcome.error ?? `${outcome.effect} failed`;
|
|
23
|
+
await store.saveTask(task);
|
|
24
|
+
}
|
|
25
|
+
catch {
|
|
26
|
+
// The effect failure remains observable even if this state has no feedback transition.
|
|
27
|
+
}
|
|
28
|
+
await emit(eventFor(job.id, task.id, task.parentTaskId, 'workflow.effect.failed', 'failed', `${outcome.effect} failed: ${outcome.error ?? 'unknown error'}`, 'orchestrator', { effect: outcome.effect, idempotencyKey: outcome.key, workflowEvent: eventName }, { agent: 'relay', component: 'agents-relay' }));
|
|
29
|
+
}
|
|
30
|
+
}
|
package/dist/workspace.js
CHANGED
|
@@ -1,6 +1,7 @@
|
|
|
1
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
|
+
import { createHash } from 'node:crypto';
|
|
4
5
|
import { spawnOwnedProcess } from './owned-process.js';
|
|
5
6
|
async function gitOutput(args) {
|
|
6
7
|
return await new Promise((resolve, reject) => {
|
|
@@ -181,6 +182,88 @@ export function managedJobWorktreePath(repository, prNumber, workspaceRoot = def
|
|
|
181
182
|
throw new Error('Invalid managed Job checkout identity');
|
|
182
183
|
return join(workspaceRoot, '.worktrees', 'agents-relay', `${repository.replace('/', '-')}-pr-${prNumber}`, 'job');
|
|
183
184
|
}
|
|
185
|
+
export function managedLocalJobWorktreePath(repository, jobId, workspaceRoot = defaultWorkspaceRoot()) {
|
|
186
|
+
if (!/^[A-Za-z0-9_.-]+\/[A-Za-z0-9_.-]+$/.test(repository) || !jobId.trim())
|
|
187
|
+
throw new Error('Invalid local Job checkout identity');
|
|
188
|
+
const safeJob = jobId.replace(/[^A-Za-z0-9_.-]+/g, '-').slice(0, 72) || 'job';
|
|
189
|
+
const digest = createHash('sha256').update(jobId).digest('hex').slice(0, 12);
|
|
190
|
+
return join(workspaceRoot, '.worktrees', 'agents-relay', `${repository.replace('/', '-')}-job-${safeJob}-${digest}`, 'job');
|
|
191
|
+
}
|
|
192
|
+
/** Bind an ordinary SQLite Job to one deterministic local work branch.
|
|
193
|
+
* The target is always main, and branch creation never switches the user's checkout.
|
|
194
|
+
* This is a Git-local operation and never needs GitHub credentials.
|
|
195
|
+
*/
|
|
196
|
+
export async function ensureLocalJobSourceBranch(projectPath, job) {
|
|
197
|
+
if (job.localMerge?.sourceRef) {
|
|
198
|
+
if (job.localMerge.targetRef !== 'refs/heads/main')
|
|
199
|
+
throw new Error('Local Job target must be main');
|
|
200
|
+
return job.localMerge.sourceRef;
|
|
201
|
+
}
|
|
202
|
+
const safe = job.id.replace(/[^A-Za-z0-9_.-]+/g, '-').slice(0, 48);
|
|
203
|
+
const digest = createHash('sha256').update(job.id).digest('hex').slice(0, 12);
|
|
204
|
+
const sourceRef = `refs/heads/relay/job-${safe}-${digest}`;
|
|
205
|
+
const primary = (await git(projectPath, ['rev-parse', '--show-toplevel'])).trim();
|
|
206
|
+
await git(primary, ['rev-parse', '--verify', 'refs/heads/main']);
|
|
207
|
+
const exists = await gitOutput(['-C', primary, 'show-ref', '--verify', '--quiet', sourceRef]).then(() => true, () => false);
|
|
208
|
+
if (!exists) {
|
|
209
|
+
// A concurrent caller may create the same ref; git update-ref is idempotent
|
|
210
|
+
// when the target already exists, and must never reset it.
|
|
211
|
+
const sha = (await git(primary, ['rev-parse', '--verify', 'refs/heads/main'])).trim();
|
|
212
|
+
await gitOutput(['-C', primary, 'update-ref', sourceRef, sha, '0000000000000000000000000000000000000000']).catch(async (error) => {
|
|
213
|
+
if (!(await gitOutput(['-C', primary, 'show-ref', '--verify', sourceRef]).then(() => true, () => false)))
|
|
214
|
+
throw error;
|
|
215
|
+
});
|
|
216
|
+
}
|
|
217
|
+
return sourceRef;
|
|
218
|
+
}
|
|
219
|
+
export async function prepareLocalJobExecutionWorktree(projectPath, job, workspaceRoot = defaultWorkspaceRoot()) {
|
|
220
|
+
if (job.prNumber !== 0 || !job.localMerge?.sourceRef)
|
|
221
|
+
return resolveRepositoryProjectPath(projectPath, job.projectPath);
|
|
222
|
+
const sourceRef = job.localMerge.sourceRef;
|
|
223
|
+
if (!/^refs\/heads\/[A-Za-z0-9][A-Za-z0-9._/-]*$/.test(sourceRef) || sourceRef.includes('..') || sourceRef.endsWith('.lock'))
|
|
224
|
+
throw new Error('Local Job execution requires a valid refs/heads sourceRef');
|
|
225
|
+
const primary = await git(projectPath, ['rev-parse', '--show-toplevel']);
|
|
226
|
+
const projectRelative = (await git(projectPath, ['rev-parse', '--show-prefix'])).replace(/\/$/, '');
|
|
227
|
+
let remote = '';
|
|
228
|
+
try {
|
|
229
|
+
remote = await git(primary, ['config', '--get', 'remote.origin.url']);
|
|
230
|
+
}
|
|
231
|
+
catch { /* Local-only repositories may have no origin. */ }
|
|
232
|
+
const remoteRepository = remote ? githubRepositoryFromRemote(remote) : null;
|
|
233
|
+
if (remoteRepository && remoteRepository !== job.repository)
|
|
234
|
+
throw new Error(`Primary checkout ${primary} does not match ${job.repository}`);
|
|
235
|
+
const sourceSha = await git(primary, ['rev-parse', '--verify', sourceRef]);
|
|
236
|
+
const worktree = managedLocalJobWorktreePath(job.repository, job.id, workspaceRoot);
|
|
237
|
+
await mkdir(dirname(worktree), { recursive: true });
|
|
238
|
+
try {
|
|
239
|
+
const existingRoot = await git(worktree, ['rev-parse', '--show-toplevel']);
|
|
240
|
+
if (await realpath(existingRoot) !== await realpath(worktree))
|
|
241
|
+
throw new Error(`Worktree ownership mismatch: ${worktree}`);
|
|
242
|
+
const branch = await git(worktree, ['symbolic-ref', 'HEAD']);
|
|
243
|
+
if (branch !== sourceRef)
|
|
244
|
+
throw new Error(`Local Job worktree is bound to ${branch}, expected ${sourceRef}`);
|
|
245
|
+
const existingHead = await git(worktree, ['rev-parse', 'HEAD']);
|
|
246
|
+
if (existingHead !== sourceSha) {
|
|
247
|
+
if (await git(worktree, ['status', '--porcelain']))
|
|
248
|
+
throw new Error(`Local Job worktree has changes while source ref advanced: ${worktree}`);
|
|
249
|
+
const ancestor = await gitOutput(['-C', worktree, 'merge-base', '--is-ancestor', existingHead, sourceSha]).then(() => true, () => false);
|
|
250
|
+
if (!ancestor)
|
|
251
|
+
throw new Error(`Local Job worktree has non-fast-forward history; preserve commits: ${worktree}`);
|
|
252
|
+
await git(worktree, ['merge', '--ff-only', sourceSha]);
|
|
253
|
+
}
|
|
254
|
+
return projectRelative ? join(worktree, projectRelative) : worktree;
|
|
255
|
+
}
|
|
256
|
+
catch (error) {
|
|
257
|
+
try {
|
|
258
|
+
await stat(worktree);
|
|
259
|
+
}
|
|
260
|
+
catch {
|
|
261
|
+
await git(primary, ['worktree', 'add', worktree, sourceRef.slice('refs/heads/'.length)]);
|
|
262
|
+
return projectRelative ? join(worktree, projectRelative) : worktree;
|
|
263
|
+
}
|
|
264
|
+
throw error;
|
|
265
|
+
}
|
|
266
|
+
}
|
|
184
267
|
/**
|
|
185
268
|
* Graph-owned eligibility: called only after authoritative MERGED -> COMPLETED.
|
|
186
269
|
* This function only performs the mechanical, conservative cleanup effect.
|
|
@@ -188,13 +271,14 @@ export function managedJobWorktreePath(repository, prNumber, workspaceRoot = def
|
|
|
188
271
|
export async function cleanupCompletedJobWorktree(projectPath, job, workspaceRoot = defaultWorkspaceRoot()) {
|
|
189
272
|
if (job.state !== 'COMPLETED')
|
|
190
273
|
return { status: 'blocked', reason: 'Job is not completed' };
|
|
191
|
-
|
|
192
|
-
|
|
274
|
+
const expectedHead = job.prNumber === 0 ? (job.localMerge?.sourceSha ?? job.review?.headSha ?? job.headSha) : job.headSha;
|
|
275
|
+
if (!expectedHead)
|
|
276
|
+
return { status: 'blocked', reason: 'No durable Job head SHA' };
|
|
193
277
|
if (job.tasks.some(task => task.executionId !== null || task.leaseOwner !== null))
|
|
194
278
|
return { status: 'blocked', reason: 'Task still owns an execution or lease' };
|
|
195
279
|
if ((job.plans ?? []).some(plan => plan.executionId !== null || plan.leaseOwner !== null))
|
|
196
280
|
return { status: 'blocked', reason: 'Planner still owns an execution or lease' };
|
|
197
|
-
const worktree = managedJobWorktreePath(job.repository, job.prNumber, workspaceRoot);
|
|
281
|
+
const worktree = job.prNumber === 0 ? managedLocalJobWorktreePath(job.repository, job.id, workspaceRoot) : managedJobWorktreePath(job.repository, job.prNumber, workspaceRoot);
|
|
198
282
|
let metadata;
|
|
199
283
|
try {
|
|
200
284
|
metadata = await lstat(worktree);
|
|
@@ -207,8 +291,13 @@ export async function cleanupCompletedJobWorktree(projectPath, job, workspaceRoo
|
|
|
207
291
|
if (!metadata.isDirectory() || metadata.isSymbolicLink())
|
|
208
292
|
return { status: 'blocked', reason: 'Worktree path is not an owned directory' };
|
|
209
293
|
const primary = await git(projectPath, ['rev-parse', '--show-toplevel']);
|
|
210
|
-
|
|
211
|
-
|
|
294
|
+
let origin = '';
|
|
295
|
+
try {
|
|
296
|
+
origin = await git(primary, ['config', '--get', 'remote.origin.url']);
|
|
297
|
+
}
|
|
298
|
+
catch { /* Local-only repositories may have no origin. */ }
|
|
299
|
+
const originRepository = origin ? githubRepositoryFromRemote(origin) : null;
|
|
300
|
+
if ((job.prNumber > 0 && originRepository !== job.repository) || (job.prNumber === 0 && originRepository && originRepository !== job.repository))
|
|
212
301
|
return { status: 'blocked', reason: 'Canonical repository identity mismatch' };
|
|
213
302
|
const registered = await git(primary, ['worktree', 'list', '--porcelain']);
|
|
214
303
|
const ownedRoot = await realpath(worktree);
|
|
@@ -224,7 +313,7 @@ export async function cleanupCompletedJobWorktree(projectPath, job, workspaceRoo
|
|
|
224
313
|
if (!matching.includes(true))
|
|
225
314
|
return { status: 'blocked', reason: 'Worktree is not registered with the canonical repository' };
|
|
226
315
|
const head = await git(worktree, ['rev-parse', 'HEAD']);
|
|
227
|
-
if (head !==
|
|
316
|
+
if (head !== expectedHead)
|
|
228
317
|
return { status: 'blocked', reason: 'Worktree contains unique or stale commits' };
|
|
229
318
|
if (await git(worktree, ['status', '--porcelain']))
|
|
230
319
|
return { status: 'blocked', reason: 'Worktree has uncommitted or untracked changes' };
|
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "agents-relay",
|
|
3
|
-
"version": "2.0.
|
|
3
|
+
"version": "2.0.70",
|
|
4
4
|
"description": "Durable async agent jobs coordinated through GitHub pull requests",
|
|
5
5
|
"type": "module",
|
|
6
6
|
"bin": {
|
|
@@ -12,9 +12,10 @@
|
|
|
12
12
|
"test": "npm run test:full",
|
|
13
13
|
"test:chatgpt-temp-quality": "AGENTS_RELAY_RUN_CHATGPT_TEMP_QUALITY=1 sh -c \"npm run build && node test/quality/run-temporary-chat.mjs\"",
|
|
14
14
|
"dev": "tsx src/cli.ts",
|
|
15
|
-
"test:publish": "npm run build && node --test --test-force-exit test/*.test.js",
|
|
16
|
-
"test:fast": "npm run build && node --test --test-concurrency=2 test/workflow-lifecycle.test.js test/workflow-v2-lifecycle-parity.test.js test/workflow-effects.test.js test/relay-store.test.js test/local-authority.test.js test/local-jobid-routing.test.js test/local-sqlite-jobid.test.js test/no-github.test.js test/scheduled-jobs.test.js test/reconcile-coordinator.test.js test/service-publication.test.js test/github-webhook.test.js",
|
|
17
|
-
"test:full": "npm run build && node --test --test-concurrency=1 test/*.test.js"
|
|
15
|
+
"test:publish": "npm run build && node --import ./test/test-store-env.mjs --test --test-concurrency=1 --test-force-exit test/*.test.js",
|
|
16
|
+
"test:fast": "npm run build && node --import ./test/test-store-env.mjs --test --test-concurrency=2 test/workflow-lifecycle.test.js test/workflow-v2-lifecycle-parity.test.js test/workflow-effects.test.js test/relay-store.test.js test/local-authority.test.js test/local-jobid-routing.test.js test/local-sqlite-jobid.test.js test/no-github.test.js test/scheduled-jobs.test.js test/reconcile-coordinator.test.js test/service-publication.test.js test/github-webhook.test.js",
|
|
17
|
+
"test:full": "npm run build && node --import ./test/test-store-env.mjs --test --test-concurrency=1 test/*.test.js",
|
|
18
|
+
"e2e": "node e2e/runner/run.mjs"
|
|
18
19
|
},
|
|
19
20
|
"devDependencies": {
|
|
20
21
|
"@types/node": "^22.10.2",
|
|
@@ -81,6 +81,7 @@ Do not create separate Tasks merely for "analyze", "format", "call tool", "check
|
|
|
81
81
|
- Treat durable Job/Task state as authoritative.
|
|
82
82
|
- Discover visible agents from the resolved execution context before selecting an agent. Choose an existing visible agent only when it is appropriate for the Task; otherwise leave `agentName` unset. Agent discovery is runtime/context-specific, not a hardcoded global list.
|
|
83
83
|
- Build `agentGraph` nodes only from agents in the discovered visible-agent catalog. Every named graph agent must be visible in the resolved planning/execution context.
|
|
84
|
+
- `agentGraph` is optional. For a single agent use `flowchart LR\n implementation[agent:implementation]` (replace with the actual visible agent); do **not** create an edge from an agent/node to itself. Every edge must connect distinct nodes, and all edges must form an acyclic directed graph. If no multi-agent orchestration is needed, omit `agentGraph` entirely.
|
|
84
85
|
- Never invent completed work or evidence.
|
|
85
86
|
- First decide whether the objective is already satisfied.
|
|
86
87
|
- If satisfied, return `objective_status: "satisfied"` and `next_tasks: []`.
|
|
@@ -98,6 +99,8 @@ Do not create separate Tasks merely for "analyze", "format", "call tool", "check
|
|
|
98
99
|
- Every child Task must declare exactly one output: `task_pr` or `file`.
|
|
99
100
|
- Every child Task must include `storySize` (`S`, `M`, or `L`); use `M` when uncertain.
|
|
100
101
|
- Do not create bookkeeping, waiting, status-checking, wrapper, or planning Tasks. Your response itself is the planning round.
|
|
102
|
+
- Relay owns lifecycle delivery for local-first Jobs. Never create a worker Task whose job is to perform the local Git merge, mark the Job `COMPLETED`, merge the projected GitHub PR, or wait/check for those Relay-owned effects. After implementation and required review are satisfied, return no lifecycle-completion Task; let the canonical Review/Local-Merge/Job Graph advance the Job.
|
|
103
|
+
- When all executable implementation work is complete and the only remaining steps are Relay-owned final review, local merge, Job completion, or projected PR convergence, return `objective_status: "satisfied"` with `next_tasks: []`. Do not return `in_progress` with an empty task list before final review; that would leave the Job with no executable progress path.
|
|
101
104
|
- Return no prose outside the JSON result.
|
|
102
105
|
|
|
103
106
|
## Output
|
|
@@ -8,22 +8,19 @@ This contract defines orchestrator-owned exception workflows that sit above norm
|
|
|
8
8
|
|
|
9
9
|
## Normal managed Job start workflow
|
|
10
10
|
|
|
11
|
-
For a normal managed Job,
|
|
11
|
+
For a normal managed Job, the preferred bootstrap is one command:
|
|
12
12
|
|
|
13
13
|
```text
|
|
14
|
-
job create
|
|
15
|
-
-> agents-relay plan --repo <owner/repo> --pr <number> --id <job_id>
|
|
14
|
+
job create --initial-plan
|
|
16
15
|
-> durable plan-N
|
|
17
16
|
-> Planner next_tasks
|
|
18
17
|
-> Relay materializes Tasks
|
|
19
18
|
-> normal routing, execution, and reconciliation
|
|
20
19
|
```
|
|
21
20
|
|
|
22
|
-
|
|
21
|
+
Without `--initial-plan`, `job create` remains create-only and establishes durable Job/PR state without execution. `agents-relay plan` remains available for an existing create-only/adopted Job. Unless the user explicitly requested **create-only / no execution**, the orchestrator should use `job create --initial-plan` rather than issuing two separate commands.
|
|
23
22
|
|
|
24
|
-
This
|
|
25
|
-
|
|
26
|
-
The explicit exception is the P0/self-job workflow below: a self job is synchronous orchestrator-owned execution, so it creates orchestrator-owned Tasks directly and terminalizes them in the same turn instead of invoking the normal initial Planner round.
|
|
23
|
+
The initial decomposition belongs to that durable Planner round. The orchestrator must not hand-author the initial child Task batch as a substitute. If initial planning fails, the create command fails visibly after preserving the durable Job/Plan evidence; the orchestrator must not silently invent Tasks. This applies regardless of whether the Job uses `fixed` or `autonomous` execution mode. Later replanning continues to follow Relay's normal Planner/reconciliation rules.
|
|
27
24
|
|
|
28
25
|
## P0 / self job workflow
|
|
29
26
|
|
|
@@ -39,6 +39,7 @@ cancelChanges["action: review.cancelStale"]
|
|
|
39
39
|
cancelBlocked["action: review.cancelStale"]
|
|
40
40
|
fixPending["action: review.createFix"]
|
|
41
41
|
fixStarted["action: review.createFix"]
|
|
42
|
+
fixChanges["action: review.createFix"]
|
|
42
43
|
mergePending["action: review.requestMerge"]
|
|
43
44
|
mergeStarted["action: review.requestMerge"]
|
|
44
45
|
|
|
@@ -105,6 +106,8 @@ autoMergeStarted -->|false| approved
|
|
|
105
106
|
started -->|on: review.changes_required| fixStarted
|
|
106
107
|
fixStarted --> changes
|
|
107
108
|
started -->|on: review.blocked| blocked
|
|
109
|
+
changes -->|on: review.changes_required| fixChanges
|
|
110
|
+
fixChanges --> changes
|
|
108
111
|
|
|
109
112
|
pending -->|on: head.changed| cancelPending
|
|
110
113
|
cancelPending --> stale
|