agents-relay 2.0.129 → 2.0.131
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/cli-help.js +3 -0
- package/dist/cli.js +27 -0
- package/dist/planner-runtime.js +11 -3
- package/dist/planner.js +1 -1
- package/dist/reconciler.js +17 -4
- package/dist/task-recovery.js +2 -2
- package/dist/troubleshooter-fallback.js +62 -0
- package/package.json +1 -1
- package/skills/agents-relay/agents/planner.agent.md +1 -1
package/dist/cli-help.js
CHANGED
|
@@ -12,6 +12,7 @@ Commands:
|
|
|
12
12
|
task create|update|show|list|complete|retry|unblock|cancel Manage durable tasks
|
|
13
13
|
review request Request graph-owned review (optional thread override)
|
|
14
14
|
plan Invoke the Planner for an existing managed Job
|
|
15
|
+
plan apply Submit a validated Task Tree within one active Plan
|
|
15
16
|
blocker add|resolve|show Manage cross-Job blocker dependencies
|
|
16
17
|
projection conflicts|resolve Inspect and repair deterministic GitHub projection conflicts
|
|
17
18
|
escalation Run the canonical escalation graph and Neo escalation executor
|
|
@@ -168,6 +169,8 @@ Required: --task-id ID and the normal Job storage options.`,
|
|
|
168
169
|
Required: --task-id ID, --reason TEXT, and the normal managed Job storage options. This does not change Task state and cannot supersede FAILED/BLOCKED/runnable work.`,
|
|
169
170
|
plan: `Invoke the Planner against the current durable Job state.
|
|
170
171
|
|
|
172
|
+
Agent submit: agents-relay plan apply --repo OWNER/REPO --pr PR_NUMBER --id JOB_ID --plan-id PLAN_ID --execution-id EXECUTION_ID --file plan.json. Validation errors reject without partial task writes and can be corrected inside the same Planner execution.
|
|
173
|
+
|
|
171
174
|
Required: --repo OWNER/REPO, --pr NUMBER, and --id JOB_ID.
|
|
172
175
|
Optional: --prompt TEXT or --prompt-file PATH|- (mutually exclusive). The prompt is additional guidance for this planning round and never replaces the durable Job objective.
|
|
173
176
|
|
package/dist/cli.js
CHANGED
|
@@ -2,6 +2,7 @@
|
|
|
2
2
|
import { configureLocalGitMerge } from './cli-local-merge-config.js';
|
|
3
3
|
import { noGitHubMode } from './no-github.js';
|
|
4
4
|
import { discoverLocalRelayStoreSnapshots, relayStoreDirectory } from './relay-store.js';
|
|
5
|
+
import { readFile } from 'node:fs/promises';
|
|
5
6
|
import { realpathSync } from 'node:fs';
|
|
6
7
|
import { once } from 'node:events';
|
|
7
8
|
import { fileURLToPath } from 'node:url';
|
|
@@ -673,6 +674,32 @@ async function main() {
|
|
|
673
674
|
console.log(helpText(command, command === 'job' || command === 'task' || command === 'context' || command === 'review' ? args[0] : undefined));
|
|
674
675
|
return;
|
|
675
676
|
}
|
|
677
|
+
if (command === 'plan' && args[0] === 'apply') {
|
|
678
|
+
const options = args.slice(1);
|
|
679
|
+
const jobId = arg(options, '--id');
|
|
680
|
+
const planId = arg(options, '--plan-id');
|
|
681
|
+
const executionId = arg(options, '--execution-id');
|
|
682
|
+
const path = arg(options, '--file');
|
|
683
|
+
if (!jobId || !planId || !executionId || !path)
|
|
684
|
+
throw new Error('plan apply requires --id --plan-id --execution-id --file');
|
|
685
|
+
const proposal = JSON.parse(path === '-' ? await new Promise((resolve, reject) => { let value = ''; process.stdin.setEncoding('utf8'); process.stdin.on('data', chunk => value += chunk); process.stdin.on('end', () => resolve(value)); process.stdin.on('error', reject); }) : await readFile(path, 'utf8'));
|
|
686
|
+
const storageArgs = options.filter((value, index) => value !== '--file' && options[index - 1] !== '--file');
|
|
687
|
+
const { store } = await storeFor(storageArgs);
|
|
688
|
+
await withEventBus(storageArgs, async (bus) => {
|
|
689
|
+
const reconciler = runtime(store, storageArgs, bus);
|
|
690
|
+
try {
|
|
691
|
+
console.log(JSON.stringify(await reconciler.applyPlannerTree(jobId, planId, executionId, proposal), null, 2));
|
|
692
|
+
}
|
|
693
|
+
catch (error) {
|
|
694
|
+
console.error(JSON.stringify({ ok: false, code: 'PLAN_APPLY_REJECTED', message: error instanceof Error ? error.message : String(error) }));
|
|
695
|
+
process.exitCode = 1;
|
|
696
|
+
}
|
|
697
|
+
finally {
|
|
698
|
+
await reconciler.close();
|
|
699
|
+
}
|
|
700
|
+
});
|
|
701
|
+
return;
|
|
702
|
+
}
|
|
676
703
|
if (command === 'job') {
|
|
677
704
|
const [action, ...jobArgs] = args;
|
|
678
705
|
await runJobCommand(action ?? '', jobArgs);
|
package/dist/planner-runtime.js
CHANGED
|
@@ -21,9 +21,11 @@ export function activateCommittedPlannerWork(job) {
|
|
|
21
21
|
export async function drainPlannerUnblocks(ports, jobId, planId) {
|
|
22
22
|
const job = await ports.store.load(jobId);
|
|
23
23
|
const plan = (job.plans ?? []).find(item => item.id === planId);
|
|
24
|
+
const pending = [...(plan?.pendingUnblocks ?? [])];
|
|
25
|
+
if (!pending.length)
|
|
26
|
+
return [];
|
|
24
27
|
if (!ports.store.registerBlockerDependent)
|
|
25
28
|
throw new Error("Blocker registration unavailable");
|
|
26
|
-
const pending = [...(plan?.pendingUnblocks ?? [])];
|
|
27
29
|
// Persist only acknowledgments observed by this invocation. Subtract from
|
|
28
30
|
// the latest transactional snapshot, never replace another drain's intents.
|
|
29
31
|
const acknowledged = new Set();
|
|
@@ -61,7 +63,7 @@ export async function retryPendingPlannerUnblocks(ports, jobId) {
|
|
|
61
63
|
await drainPlannerUnblocks(ports, jobId, plan.id);
|
|
62
64
|
}
|
|
63
65
|
}
|
|
64
|
-
async function finishPlanner(ports, jobId, planId, executionId, error, raw) {
|
|
66
|
+
export async function finishPlanner(ports, jobId, planId, executionId, error, raw, cliApply = false) {
|
|
65
67
|
const live = ports.live.get(planId);
|
|
66
68
|
if (live?.executionId === executionId)
|
|
67
69
|
clearTimeout(live.timer);
|
|
@@ -71,6 +73,8 @@ async function finishPlanner(ports, jobId, planId, executionId, error, raw) {
|
|
|
71
73
|
const plan = (job.plans ?? []).find(item => item.id === planId);
|
|
72
74
|
if (!plan || plan.status !== "PLANNING" || plan.executionId !== executionId) {
|
|
73
75
|
releaseLive();
|
|
76
|
+
if (cliApply)
|
|
77
|
+
throw new Error(`Plan ${planId} is not owned by this active execution`);
|
|
74
78
|
return;
|
|
75
79
|
}
|
|
76
80
|
const failOrRetry = async (message) => {
|
|
@@ -115,6 +119,8 @@ async function finishPlanner(ports, jobId, planId, executionId, error, raw) {
|
|
|
115
119
|
ports.detach(ports.reconcile(jobId), jobId, planId, 'planner retry reconciliation');
|
|
116
120
|
};
|
|
117
121
|
if (error) {
|
|
122
|
+
if (cliApply)
|
|
123
|
+
throw error;
|
|
118
124
|
await failOrRetry(error.message);
|
|
119
125
|
releaseLive();
|
|
120
126
|
return;
|
|
@@ -240,6 +246,8 @@ async function finishPlanner(ports, jobId, planId, executionId, error, raw) {
|
|
|
240
246
|
releaseLive();
|
|
241
247
|
throw caught;
|
|
242
248
|
}
|
|
249
|
+
if (cliApply)
|
|
250
|
+
throw caught;
|
|
243
251
|
await failOrRetry(caught instanceof Error ? caught.message : String(caught));
|
|
244
252
|
releaseLive();
|
|
245
253
|
return;
|
|
@@ -318,7 +326,7 @@ export async function launchPlanExecution(ports, job, plan) {
|
|
|
318
326
|
let promise;
|
|
319
327
|
try {
|
|
320
328
|
const executionContext = await ports.plannerContext(job, plan);
|
|
321
|
-
const context = { job, tasks: job.tasks, objective: job.objective ?? job.description ?? job.title, previousPlannerTaskId: plan.previousPlanId, trigger: plan.trigger, executionContext, retryFeedback: plan.retryFeedback, guidance: plan.guidance };
|
|
329
|
+
const context = { planExecution: { planId: plan.id, executionId }, job, tasks: job.tasks, objective: job.objective ?? job.description ?? job.title, previousPlannerTaskId: plan.previousPlanId, trigger: plan.trigger, executionContext, retryFeedback: plan.retryFeedback, guidance: plan.guidance };
|
|
322
330
|
promise = planner.plan(context, controller.signal);
|
|
323
331
|
}
|
|
324
332
|
catch (error) {
|
package/dist/planner.js
CHANGED
|
@@ -415,7 +415,7 @@ export function plannerInput(context) {
|
|
|
415
415
|
const project = context.executionContext?.project;
|
|
416
416
|
return JSON.stringify({
|
|
417
417
|
objective: context.objective,
|
|
418
|
-
retry_feedback: context.retryFeedback,
|
|
418
|
+
plan_execution: context.planExecution, retry_feedback: context.retryFeedback,
|
|
419
419
|
caller_prompt: context.guidance,
|
|
420
420
|
previous_planner_task_id: context.previousPlannerTaskId,
|
|
421
421
|
plan_trigger: context.trigger,
|
package/dist/reconciler.js
CHANGED
|
@@ -21,6 +21,7 @@ import { executionEventSource, withExecutionBoundary } from './reconciler-runtim
|
|
|
21
21
|
export { executionEventSource } from './reconciler-runtime.js';
|
|
22
22
|
import { CallbackWorkflowEffectHandler } from './workflow/effects.js';
|
|
23
23
|
import { exhaustedTroubleshooterRecoveries, pendingTroubleshooterTasks, pendingTroubleshooterReopens, refreshTroubleshooterInput } from './troubleshooter.js';
|
|
24
|
+
import { fallbackFailedTroubleshooters, escalateExhaustedTroubleshooters } from './troubleshooter-fallback.js';
|
|
24
25
|
import { taskUsesCanonicalProjectRun } from './workspace.js';
|
|
25
26
|
import { cleanupMergedJobWorktree } from './job-worktree-cleanup.js';
|
|
26
27
|
import { jobHasActiveExecution } from './job-execution-slot.js';
|
|
@@ -145,6 +146,10 @@ export class Reconciler extends ReconcilerReviewBase {
|
|
|
145
146
|
await this.store.saveTask(recovery);
|
|
146
147
|
await this.emit(eventFor(job.id, recovery.id, original.id, 'troubleshooter.retry_queued', 'queued', `Retrying failed Troubleshooter ${recovery.id} (attempt ${recovery.attempt + 1}/${recovery.maxAttempts})`, 'orchestrator', { originalTaskId: original.id, attempt: recovery.attempt + 1, maxAttempts: recovery.maxAttempts }));
|
|
147
148
|
}
|
|
149
|
+
for (const id of await fallbackFailedTroubleshooters(this.store, jobId))
|
|
150
|
+
await this.emit(eventFor(job.id, id, null, 'troubleshooter.retry_queued', 'queued', `Alternate model fallback scheduled for ${id}`, 'orchestrator'));
|
|
151
|
+
for (const id of await escalateExhaustedTroubleshooters(this.store, jobId))
|
|
152
|
+
await this.emit(eventFor(job.id, id, null, 'task.blocked', 'blocked', `Fallback exhausted: human action required for ${id}`, 'orchestrator'));
|
|
148
153
|
const exhaustedJob = await this.store.load(jobId);
|
|
149
154
|
for (const exhausted of exhaustedTroubleshooterRecoveries(exhaustedJob)) {
|
|
150
155
|
const at = new Date().toISOString();
|
|
@@ -529,15 +534,23 @@ export class Reconciler extends ReconcilerReviewBase {
|
|
|
529
534
|
? this.failLaunch(job.id, task.id, durableExecutionId, error.message, 'delivery')
|
|
530
535
|
: this.runtimeSettled(job.id, task.id, durableExecutionId, null, error instanceof Error ? error.message : String(error))), job.id, task.id, 'worker runtime completion');
|
|
531
536
|
}
|
|
537
|
+
async applyPlannerTree(jobId, planId, executionId, proposal) {
|
|
538
|
+
const { finishPlanner } = await import('./planner-runtime.js');
|
|
539
|
+
const { parsePlannerResult } = await import('./planner.js');
|
|
540
|
+
const ports = { store: this.store, options: this.options, live: this.plannerLive, plannerContext: (job, plan) => this.plannerContext(job, plan), emit: (event) => this.emit(event), detach: (promise, id, task, label) => this.detach(promise, id, task, label), reconcile: (id) => this.reconcile(id) };
|
|
541
|
+
await finishPlanner(ports, jobId, planId, executionId, null, parsePlannerResult(proposal), true);
|
|
542
|
+
const updated = (await this.store.load(jobId)).plans?.find(plan => plan.id === planId);
|
|
543
|
+
if (!updated || updated.status !== 'SUCCEEDED')
|
|
544
|
+
throw new Error(`Plan ${planId} was not committed`);
|
|
545
|
+
return updated;
|
|
546
|
+
}
|
|
532
547
|
async launchPlanner(job, plan) {
|
|
533
548
|
return launchPlanExecution({
|
|
534
549
|
store: this.store,
|
|
535
550
|
options: this.options,
|
|
536
551
|
live: this.plannerLive,
|
|
537
|
-
plannerContext: (currentJob, currentPlan) => this.plannerContext(currentJob, currentPlan),
|
|
538
|
-
|
|
539
|
-
detach: (promise, jobId, planId, context) => this.detach(promise, jobId, planId, context),
|
|
540
|
-
reconcile: jobId => this.reconcile(jobId),
|
|
552
|
+
plannerContext: (currentJob, currentPlan) => this.plannerContext(currentJob, currentPlan), emit: event => this.emit(event),
|
|
553
|
+
detach: (promise, jobId, planId, context) => this.detach(promise, jobId, planId, context), reconcile: jobId => this.reconcile(jobId),
|
|
541
554
|
}, job, plan);
|
|
542
555
|
}
|
|
543
556
|
}
|
package/dist/task-recovery.js
CHANGED
|
@@ -36,7 +36,7 @@ export async function requestTroubleshooterEscalation(store, jobId, recoveryTask
|
|
|
36
36
|
result = { task: structuredClone(recovery), escalation: structuredClone(recovery.escalation), duplicate: true };
|
|
37
37
|
return job;
|
|
38
38
|
}
|
|
39
|
-
if (!['QUEUED', 'RUNNING'].includes(recovery.state))
|
|
39
|
+
if (!['QUEUED', 'RUNNING', 'FAILED'].includes(recovery.state))
|
|
40
40
|
throw new Error(`Troubleshooter ${recoveryTaskId} cannot escalate from ${recovery.state}`);
|
|
41
41
|
const proposal = { path: 'escalation_gate', targetTaskId: original.id, rootCause: request.context.trim(), solution: request.requiredAction.trim() };
|
|
42
42
|
const decision = dispatchTaskResolutionSync(original, proposal, 'troubleshooter', {
|
|
@@ -44,7 +44,7 @@ export async function requestTroubleshooterEscalation(store, jobId, recoveryTask
|
|
|
44
44
|
});
|
|
45
45
|
if (!decision.accepted)
|
|
46
46
|
throw new Error(`Task resolution graph rejected escalation for ${original.id}`);
|
|
47
|
-
if (!tryTransitionTaskEvent(recovery, 'task.blocked').accepted)
|
|
47
|
+
if (!tryTransitionTaskEvent(recovery, 'task.blocked', { recoverableExecution: true }).accepted)
|
|
48
48
|
throw new Error(`Troubleshooter ${recoveryTaskId} cannot be blocked`);
|
|
49
49
|
const escalation = {
|
|
50
50
|
id: randomUUID(), originalTaskId: original.id, originalExecutionId: original.executionId ?? undefined,
|
|
@@ -0,0 +1,62 @@
|
|
|
1
|
+
import { tryTransitionTaskEvent } from './scheduler.js';
|
|
2
|
+
import { requestTroubleshooterEscalation } from './task-recovery.js';
|
|
3
|
+
const MARKER = '[Relay execution fallback attempted]';
|
|
4
|
+
const RUNTIME_FAILURE = /worker_process_failed|worker_start_timeout|terminal_event_timeout|model.*not supported|unsupported.model|delivery|process.exited|provider/i;
|
|
5
|
+
/** One bounded Codex-config-default retry after normal recovery attempts. */
|
|
6
|
+
export async function fallbackFailedTroubleshooters(store, jobId) {
|
|
7
|
+
const job = await store.load(jobId);
|
|
8
|
+
const queued = [];
|
|
9
|
+
for (const recovery of job.tasks.filter(task => task.agentName === 'troubleshooter' && task.state === 'FAILED' && task.attempt >= task.maxAttempts && !task.input.includes(MARKER))) {
|
|
10
|
+
const original = job.tasks.find(task => task.id === recovery.parentTaskId);
|
|
11
|
+
if (!original || original.state !== 'FAILED' || !RUNTIME_FAILURE.test(`${recovery.failureReason ?? ''} ${recovery.error ?? ''}`))
|
|
12
|
+
continue;
|
|
13
|
+
const update = (task) => {
|
|
14
|
+
if (task.state !== 'FAILED' || task.input.includes(MARKER))
|
|
15
|
+
return false;
|
|
16
|
+
if (!tryTransitionTaskEvent(task, 'retry').accepted)
|
|
17
|
+
throw new Error(`Fallback retry rejected for ${task.id}`);
|
|
18
|
+
task.maxAttempts = Math.max(task.maxAttempts, task.attempt + 1);
|
|
19
|
+
task.input += `\n\n${MARKER} Retry diagnosis with Codex config default model; report valid resolution or explicit blocker.`;
|
|
20
|
+
task.routing = { provider: '', model: '', decidedBy: 'troubleshooter-fallback:codex-config-default', decidedAt: new Date().toISOString() };
|
|
21
|
+
task.workerSelection = undefined;
|
|
22
|
+
task.leaseOwner = null;
|
|
23
|
+
task.leaseExpiresAt = null;
|
|
24
|
+
task.executionId = null;
|
|
25
|
+
return true;
|
|
26
|
+
};
|
|
27
|
+
if (store.mutateJob) {
|
|
28
|
+
await store.mutateJob(jobId, current => {
|
|
29
|
+
const task = current.tasks.find(item => item.id === recovery.id);
|
|
30
|
+
if (task && update(task))
|
|
31
|
+
queued.push(task.id);
|
|
32
|
+
return current;
|
|
33
|
+
});
|
|
34
|
+
}
|
|
35
|
+
else {
|
|
36
|
+
const task = (await store.load(jobId)).tasks.find(item => item.id === recovery.id);
|
|
37
|
+
if (task && update(task)) {
|
|
38
|
+
await store.saveTask(task);
|
|
39
|
+
queued.push(task.id);
|
|
40
|
+
}
|
|
41
|
+
}
|
|
42
|
+
}
|
|
43
|
+
return queued;
|
|
44
|
+
}
|
|
45
|
+
/** The Core, not the crashed Agent, creates a durable human action gate. */
|
|
46
|
+
export async function escalateExhaustedTroubleshooters(store, jobId) {
|
|
47
|
+
const job = await store.load(jobId);
|
|
48
|
+
const escalated = [];
|
|
49
|
+
for (const recovery of job.tasks.filter(task => task.agentName === 'troubleshooter' && task.state === 'FAILED' && task.attempt >= task.maxAttempts && !task.escalation)) {
|
|
50
|
+
const original = job.tasks.find(task => task.id === recovery.parentTaskId);
|
|
51
|
+
if (!original || original.state !== 'FAILED')
|
|
52
|
+
continue;
|
|
53
|
+
const context = `Automatic Troubleshooter ${recovery.id} could not produce a verified resolution after ${recovery.attempt} attempts. Last failure: ${recovery.error ?? recovery.failureReason ?? 'unknown'}. Original Task ${original.id}: ${original.error ?? original.failureReason ?? 'failed'}.`;
|
|
54
|
+
const result = await requestTroubleshooterEscalation(store, jobId, recovery.id, {
|
|
55
|
+
requiredAction: `Inspect Task ${original.id} and Troubleshooter ${recovery.id}; correct provider/runtime or supply a concrete recovery decision and supporting evidence.`,
|
|
56
|
+
resolutionCondition: `User supplies a corrective action or verified recovery instructions for Task ${original.id}.`, context,
|
|
57
|
+
});
|
|
58
|
+
if (!result.duplicate)
|
|
59
|
+
escalated.push(recovery.id);
|
|
60
|
+
}
|
|
61
|
+
return escalated;
|
|
62
|
+
}
|
package/package.json
CHANGED
|
@@ -4,7 +4,7 @@ You are the default objective planner for Agents Relay autonomous jobs.
|
|
|
4
4
|
|
|
5
5
|
Before producing tasks, read and follow `../contracts/task-job-contract.md`.
|
|
6
6
|
|
|
7
|
-
You are a
|
|
7
|
+
You are a planning agent. Relay invokes you with the durable Job, existing Tasks, previous planner rounds, and resolved project context. Your sole responsibility is to decompose the objective and create a valid Plan/Task Tree. When the invocation provides a live Plan ID and execution ID, write the proposed JSON to a temporary file and submit it with `agents-relay plan apply --repo OWNER/REPO --pr PR_NUMBER --id JOB_ID --plan-id PLAN_ID --execution-id EXECUTION_ID --file PATH`. If the CLI rejects it, correct the reported error and re-submit within the SAME Plan, up to three corrections. Do not create new Jobs, run Tasks, change statuses, or perform scheduling/recovery. On successful apply, return the same JSON as your final answer for compatibility; Relay detects the committed round and does not create duplicate Tasks. If no live execution identity is provided, return JSON and let Relay materialize it as before.
|
|
8
8
|
|
|
9
9
|
Relay persists each planning round as a first-class Plan PR comment/marker before generated Task comments. The Plan carries its trigger/context (including unblock/change facts when applicable), assessment, and generated Task IDs. Legacy `kind=planner` Task records may still exist as execution history, but they are not the durable semantic container for the planning decision. Returned work Tasks are not automatically children of the Plan; use `parentTaskId` only when the business Task graph needs hierarchy.
|
|
10
10
|
|