agents-relay 2.0.87 → 2.0.89
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/adapters.js +1 -1
- package/dist/planner-task-title.js +10 -0
- package/dist/planner.js +2 -1
- package/dist/reconciler.js +7 -1
- package/dist/troubleshooter.js +37 -7
- package/dist/workflow/contracts.js +1 -0
- package/dist/workflow/definitions-v2.js +1 -0
- package/package.json +1 -1
- package/skills/agents-relay/agents/planner.agent.md +1 -1
- package/workflow/task-lifecycle.mmd +14 -0
package/dist/adapters.js
CHANGED
|
@@ -274,7 +274,7 @@ async function findExistingReviewTurn(cli, sessionId, delivery, signal) {
|
|
|
274
274
|
verified_by: 'existing-user-turn', user_message_id: typeof observation.user_message_id === 'string' ? observation.user_message_id : null };
|
|
275
275
|
}
|
|
276
276
|
async function runBrowserWorker(cli, configFile, signal, action = 'temporary-submit', delivery) {
|
|
277
|
-
const session = parseWorkspaceJson(await browserWorkspaceCommand(cli, ['session', 'start'], undefined, signal));
|
|
277
|
+
const session = parseWorkspaceJson(await browserWorkspaceCommand(cli, ['session', 'start', '--workspace', CHATGPT_BROWSER_WORKSPACE], undefined, signal));
|
|
278
278
|
const sessionId = session.session_id;
|
|
279
279
|
if (typeof sessionId !== 'string' || !sessionId)
|
|
280
280
|
throw new Error('browser-workspace session start returned no session_id');
|
|
@@ -0,0 +1,10 @@
|
|
|
1
|
+
/** Validate freshly proposed planner titles; legacy persisted Plans are not re-parsed. */
|
|
2
|
+
export function requiredPlannerTaskTitle(value, index) {
|
|
3
|
+
if (typeof value !== 'string' || !value.trim())
|
|
4
|
+
throw new Error(`Planner next_tasks[${index}].title is required`);
|
|
5
|
+
const title = value.trim();
|
|
6
|
+
const words = title.split(/\s+/);
|
|
7
|
+
if (words.length < 5 || words.length > 8)
|
|
8
|
+
throw new Error(`Planner next_tasks[${index}].title must contain 5–8 words (got ${words.length})`);
|
|
9
|
+
return title;
|
|
10
|
+
}
|
package/dist/planner.js
CHANGED
|
@@ -11,6 +11,7 @@ import { dispatchPlannerTrigger } from './workflow/planner-trigger-v2.js';
|
|
|
11
11
|
import { createTaskEntity } from './workflow/entity-creation.js';
|
|
12
12
|
import { prepareReviewerThreadDelivery, chatgptThreadRequirements } from './reviewer-thread.js';
|
|
13
13
|
import { spawnOwnedProcess } from './owned-process.js';
|
|
14
|
+
import { requiredPlannerTaskTitle } from './planner-task-title.js';
|
|
14
15
|
export function semanticTaskTree(tasks) {
|
|
15
16
|
const work = tasks.filter(task => task.kind !== 'planner');
|
|
16
17
|
const ids = new Set(work.map(task => task.id));
|
|
@@ -309,7 +310,7 @@ export function parsePlannerResult(value) {
|
|
|
309
310
|
if (typeof timeoutMs !== 'number' || !Number.isFinite(timeoutMs) || timeoutMs < 0)
|
|
310
311
|
throw new Error(`Planner next_tasks[${index}].timeoutMs is invalid`);
|
|
311
312
|
return {
|
|
312
|
-
id, title:
|
|
313
|
+
id, title: requiredPlannerTaskTitle(task.title, index), parentTaskId, input: task.input, priority, storySize,
|
|
313
314
|
projectName: optionalString(task.projectName, 'projectName'), agentName: optionalString(task.agentName, 'agentName'), agentGraph, review,
|
|
314
315
|
dependencies: stringArray(task.dependencies, 'dependencies'), capabilities: stringArray(task.capabilities, 'capabilities'), workerRequirements: requirements,
|
|
315
316
|
testInjection: parseTestInjection(task.testInjection ?? task['--test-injection']) ?? optionalString(task.testInjection ?? task['--test-injection'], `next_tasks[${index}].--test-injection`),
|
package/dist/reconciler.js
CHANGED
|
@@ -18,7 +18,7 @@ import { executeManualPlan } from './manual-plan.js';
|
|
|
18
18
|
import { executionEventSource, withExecutionBoundary } from './reconciler-runtime.js';
|
|
19
19
|
export { executionEventSource } from './reconciler-runtime.js';
|
|
20
20
|
import { CallbackWorkflowEffectHandler } from './workflow/effects.js';
|
|
21
|
-
import { exhaustedTroubleshooterRecoveries, pendingTroubleshooterTasks } from './troubleshooter.js';
|
|
21
|
+
import { exhaustedTroubleshooterRecoveries, pendingTroubleshooterTasks, pendingTroubleshooterReopens, refreshTroubleshooterInput } from './troubleshooter.js';
|
|
22
22
|
import { taskUsesCanonicalProjectRun } from './workspace.js';
|
|
23
23
|
import { cleanupMergedJobWorktree } from './job-worktree-cleanup.js';
|
|
24
24
|
import { jobHasActiveExecution } from './job-execution-slot.js';
|
|
@@ -106,6 +106,12 @@ export class Reconciler extends ReconcilerReviewBase {
|
|
|
106
106
|
detach: (promise, currentJobId, planId, context) => this.detach(promise, currentJobId, planId, context),
|
|
107
107
|
reconcile: currentJobId => this.reconcile(currentJobId),
|
|
108
108
|
}, jobId);
|
|
109
|
+
for (const { original, recovery } of pendingTroubleshooterReopens(await this.store.load(jobId))) {
|
|
110
|
+
await transitionTaskEvent(recovery, 'recovery.reopen', { troubleshooterRecovery: recovery.agentName === 'troubleshooter' });
|
|
111
|
+
refreshTroubleshooterInput(job, original, recovery);
|
|
112
|
+
await this.store.saveTask(recovery);
|
|
113
|
+
await this.emit(eventFor(job.id, recovery.id, original.id, 'troubleshooter.reopened', 'queued', `Reusing Troubleshooter ${recovery.id} for Task ${original.id} attempt ${original.attempt}`, 'orchestrator', { originalTaskId: original.id, attempt: original.attempt }));
|
|
114
|
+
}
|
|
109
115
|
for (const recovery of pendingTroubleshooterTasks(await this.store.load(jobId))) {
|
|
110
116
|
await this.store.appendTask(recovery);
|
|
111
117
|
await this.emit(eventFor(job.id, recovery.id, null, "troubleshooter.created", "queued", "Troubleshooter task " + recovery.id + " created for failed/blocked work", "orchestrator", { agentName: "troubleshooter" }));
|
package/dist/troubleshooter.js
CHANGED
|
@@ -5,9 +5,9 @@ function slug(value) {
|
|
|
5
5
|
return value.replace(/[^A-Za-z0-9_-]+/g, '-').replace(/^-+|-+$/g, '').slice(0, 72) || 'task';
|
|
6
6
|
}
|
|
7
7
|
export function troubleshooterTaskId(task) {
|
|
8
|
-
return `troubleshoot-${slug(task.id)}
|
|
8
|
+
return `troubleshoot-${slug(task.id)}`;
|
|
9
9
|
}
|
|
10
|
-
function failureInput(job, task) {
|
|
10
|
+
function failureInput(job, task, recoveryId = troubleshooterTaskId(task)) {
|
|
11
11
|
return `Recover the failed/blocked task below and make it move again if technically safe.
|
|
12
12
|
|
|
13
13
|
Original repository: ${job.repository}
|
|
@@ -26,23 +26,51 @@ 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. Select exactly one graph-enabled resolution path: exact_original_retry, same_job_dependency, blocker_job_dependency (with merge or verified mode), escalation_gate, or cancel_supersede. 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 ${
|
|
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. Select exactly one graph-enabled resolution path: exact_original_retry, same_job_dependency, blocker_job_dependency (with merge or verified mode), escalation_gate, or cancel_supersede. 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 ${recoveryId}; 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; no replacement_task action exists. 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;
|
|
33
33
|
return !slot || task.createdAt >= slot;
|
|
34
34
|
}
|
|
35
|
-
const ACTIVE_RECOVERY_STATES = new Set(['DRAFT', 'QUEUED', 'RUNNING', 'BLOCKED']);
|
|
35
|
+
const ACTIVE_RECOVERY_STATES = new Set(['DRAFT', 'QUEUED', 'RUNNING', 'BLOCKED', 'UNBLOCKED']);
|
|
36
36
|
function recoveryTargetsTask(recovery, original) {
|
|
37
37
|
if (recovery.agentName !== 'troubleshooter')
|
|
38
38
|
return false;
|
|
39
39
|
if (recovery.parentTaskId)
|
|
40
40
|
return recovery.parentTaskId === original.id;
|
|
41
|
-
return recovery.id.startsWith(`troubleshoot-${slug(original.id)}-a`);
|
|
41
|
+
return recovery.id === troubleshooterTaskId(original) || recovery.id.startsWith(`troubleshoot-${slug(original.id)}-a`);
|
|
42
42
|
}
|
|
43
43
|
function hasRecoveryBarrier(job, original) {
|
|
44
|
-
|
|
45
|
-
|
|
44
|
+
// Exactly one durable recovery identity per original Task, across all failures.
|
|
45
|
+
return job.tasks.some(task => recoveryTargetsTask(task, original));
|
|
46
|
+
}
|
|
47
|
+
function recoveryEpisode(recovery) {
|
|
48
|
+
return recovery.recoveryForAttempt ?? Number(/-a(\d+)$/.exec(recovery.id)?.[1] ?? -1);
|
|
49
|
+
}
|
|
50
|
+
/** Reuse the existing recovery rather than creating one child per worker retry. */
|
|
51
|
+
export function pendingTroubleshooterReopens(job) {
|
|
52
|
+
const pending = [];
|
|
53
|
+
for (const original of job.tasks) {
|
|
54
|
+
if (original.kind === 'planner' || original.agentName === 'troubleshooter' || original.state !== 'FAILED')
|
|
55
|
+
continue;
|
|
56
|
+
if (!belongsToCurrentRun(job, original))
|
|
57
|
+
continue;
|
|
58
|
+
const recoveries = job.tasks.filter(candidate => recoveryTargetsTask(candidate, original));
|
|
59
|
+
if (!recoveries.length || recoveries.some(candidate => ACTIVE_RECOVERY_STATES.has(candidate.state)))
|
|
60
|
+
continue;
|
|
61
|
+
const recovery = recoveries.sort((a, b) => recoveryEpisode(b) - recoveryEpisode(a))[0];
|
|
62
|
+
if (!['SUCCEEDED', 'CANCELLED'].includes(recovery.state) || recoveryEpisode(recovery) >= original.attempt)
|
|
63
|
+
continue;
|
|
64
|
+
pending.push({ original, recovery });
|
|
65
|
+
}
|
|
66
|
+
return pending;
|
|
67
|
+
}
|
|
68
|
+
export function refreshTroubleshooterInput(job, original, recovery) {
|
|
69
|
+
recovery.input = failureInput(job, original, recovery.id);
|
|
70
|
+
recovery.recoveryForAttempt = original.attempt;
|
|
71
|
+
recovery.maxAttempts = Math.max(recovery.maxAttempts, recovery.attempt + 3);
|
|
72
|
+
recovery.routing = undefined;
|
|
73
|
+
recovery.workerSelection = undefined;
|
|
46
74
|
}
|
|
47
75
|
export function pendingTroubleshooterTasks(job) {
|
|
48
76
|
const existing = new Set(job.tasks.filter(task => task.agentName === 'troubleshooter').map(task => task.id));
|
|
@@ -64,6 +92,8 @@ export function pendingTroubleshooterTasks(job) {
|
|
|
64
92
|
recoveries.push(createTaskEntity(job, {
|
|
65
93
|
jobId: job.id,
|
|
66
94
|
id,
|
|
95
|
+
title: `Diagnose and recover failed task ${task.id}`,
|
|
96
|
+
recoveryForAttempt: task.attempt,
|
|
67
97
|
kind: 'work',
|
|
68
98
|
priority: 'P0',
|
|
69
99
|
storySize: 'M',
|
|
@@ -25,6 +25,7 @@ export const taskLifecycleV2 = load('task-lifecycle', TASK_STATES, {
|
|
|
25
25
|
runnable: boolGuard('runnable'),
|
|
26
26
|
dispatchable: boolGuard('dispatchable'),
|
|
27
27
|
recoverableExecution: boolGuard('recoverableExecution'),
|
|
28
|
+
troubleshooterRecovery: boolGuard('troubleshooterRecovery'),
|
|
28
29
|
retryAvailable: boolGuard('retryAvailable'),
|
|
29
30
|
});
|
|
30
31
|
export const jobLifecycleV2 = load('job-lifecycle', JOB_STATES, {
|
package/package.json
CHANGED
|
@@ -116,7 +116,7 @@ For `objective_status: "satisfied"`, `assessment` may be a concise evidence-base
|
|
|
116
116
|
|
|
117
117
|
Each item in `next_tasks` MUST include a concise, meaningful `title` of **5–8 words** (separate from the detailed `input`). Do not use the input text as a title.
|
|
118
118
|
|
|
119
|
-
Each item in next_tasks contains id, input, output, and storySize. `unblocks`, when present, is an array of `{repository, prNumber, jobId, taskId, resolutionMode?}` records identifying Tasks that must remain waiting until the current Job resolves their blocker. It may also contain parentTaskId, priority, projectName, agentName, dependencies, capabilities, workerRequirements, maxAttempts, and timeoutMs. Adapter/routing fields are selected by Relay after planning; do not invent them.
|
|
119
|
+
Each item in `next_tasks` contains **required `title`**, id, input, output, and storySize. The planner parser rejects missing titles and titles outside 5–8 whitespace-separated words. `unblocks`, when present, is an array of `{repository, prNumber, jobId, taskId, resolutionMode?}` records identifying Tasks that must remain waiting until the current Job resolves their blocker. It may also contain parentTaskId, priority, projectName, agentName, dependencies, capabilities, workerRequirements, maxAttempts, and timeoutMs. Adapter/routing fields are selected by Relay after planning; do not invent them.
|
|
120
120
|
|
|
121
121
|
### Exact `next_tasks` serialization
|
|
122
122
|
|
|
@@ -18,6 +18,11 @@ recoverComplete["guard: recoverableExecution"]
|
|
|
18
18
|
recoverBlocked["guard: recoverableExecution"]
|
|
19
19
|
recoverFailed["guard: recoverableExecution"]
|
|
20
20
|
recoverCancelled["guard: recoverableExecution"]
|
|
21
|
+
troubleshooterRecovery["guard: troubleshooterRecovery"]
|
|
22
|
+
recoveryReopenClearError["action: mutation.clearError"]
|
|
23
|
+
recoveryReopenClearReason["action: mutation.clearFailureReason"]
|
|
24
|
+
recoveryReopenClearLease["action: mutation.clearLease"]
|
|
25
|
+
recoveryReopenClearExecution["action: mutation.clearExecution"]
|
|
21
26
|
|
|
22
27
|
retryAvailableRunning["guard: retryAvailable"]
|
|
23
28
|
plannerRetryAvailable["guard: retryAvailable"]
|
|
@@ -200,3 +205,12 @@ recoverFailed -->|true| failed
|
|
|
200
205
|
failed -->|on: task.cancelled| recoverCancelled
|
|
201
206
|
recoverCancelled -->|true| cancelled
|
|
202
207
|
failed -->|on: cancel| cancelled
|
|
208
|
+
|
|
209
|
+
%% Only a persistent Troubleshooter may reopen a terminal recovery for a new failure episode.
|
|
210
|
+
succeeded -->|on: recovery.reopen| troubleshooterRecovery
|
|
211
|
+
cancelled -->|on: recovery.reopen| troubleshooterRecovery
|
|
212
|
+
troubleshooterRecovery -->|true| recoveryReopenClearError
|
|
213
|
+
recoveryReopenClearError --> recoveryReopenClearReason
|
|
214
|
+
recoveryReopenClearReason --> recoveryReopenClearLease
|
|
215
|
+
recoveryReopenClearLease --> recoveryReopenClearExecution
|
|
216
|
+
recoveryReopenClearExecution --> queued
|