agents-relay 2.0.157 → 2.0.159
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 +4 -5
- package/dist/cli-worker-task.js +11 -0
- package/dist/cli.js +24 -16
- package/dist/dashboard-html.js +1 -1
- package/dist/manual-plan.js +9 -1
- package/dist/planner-history-cleanup.js +33 -0
- package/dist/planner-recovery.js +1 -1
- package/dist/planner-runtime.js +29 -2
- package/dist/planner-task-context.js +26 -0
- package/dist/planner.js +24 -37
- package/dist/reconciler-review.js +4 -3
- package/dist/reconciler.js +3 -3
- package/dist/relayd-control.js +11 -2
- package/dist/single-job-task-control.js +1 -1
- package/dist/task-actions.js +19 -0
- package/dist/troubleshooter.js +1 -1
- package/dist/workflow/task-resolution.js +32 -3
- package/package.json +3 -3
- package/skills/agents-relay/agents/planner.agent.md +9 -3
- package/skills/agents-relay/contracts/task-job-contract.md +12 -0
- package/workflow/task-resolution.mmd +15 -0
package/dist/cli-help.js
CHANGED
|
@@ -9,7 +9,7 @@ Usage:
|
|
|
9
9
|
|
|
10
10
|
Commands:
|
|
11
11
|
job create|update|show|list|merge|unblock|cancel|pause|resume Manage SQLite-authoritative jobs with optional GitHub PRs
|
|
12
|
-
task create|update|show|list|complete|retry|unblock|cancel Manage durable tasks
|
|
12
|
+
task create|update|show|list|classify|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
15
|
plan apply Submit a validated Task Tree within one active Plan
|
|
@@ -166,15 +166,14 @@ Optional: --failure-kind adapter_failure|model_failure|provider_failure|task_fai
|
|
|
166
166
|
'task cancel': `Cancel one durable Task.
|
|
167
167
|
|
|
168
168
|
Required: --task-id ID and the normal Job storage options.`,
|
|
169
|
-
'task supersede': `Mark an already-CANCELLED durable Task as superseded by later corrected work through the Task Resolution Graph.
|
|
170
|
-
|
|
171
|
-
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
|
+
'task supersede': `Mark an already-CANCELLED durable Task as superseded by later corrected work through the Task Resolution Graph.\n\nRequired: --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.`,
|
|
170
|
+
'task classify': `Classify a FAILED work Task as superseded or invalid without changing its Task lifecycle.\n\nRequired: --task-id ID --classification superseded|invalid --target-task-id ID --evidence TEXT --reason TEXT and the normal Job storage options. Unknown or active execution outcomes are rejected.`,
|
|
172
171
|
plan: `Invoke the Planner against the current durable Job state.
|
|
173
172
|
|
|
174
173
|
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.
|
|
175
174
|
|
|
176
175
|
Required: --repo OWNER/REPO, --pr NUMBER, and --id JOB_ID.
|
|
177
|
-
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.
|
|
176
|
+
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. --force-clean-history asks Planner to inspect authoritative failed Tasks, decide whether evidence supports superseded/invalid classification or cancellation of a failed review, and apply selected changes through guarded task commands before submitting the Plan. Tasks without sufficient evidence remain unresolved. The durable Plan records the cleanup assessment, and plan apply remains the required receipt. Text responses are never applied as a Plan; unknown execution outcomes are not classified without proof.
|
|
178
177
|
|
|
179
178
|
The command persists one real plan-N round, materializes validated next_tasks with planner provenance, and wakes normal reconciliation. It works for zero-task DRAFT Jobs. Historical planner-N rounds remain readable, but new rounds use plan-N.
|
|
180
179
|
|
package/dist/cli-worker-task.js
CHANGED
|
@@ -2,6 +2,17 @@ import { taskOverallTimeoutMs } from './types.js';
|
|
|
2
2
|
import { arg, hasArg, textOption, integerArg, chatGptTabClosePolicy, storySize, priority, continuation } from './cli-support.js';
|
|
3
3
|
import { parseAgentGraph } from './agent-graph.js';
|
|
4
4
|
import { createTaskEntity } from './workflow/entity-creation.js';
|
|
5
|
+
export function recoveryDiagnosisFromArgs(args) {
|
|
6
|
+
const failureKind = arg(args, '--failure-kind');
|
|
7
|
+
if (!failureKind)
|
|
8
|
+
return undefined;
|
|
9
|
+
const reason = arg(args, '--reason');
|
|
10
|
+
if (!reason.trim())
|
|
11
|
+
throw new Error('--failure-kind requires --reason');
|
|
12
|
+
if (!['adapter_failure', 'model_failure', 'provider_failure', 'task_failure', 'unknown'].includes(failureKind))
|
|
13
|
+
throw new Error('Invalid --failure-kind');
|
|
14
|
+
return { failureKind, reason, evidence: arg(args, '--evidence') || undefined, reroute: hasArg(args, '--reroute'), excludeCurrentRoute: hasArg(args, '--exclude-current-route') };
|
|
15
|
+
}
|
|
5
16
|
export async function workerTaskFromArgs(job, args, now) {
|
|
6
17
|
const model = arg(args, '--model');
|
|
7
18
|
const adapterValue = arg(args, '--adapter');
|
package/dist/cli.js
CHANGED
|
@@ -23,6 +23,7 @@ import { discoverAgents } from './registry.js';
|
|
|
23
23
|
import { AgentObjectivePlanner, CommandObjectivePlanner } from './planner.js';
|
|
24
24
|
import { applyTaskHoldAction, applyTaskQueueAction, dispatchTaskAction } from './task-actions.js';
|
|
25
25
|
import { applyJobActivateAction, applyJobCancelAction } from './job-actions.js';
|
|
26
|
+
import { classifyFailedTask } from './task-actions.js';
|
|
26
27
|
import { resolveExecutionContext } from './execution-context.js';
|
|
27
28
|
import { startServiceExternalActionFlusher } from './service-external-actions.js';
|
|
28
29
|
import { handleServicePublication } from './service-publication.js';
|
|
@@ -42,7 +43,7 @@ import { normalizeThreadId, runtimeReviewer } from './reviewer-thread.js';
|
|
|
42
43
|
export { runtimeReviewer } from './reviewer-thread.js';
|
|
43
44
|
import { SERVICE_DEFAULTS, ServiceEventBus, arg, hasArg, textOption, integerArg, repository, storeFor, saveLocal, eventBus, publishWake, priority, storySize, executionMode, chatGptTabClosePolicy, requestedExecutionMode, continuation, scheduleFromArgs, jobTagFromArgs, requestManagedReconciliation, relayControlJob, relayControlCommand, relayJobList, relayJobStateCommand, relayTaskCommand, withEventBus } from './cli-support.js';
|
|
44
45
|
export { SERVICE_DEFAULTS, eventBus, executionMode, chatGptTabClosePolicy } from './cli-support.js';
|
|
45
|
-
import { workerTaskFromArgs } from './cli-worker-task.js';
|
|
46
|
+
import { recoveryDiagnosisFromArgs, workerTaskFromArgs } from './cli-worker-task.js';
|
|
46
47
|
import { taskExecutionContext } from './task-context.js';
|
|
47
48
|
import { requestTroubleshooterEscalation, resumeTroubleshooter } from './task-recovery.js';
|
|
48
49
|
import { pauseJob, resumeJob } from './job-pause.js';
|
|
@@ -72,20 +73,8 @@ async function runManagedTaskCommand(action, args) {
|
|
|
72
73
|
throw new Error(`Task ${id} missing after ${action}`);
|
|
73
74
|
console.log(JSON.stringify(task, null, 2));
|
|
74
75
|
}
|
|
75
|
-
function recoveryDiagnosisFromArgs(args) {
|
|
76
|
-
const failureKind = arg(args, '--failure-kind');
|
|
77
|
-
if (!failureKind)
|
|
78
|
-
return undefined;
|
|
79
|
-
const reason = arg(args, '--reason');
|
|
80
|
-
if (!reason.trim())
|
|
81
|
-
throw new Error('--failure-kind requires --reason');
|
|
82
|
-
if (!['adapter_failure', 'model_failure', 'provider_failure', 'task_failure', 'unknown'].includes(failureKind))
|
|
83
|
-
throw new Error('Invalid --failure-kind');
|
|
84
|
-
const reroute = args.includes('--reroute');
|
|
85
|
-
return { failureKind, reason, evidence: arg(args, '--evidence') || undefined, reroute, excludeCurrentRoute: args.includes('--exclude-current-route') };
|
|
86
|
-
}
|
|
87
76
|
async function runTaskCommand(action, args) {
|
|
88
|
-
if (!['create', 'update', 'show', 'context', 'comment', 'comments', 'list', 'retry', 'recover', 'unblock', 'escalate', 'resume-troubleshooter', 'cancel', 'supersede'].includes(action))
|
|
77
|
+
if (!['create', 'update', 'show', 'context', 'comment', 'comments', 'list', 'retry', 'recover', 'unblock', 'escalate', 'resume-troubleshooter', 'cancel', 'supersede', 'classify'].includes(action))
|
|
89
78
|
throw new Error(usage);
|
|
90
79
|
const managed = !arg(args, '--file') && Boolean(arg(args, '--repo') && arg(args, '--id'));
|
|
91
80
|
if (managed) {
|
|
@@ -130,6 +119,16 @@ async function runTaskCommand(action, args) {
|
|
|
130
119
|
console.log(JSON.stringify(task, null, 2));
|
|
131
120
|
return;
|
|
132
121
|
}
|
|
122
|
+
if (action === 'classify') {
|
|
123
|
+
if (!taskId)
|
|
124
|
+
throw new Error('task classify requires --task-id');
|
|
125
|
+
const kind = arg(args, '--classification');
|
|
126
|
+
if (kind !== 'superseded' && kind !== 'invalid')
|
|
127
|
+
throw new Error('--classification must be superseded or invalid');
|
|
128
|
+
const result = await relayControlCommand(args, { command: 'task.classify', jobId, taskId, classification: { kind, targetTaskId: arg(args, '--target-task-id'), evidence: await textOption(args, '--evidence', '--evidence-file'), reason: await textOption(args, '--reason', '--reason-file') } });
|
|
129
|
+
console.log(JSON.stringify(result.tasks.find(item => item.id === taskId), null, 2));
|
|
130
|
+
return;
|
|
131
|
+
}
|
|
133
132
|
if (['create', 'retry', 'cancel'].includes(action)) {
|
|
134
133
|
await runManagedTaskCommand(action, args);
|
|
135
134
|
return;
|
|
@@ -262,6 +261,15 @@ async function runTaskCommand(action, args) {
|
|
|
262
261
|
console.log(JSON.stringify(task, null, 2));
|
|
263
262
|
return;
|
|
264
263
|
}
|
|
264
|
+
if (action === 'classify') {
|
|
265
|
+
const kind = arg(args, '--classification');
|
|
266
|
+
if (kind !== 'superseded' && kind !== 'invalid')
|
|
267
|
+
throw new Error('--classification must be superseded or invalid');
|
|
268
|
+
const result = await classifyFailedTask(store, job.id, id, { kind, targetTaskId: arg(args, '--target-task-id'), evidence: await textOption(args, '--evidence', '--evidence-file'), reason: await textOption(args, '--reason', '--reason-file') });
|
|
269
|
+
await saveLocal(localFile, result.job);
|
|
270
|
+
console.log(JSON.stringify(result.task, null, 2));
|
|
271
|
+
return;
|
|
272
|
+
}
|
|
265
273
|
if (action === 'unblock') {
|
|
266
274
|
await runTaskUnblockCommand(args, store, job, task, localFile);
|
|
267
275
|
return;
|
|
@@ -798,7 +806,7 @@ async function main() {
|
|
|
798
806
|
throw new Error(`${command} requires --repo and --id`);
|
|
799
807
|
if (command === 'plan') {
|
|
800
808
|
const guidance = hasArg(args, '--prompt') || hasArg(args, '--prompt-file') ? await textOption(args, '--prompt', '--prompt-file') : undefined;
|
|
801
|
-
console.log(JSON.stringify(await relayControlCommand(args, { command: 'job.plan', jobId, ...(guidance?.trim() ? { guidance: guidance.trim() } : {}) }), null, 2));
|
|
809
|
+
console.log(JSON.stringify(await relayControlCommand(args, { command: 'job.plan', jobId, ...(guidance?.trim() ? { guidance: guidance.trim() } : {}), forceCleanHistory: hasArg(args, '--force-clean-history') }), null, 2));
|
|
802
810
|
return;
|
|
803
811
|
}
|
|
804
812
|
if (command === 'reconcile') {
|
|
@@ -827,7 +835,7 @@ async function main() {
|
|
|
827
835
|
await withEventBus(args, async (bus) => {
|
|
828
836
|
const reconciler = runtime(store, args, bus);
|
|
829
837
|
try {
|
|
830
|
-
const result = await reconciler.planJob(job.id, guidance?.trim() || undefined);
|
|
838
|
+
const result = await reconciler.planJob(job.id, guidance?.trim() || undefined, hasArg(args, '--force-clean-history'));
|
|
831
839
|
await saveLocal(localFile, result.job);
|
|
832
840
|
await publishWake(bus, result.job, result.round.id, `Planning round ${result.round.id} completed`);
|
|
833
841
|
console.log(JSON.stringify(result.round, null, 2));
|
package/dist/dashboard-html.js
CHANGED
|
@@ -263,7 +263,7 @@ const TASK_STATES=['DRAFT','QUEUED','RUNNING','BLOCKED','SUCCEEDED','FAILED','CA
|
|
|
263
263
|
async function setTaskState(task,state,select){const job=selected();if(!job||state===task.state)return;select.disabled=true;try{const params=new URLSearchParams({id:job.id,task:task.id,state,repo:job.repository,pr:String(job.prNumber)});const response=await fetch('/api/task-state?'+params.toString(),{method:'POST'});if(!response.ok)throw new Error(response.status+' '+response.statusText);const updated=await response.json();const index=jobs.findIndex(candidate=>jobSelectionKey(candidate)===jobSelectionKey(job));if(index>=0)jobs[index]=updated;else jobs.push(updated);renderAll();void loadEventHistory();}catch(error){select.title='Set task state: '+error.message;select.value=task.state;}finally{select.disabled=false;}}
|
|
264
264
|
function taskStateField(task){const box=element('div','detail-group');box.append(element('p','detail-label',text('State')));const select=document.createElement('select');select.className='job-filter task-state-select';select.setAttribute('aria-label','Task state');for(const state of TASK_STATES)select.append(new Option(state,state));select.value=task.state;select.addEventListener('change',()=>void setTaskState(task,select.value,select));box.append(select);return box;}
|
|
265
265
|
function manualOverrideField(task){const rows=Array.isArray(task.manualStateOverrides)?task.manualStateOverrides:[];const last=rows.at(-1);return field('Manual override',last?last.from+' → '+last.to+' · '+relativeTime(last.at):'—');}
|
|
266
|
-
function showDetailsDialog(title,sections){const dialog=element('dialog','details-dialog');dialog.setAttribute('aria-label',title);const head=element('div','details-dialog-head');const name=element('strong','',text(title));const close=element('button','refresh-button',text('Close'));close.type='button';close.addEventListener('click',()=>dialog.close());head.append(name,close);dialog.append(head);for(const [label,value] of sections){if(value===undefined||value===null||value==='')continue;dialog.append(element('h3','',text(label)));const content=element('pre');content.textContent=typeof value==='string'?value:JSON.stringify(value,null,2);dialog.append(content);}dialog.addEventListener('close',()=>dialog.remove());document.body.append(dialog);dialog.showModal();}function openJobDetails(){const job=selected();if(!job)return;showDetailsDialog('Job '+job.id+' · '+value(job.title),[['Objective',job.objective],['Description',job.description],['State',job.state],['Plans',job.plans?.map(p=>({id:p.id,status:p.status,assessment:p.assessment,error:p.error,generatedTaskIds:p.generatedTaskIds}))],['Review',job.review],['Outcome',job.result],['Publication',job.publication],['Failure',job.error]]);}function openTaskDetails(task){const result=task.result?.data??task.result;showDetailsDialog('Task '+task.id+' · '+value(task.title),[['Input / task objective',task.input],['State',task.state],['Conclusion',result],['Result summary',task.result?.summary],['Planner assessment',task.plannerResult?.assessment],['Resolution',task.resolution],['Failure',task.failureReason||task.error],['Dependencies',task.dependencies]]);}document.querySelector('#job-content-open').addEventListener('click',openJobDetails);function renderTaskDetail(task){taskDetailRoot.replaceChildren();if(!task){taskDetailRoot.append(element('p','empty',text('Select a task to inspect execution details.')));return;}const routing=task.routing||{};const groups=element('div','detail-groups');groups.append(field('Title',task.title||'—'),field('Task ID',task.id),field('Kind',task.kind||'work'),taskStateField(task),manualOverrideField(task),field('Planning decision',task.planningDecisionId||'—'),field('Planning source',task.planningSource||'—'),field('Planning round',task.planningRound===undefined?'—':String(task.planningRound)),field('Project / agent',value(task.projectName)+' / '+value(task.agentName)),field('Parent task',task.parentTaskId||'root'),field('Adapter',task.adapter),field('Provider / model',value(routing.provider)+' / '+value(routing.model)),field('Reasoning / profile',value(routing.reasoning||routing.profile)),field('Attempt',attemptBudget(task)),field('Dependencies',Array.isArray(task.dependencies)&&task.dependencies.length?task.dependencies.join(', '):'none'),taskCommentField(task),chatField(task));const job=selected();if(task.reviewHeadSha&&job)groups.append(commitField('HEAD commit',job,task.reviewHeadSha));const graph=agentGraphField(task);if(graph)groups.append(graph);if(task.plannerResult)groups.append(field('Objective status',task.plannerResult.objective_status),markdownField('Assessment',task.plannerResult.assessment));if(task.error)groups.append(field('Error',task.error));const result=task.result?.data??task.result;if(result){groups.append(field('Conclusion',result.verdict||result.outcome?.verdict||task.result?.summary||'Available'));}taskDetailRoot.append(groups);const details=element('button','refresh-button',text('Task details / conclusion'));details.type='button';details.addEventListener('click',()=>openTaskDetails(task));taskDetailRoot.append(details);}
|
|
266
|
+
function showDetailsDialog(title,sections){const dialog=element('dialog','details-dialog');dialog.setAttribute('aria-label',title);const head=element('div','details-dialog-head');const name=element('strong','',text(title));const close=element('button','refresh-button',text('Close'));close.type='button';close.addEventListener('click',()=>dialog.close());head.append(name,close);dialog.append(head);for(const [label,value] of sections){if(value===undefined||value===null||value==='')continue;dialog.append(element('h3','',text(label)));const content=element('pre');content.textContent=typeof value==='string'?value:JSON.stringify(value,null,2);dialog.append(content);}dialog.addEventListener('close',()=>dialog.remove());document.body.append(dialog);dialog.showModal();}function openJobDetails(){const job=selected();if(!job)return;showDetailsDialog('Job '+job.id+' · '+value(job.title),[['Objective',job.objective],['Description',job.description],['State',job.state],['Plans',job.plans?.map(p=>({id:p.id,status:p.status,assessment:p.assessment,error:p.error,generatedTaskIds:p.generatedTaskIds}))],['Review',job.review],['Outcome',job.result],['Publication',job.publication],['Failure',job.error]]);}function openTaskDetails(task){const result=task.result?.data??task.result;showDetailsDialog('Task '+task.id+' · '+value(task.title),[['Input / task objective',task.input],['State',task.state],['Conclusion',result],['Result summary',task.result?.summary],['Planner assessment',task.plannerResult?.assessment],['Resolution',task.resolution],['Resolution classification',task.resolutionClassification],['Failure',task.failureReason||task.error],['Dependencies',task.dependencies]]);}document.querySelector('#job-content-open').addEventListener('click',openJobDetails);function renderTaskDetail(task){taskDetailRoot.replaceChildren();if(!task){taskDetailRoot.append(element('p','empty',text('Select a task to inspect execution details.')));return;}const routing=task.routing||{};const groups=element('div','detail-groups');groups.append(field('Title',task.title||'—'),field('Task ID',task.id),field('Kind',task.kind||'work'),taskStateField(task),manualOverrideField(task),field('Planning decision',task.planningDecisionId||'—'),field('Planning source',task.planningSource||'—'),field('Planning round',task.planningRound===undefined?'—':String(task.planningRound)),field('Project / agent',value(task.projectName)+' / '+value(task.agentName)),field('Parent task',task.parentTaskId||'root'),...(task.resolutionClassification?[field('Resolution classification',task.resolutionClassification.kind),field('Resolution target',task.resolutionClassification.targetTaskId),field('Resolution evidence',task.resolutionClassification.evidence),field('Resolution reason',task.resolutionClassification.reason)]:[]),field('Adapter',task.adapter),field('Provider / model',value(routing.provider)+' / '+value(routing.model)),field('Reasoning / profile',value(routing.reasoning||routing.profile)),field('Attempt',attemptBudget(task)),field('Dependencies',Array.isArray(task.dependencies)&&task.dependencies.length?task.dependencies.join(', '):'none'),taskCommentField(task),chatField(task));const job=selected();if(task.reviewHeadSha&&job)groups.append(commitField('HEAD commit',job,task.reviewHeadSha));const graph=agentGraphField(task);if(graph)groups.append(graph);if(task.plannerResult)groups.append(field('Objective status',task.plannerResult.objective_status),markdownField('Assessment',task.plannerResult.assessment));if(task.error)groups.append(field('Error',task.error));const result=task.result?.data??task.result;if(result){groups.append(field('Conclusion',result.verdict||result.outcome?.verdict||task.result?.summary||'Available'));}taskDetailRoot.append(groups);const details=element('button','refresh-button',text('Task details / conclusion'));details.type='button';details.addEventListener('click',()=>openTaskDetails(task));taskDetailRoot.append(details);}
|
|
267
267
|
function reviewStatus(job){const review=job?.review;if(!review)return 'Not started';if(review.outcome==='approved'&&review.headSha===job.headSha)return 'MERGE READY · approved';const stale=Array.isArray(job?.tasks)&&job.tasks.some(task=>task.agentName==='reviewer'&&task.reviewHeadSha&&job.headSha&&task.reviewHeadSha!==job.headSha);if(stale)return 'STALE · awaiting review';return String(review.outcome||'pending').replace('_',' ').toUpperCase();}function renderSelectedJob(){jobDetail.replaceChildren();const job=selected();if(!job){selectedJobTitle.textContent='';jobDetail.append(element('div','empty',text('No durable jobs discovered.')));buildTree([]);renderTaskDetail(null);return;}selectedJobTitle.textContent=value(job.title||job.id);if(jobSelectionKey(job)!==selectedJob){selectedJob=jobSelectionKey(job);selectedTaskId=null;eventTaskId=null;clearEvents();rememberSelection();}const summary=element('div','job-summary');summary.append(field('PR state',job.githubState||'UNKNOWN'),jobStateField(job),field('Runtime status',job.runtimeStatus||'IDLE'),field('Execution mode',job.executionMode||'fixed'),field('Cadence',cadence(job)),field('Review',reviewStatus(job)),field('Creator thread',job.creatorThreadId||'—'),threadField('Implementation thread',job.implementationThreadId),threadField('Reviewer thread',job.reviewerThreadId),commitField('Review head SHA',job,job.review?.headSha||job.headSha||''),prField(job),field('Repository',job.repository||'—'),field('Last activity',relativeTime(job.updatedAt)),field('Task states',compactCounts(job.tasks)),chatShortcutField(job));jobDetail.append(summary);const selectedTask=(job.tasks||[]).find(task=>task.id===selectedTaskId)||null;if(selectedTaskId&&!selectedTask){selectedTaskId=null;eventTaskId=null;clearEvents();rememberSelection();}buildTree(job.tasks||[]);renderTaskDetail(selectedTask);renderEventScope();}function renderAll(){jobs.sort(compareJobs);syncFilters();renderJobList();renderSelectedJob();syncMobileJobsView();}
|
|
268
268
|
function isCompleteUnmerged(job){return job.runtimeStatus==='MERGE_PENDING';}
|
|
269
269
|
function isTerminalJob(job){return ['COMPLETED','CANCELLED'].includes(job.state)||['MERGED','CLOSED'].includes(job.githubState);}
|
package/dist/manual-plan.js
CHANGED
|
@@ -1,7 +1,9 @@
|
|
|
1
1
|
import { dispatchJobLifecycleSync } from './workflow/lifecycle.js';
|
|
2
2
|
import { createPlanRound, pendingPlan } from './planner.js';
|
|
3
3
|
import { applyJobActivateAction } from './job-actions.js';
|
|
4
|
-
export async function executeManualPlan(store, jobId, guidance, timeoutMs, launchPlanner) {
|
|
4
|
+
export async function executeManualPlan(store, jobId, guidance, timeoutMs, launchPlanner, forceCleanHistory = false) {
|
|
5
|
+
if (forceCleanHistory && !store.mutateJob)
|
|
6
|
+
throw new Error('--force-clean-history requires atomic durable SQLite mutations');
|
|
5
7
|
let job = await store.load(jobId);
|
|
6
8
|
if (['COMPLETED', 'CANCELLED'].includes(job.state))
|
|
7
9
|
throw new Error(`Cannot plan terminal job ${job.id} (${job.state})`);
|
|
@@ -18,6 +20,8 @@ export async function executeManualPlan(store, jobId, guidance, timeoutMs, launc
|
|
|
18
20
|
if (current.pause)
|
|
19
21
|
throw new Error(`Cannot plan paused Job ${current.id}; resume it first`);
|
|
20
22
|
const concurrent = pendingPlan(current);
|
|
23
|
+
if (forceCleanHistory && concurrent)
|
|
24
|
+
throw new Error(`--force-clean-history requires no pending Plan (${concurrent.id})`);
|
|
21
25
|
if (concurrent) {
|
|
22
26
|
if (concurrent.executionId)
|
|
23
27
|
throw new Error(`Planning round ${concurrent.id} is already PLANNING`);
|
|
@@ -25,6 +29,10 @@ export async function executeManualPlan(store, jobId, guidance, timeoutMs, launc
|
|
|
25
29
|
}
|
|
26
30
|
else {
|
|
27
31
|
round = createPlanRound(current, { guidance, manual: true, timeoutMs });
|
|
32
|
+
if (forceCleanHistory) {
|
|
33
|
+
round.forceCleanHistory = true;
|
|
34
|
+
round.cleanHistoryTaskIds = current.tasks.filter(task => task.kind !== 'planner' && task.state === 'FAILED' && !task.resolutionClassification).map(task => task.id);
|
|
35
|
+
}
|
|
28
36
|
current.plans = [...(current.plans ?? []), structuredClone(round)].sort((a, b) => a.round - b.round);
|
|
29
37
|
}
|
|
30
38
|
if (current.state === 'DRAFT') {
|
|
@@ -0,0 +1,33 @@
|
|
|
1
|
+
function record(value, field) {
|
|
2
|
+
if (!value || typeof value !== 'object' || Array.isArray(value))
|
|
3
|
+
throw new Error(`Planner ${field} must be an object`);
|
|
4
|
+
return value;
|
|
5
|
+
}
|
|
6
|
+
function requiredText(value, field) {
|
|
7
|
+
if (typeof value !== 'string' || value.trim().length === 0)
|
|
8
|
+
throw new Error(`Planner ${field} must be a non-empty string`);
|
|
9
|
+
return value;
|
|
10
|
+
}
|
|
11
|
+
export function parsePlannerHistoryCleanup(value) {
|
|
12
|
+
if (value === undefined)
|
|
13
|
+
return undefined;
|
|
14
|
+
if (!Array.isArray(value))
|
|
15
|
+
throw new Error('Planner history_cleanup must be an array when provided');
|
|
16
|
+
const ids = new Set();
|
|
17
|
+
return value.map((item, index) => {
|
|
18
|
+
const row = record(item, `history_cleanup[${index}]`);
|
|
19
|
+
const taskId = requiredText(row.taskId, `history_cleanup[${index}].taskId`);
|
|
20
|
+
const outcome = row.outcome;
|
|
21
|
+
const evidence = requiredText(row.evidence, `history_cleanup[${index}].evidence`);
|
|
22
|
+
const reason = requiredText(row.reason, `history_cleanup[${index}].reason`);
|
|
23
|
+
if (!['superseded', 'invalid', 'cancelled_review', 'unresolved'].includes(String(outcome)))
|
|
24
|
+
throw new Error(`Planner history_cleanup[${index}].outcome is invalid`);
|
|
25
|
+
if (ids.has(taskId))
|
|
26
|
+
throw new Error(`Planner history_cleanup contains duplicate Task ${taskId}`);
|
|
27
|
+
ids.add(taskId);
|
|
28
|
+
const targetTaskId = row.targetTaskId === undefined ? undefined : requiredText(row.targetTaskId, `history_cleanup[${index}].targetTaskId`);
|
|
29
|
+
if ((outcome === 'superseded' || outcome === 'invalid') && !targetTaskId)
|
|
30
|
+
throw new Error(`Planner history_cleanup[${index}].targetTaskId is required for ${String(outcome)}`);
|
|
31
|
+
return { taskId, outcome: outcome, evidence, reason, ...(targetTaskId ? { targetTaskId } : {}) };
|
|
32
|
+
});
|
|
33
|
+
}
|
package/dist/planner-recovery.js
CHANGED
|
@@ -9,7 +9,7 @@ export function createFailedPlanRecovery(job, plan) {
|
|
|
9
9
|
throw Error('Plan recovery identity already exists');
|
|
10
10
|
const now = new Date().toISOString();
|
|
11
11
|
return createTaskEntity(job, {
|
|
12
|
-
jobId: job.id, id, title: '
|
|
12
|
+
jobId: job.id, id, title: 'Troubleshoot',
|
|
13
13
|
recoveryPlanId: plan.id,
|
|
14
14
|
kind: 'work', priority: 'P0', storySize: 'M', projectName: job.title,
|
|
15
15
|
agentName: 'troubleshooter', parentTaskId: null, dependencies: [],
|
package/dist/planner-runtime.js
CHANGED
|
@@ -71,7 +71,7 @@ export async function finishPlanner(ports, jobId, planId, executionId, error, ra
|
|
|
71
71
|
ports.live.delete(planId); };
|
|
72
72
|
const job = await ports.store.load(jobId);
|
|
73
73
|
const plan = (job.plans ?? []).find(item => item.id === planId);
|
|
74
|
-
if (!plan || plan.
|
|
74
|
+
if (!plan || (plan.executionId !== executionId && !(raw?.cliApplied && plan.status === 'SUCCEEDED'))) {
|
|
75
75
|
releaseLive();
|
|
76
76
|
if (cliApply)
|
|
77
77
|
throw new Error(`Plan ${planId} is not owned by this active execution`);
|
|
@@ -118,6 +118,14 @@ export async function finishPlanner(ports, jobId, planId, executionId, error, ra
|
|
|
118
118
|
if (retry && !plan.manualPlanning)
|
|
119
119
|
ports.detach(ports.reconcile(jobId), jobId, planId, 'planner retry reconciliation');
|
|
120
120
|
};
|
|
121
|
+
if (raw?.cliApplied) {
|
|
122
|
+
if (plan.status !== 'SUCCEEDED') {
|
|
123
|
+
releaseLive();
|
|
124
|
+
throw new Error(`Planner reported CLI application, but Plan ${planId} is ${plan.status}`);
|
|
125
|
+
}
|
|
126
|
+
releaseLive();
|
|
127
|
+
return;
|
|
128
|
+
}
|
|
121
129
|
if (error) {
|
|
122
130
|
if (cliApply)
|
|
123
131
|
throw error;
|
|
@@ -128,6 +136,25 @@ export async function finishPlanner(ports, jobId, planId, executionId, error, ra
|
|
|
128
136
|
let planCommitted = false;
|
|
129
137
|
try {
|
|
130
138
|
const parsed = parsePlannerResult(raw);
|
|
139
|
+
if (plan.forceCleanHistory) {
|
|
140
|
+
const cleanup = parsed.history_cleanup ?? [];
|
|
141
|
+
const expected = new Set(plan.cleanHistoryTaskIds ?? []);
|
|
142
|
+
if (cleanup.length !== expected.size || cleanup.some(item => !expected.delete(item.taskId)) || expected.size)
|
|
143
|
+
throw new Error('--force-clean-history requires exactly one Planner disposition for each failed Task in its durable snapshot');
|
|
144
|
+
for (const item of cleanup) {
|
|
145
|
+
const task = job.tasks.find(candidate => candidate.id === item.taskId);
|
|
146
|
+
if (!task)
|
|
147
|
+
throw new Error(`Clean history Task ${item.taskId} disappeared`);
|
|
148
|
+
if (task.failureReason === 'worker_outcome_unknown' && item.outcome !== 'unresolved')
|
|
149
|
+
throw new Error(`Unknown execution outcome for ${item.taskId} must remain unresolved without graph-verified proof`);
|
|
150
|
+
if ((item.outcome === 'superseded' || item.outcome === 'invalid') && (task.resolutionClassification?.kind !== item.outcome || task.resolutionClassification.targetTaskId !== item.targetTaskId || task.resolutionClassification.evidence !== item.evidence || task.resolutionClassification.reason !== item.reason))
|
|
151
|
+
throw new Error(`Clean history classification for ${item.taskId} was not applied through task classify with matching evidence`);
|
|
152
|
+
if (item.outcome === 'cancelled_review' && (task.agentName !== 'reviewer' || task.state !== 'CANCELLED'))
|
|
153
|
+
throw new Error(`Failed review ${item.taskId} must be cancelled through task cancel before Plan apply`);
|
|
154
|
+
if (item.outcome === 'unresolved' && (task.state !== 'FAILED' || task.resolutionClassification))
|
|
155
|
+
throw new Error(`Unresolved history item ${item.taskId} changed after Planner assessment`);
|
|
156
|
+
}
|
|
157
|
+
}
|
|
131
158
|
const result = assignPlannerTaskIds(parsed, job.repository, job.id, plan.id);
|
|
132
159
|
const executionContext = await ports.plannerContext(job, plan);
|
|
133
160
|
validatePlannerHierarchy(job.tasks, result.next_tasks);
|
|
@@ -326,7 +353,7 @@ export async function launchPlanExecution(ports, job, plan) {
|
|
|
326
353
|
let promise;
|
|
327
354
|
try {
|
|
328
355
|
const executionContext = await ports.plannerContext(job, plan);
|
|
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 };
|
|
356
|
+
const context = { planExecution: { planId: plan.id, executionId }, forceCleanHistory: plan.forceCleanHistory, cleanHistoryTaskIds: plan.cleanHistoryTaskIds, job, tasks: job.tasks, objective: job.objective ?? job.description ?? job.title, previousPlannerTaskId: plan.previousPlanId, trigger: plan.trigger, executionContext, retryFeedback: plan.retryFeedback, guidance: plan.guidance };
|
|
330
357
|
promise = planner.plan(context, controller.signal);
|
|
331
358
|
}
|
|
332
359
|
catch (error) {
|
|
@@ -0,0 +1,26 @@
|
|
|
1
|
+
function boundedText(value, max = 4000) {
|
|
2
|
+
if (value === null || value === undefined || value.length <= max)
|
|
3
|
+
return value;
|
|
4
|
+
return value.slice(0, max) + `\n...[truncated ${value.length - max} chars]`;
|
|
5
|
+
}
|
|
6
|
+
export function plannerTaskContext(task) {
|
|
7
|
+
return {
|
|
8
|
+
id: task.id,
|
|
9
|
+
title: task.title,
|
|
10
|
+
kind: task.kind ?? 'work',
|
|
11
|
+
parent_task_id: task.parentTaskId,
|
|
12
|
+
dependencies: task.dependencies,
|
|
13
|
+
state: task.state,
|
|
14
|
+
failure_reason: task.failureReason,
|
|
15
|
+
input: task.kind === 'planner' ? undefined : boundedText(task.input),
|
|
16
|
+
result_summary: boundedText(task.result?.summary),
|
|
17
|
+
result_evidence: task.result?.data ? boundedText(JSON.stringify(task.result.data), 2000) : undefined,
|
|
18
|
+
execution_history: task.executionHistory?.map(item => ({ id: item.id, attempt: item.attempt, started_at: item.startedAt })),
|
|
19
|
+
platform_failure: task.platformFailure ? { classification: task.platformFailure.classification, evidence: boundedText(task.platformFailure.evidence), safe_to_reroute: task.platformFailure.safeToReroute } : undefined,
|
|
20
|
+
comments: task.comments?.slice(-10).map(comment => ({ author: comment.author, text: boundedText(comment.text, 1000), created_at: comment.createdAt })),
|
|
21
|
+
resolution_classification: task.resolutionClassification,
|
|
22
|
+
error: boundedText(task.error, 2000),
|
|
23
|
+
blocked_baseline: task.blockedBaseline,
|
|
24
|
+
unblock: task.unblock,
|
|
25
|
+
};
|
|
26
|
+
}
|
package/dist/planner.js
CHANGED
|
@@ -13,12 +13,14 @@ import { createTaskEntity } from './workflow/entity-creation.js';
|
|
|
13
13
|
import { prepareReviewerThreadDelivery, chatgptThreadRequirements } from './reviewer-thread.js';
|
|
14
14
|
import { spawnOwnedProcess } from './owned-process.js';
|
|
15
15
|
import { requiredPlannerTaskTitle } from './planner-task-title.js';
|
|
16
|
+
import { parsePlannerHistoryCleanup } from './planner-history-cleanup.js';
|
|
17
|
+
import { plannerTaskContext } from './planner-task-context.js';
|
|
16
18
|
export function semanticTaskTree(tasks) {
|
|
17
19
|
const work = tasks.filter(task => task.kind !== 'planner');
|
|
18
20
|
const ids = new Set(work.map(task => task.id));
|
|
19
21
|
return {
|
|
20
22
|
roots: work.filter(task => !task.parentTaskId || !ids.has(task.parentTaskId)).map(task => task.id),
|
|
21
|
-
tasks: work.map(task => ({ id: task.id, title: task.title, parent_task_id: task.parentTaskId, state: task.state, dependencies: [...task.dependencies], planning_decision_id: task.planningDecisionId, planning_round: task.planningRound, review: task.review })),
|
|
23
|
+
tasks: work.map(task => ({ id: task.id, title: task.title, parent_task_id: task.parentTaskId, state: task.state, dependencies: [...task.dependencies], planning_decision_id: task.planningDecisionId, planning_round: task.planningRound, review: task.review, resolution_classification: task.resolutionClassification })),
|
|
22
24
|
};
|
|
23
25
|
}
|
|
24
26
|
function plannerTrigger(job, manual = false) {
|
|
@@ -107,6 +109,7 @@ export function materializePlannerChild(job, planner, spec, executionContext) {
|
|
|
107
109
|
const round = 'round' in planner ? planner.round : planner.planningRound ?? 1;
|
|
108
110
|
const plannerHeadSha = 'round' in planner ? planner.headSha : undefined;
|
|
109
111
|
const reviewHeadSha = spec.agentName === 'reviewer' && !(spec.dependencies?.length) ? plannerHeadSha ?? job.headSha : undefined;
|
|
112
|
+
const objectiveRoot = spec.agentName === 'reviewer' ? job.tasks.find(task => task.kind !== 'planner' && task.agentName !== 'reviewer' && task.agentName !== 'troubleshooter' && !task.parentTaskId) : undefined;
|
|
110
113
|
const resolution = spec.resolution;
|
|
111
114
|
if (resolution) {
|
|
112
115
|
const target = resolution.targetTaskId ? job.tasks.find(task => task.id === resolution.targetTaskId) : undefined;
|
|
@@ -115,7 +118,7 @@ export function materializePlannerChild(job, planner, spec, executionContext) {
|
|
|
115
118
|
validateResolutionProposal(job, target, resolution);
|
|
116
119
|
}
|
|
117
120
|
const reviewerThread = spec.agentName === 'reviewer' ? prepareReviewerThreadDelivery(job, { id: spec.id, agentName: spec.agentName, reviewHeadSha }) : undefined, implementationThread = (spec.agentName === 'implementation' || spec.agentName == null) && job.implementationThreadId ? job.implementationThreadId : undefined, threadOwned = Boolean(reviewerThread || implementationThread);
|
|
118
|
-
return createTaskEntity(job, { jobId: job.id, id: spec.id, title: spec.title, kind: 'work', priority: spec.priority ?? job.priority, storySize: spec.storySize ?? 'M', projectName: spec.projectName, agentName: spec.agentName, agentGraph: spec.agentGraph, review: spec.review, parentTaskId: spec.parentTaskId ?? null, dependencies: spec.dependencies ?? [], capabilities: spec.capabilities ?? [], workerRequirements: threadOwned ? chatgptThreadRequirements(spec.workerRequirements) : spec.workerRequirements, testInjection: spec.testInjection, adapter: threadOwned ? 'chatgpt' : (spec.adapter ?? 'codex'), chatgptThreadDelivery: reviewerThread, input: spec.input, output: spec.output, continuation: spec.continuation ?? null, continuationDeliveredAt: null, attempt: 0, maxAttempts: spec.maxAttempts ?? 3, leaseOwner: null, leaseExpiresAt: null, executionId: null, threadId: null, result: null, plannerResult: null, error: null, timeoutMs: spec.timeoutMs ?? taskOverallTimeoutMs(spec.storySize ?? 'M'), createdAt: now, updatedAt: now, planningDecisionId: decisionId, planningSource: source, planningRound: round, reviewHeadSha, resolution: resolution ? { ...resolution, state: 'proposed', owner: 'planner', updatedAt: now } : undefined });
|
|
121
|
+
return createTaskEntity(job, { jobId: job.id, id: spec.id, title: spec.agentName === 'reviewer' ? 'Review' : spec.title, kind: 'work', priority: spec.priority ?? job.priority, storySize: spec.storySize ?? 'M', projectName: spec.projectName, agentName: spec.agentName, agentGraph: spec.agentGraph, review: spec.review, parentTaskId: objectiveRoot?.id ?? spec.parentTaskId ?? null, dependencies: spec.dependencies ?? [], capabilities: spec.capabilities ?? [], workerRequirements: threadOwned ? chatgptThreadRequirements(spec.workerRequirements) : spec.workerRequirements, testInjection: spec.testInjection, adapter: threadOwned ? 'chatgpt' : (spec.adapter ?? 'codex'), chatgptThreadDelivery: reviewerThread, input: spec.input, output: spec.output, continuation: spec.continuation ?? null, continuationDeliveredAt: null, attempt: 0, maxAttempts: spec.maxAttempts ?? 3, leaseOwner: null, leaseExpiresAt: null, executionId: null, threadId: null, result: null, plannerResult: null, error: null, timeoutMs: spec.timeoutMs ?? taskOverallTimeoutMs(spec.storySize ?? 'M'), createdAt: now, updatedAt: now, planningDecisionId: decisionId, planningSource: source, planningRound: round, reviewHeadSha, resolution: resolution ? { ...resolution, state: 'proposed', owner: 'planner', updatedAt: now } : undefined });
|
|
119
122
|
}
|
|
120
123
|
export function planningSpec(task) {
|
|
121
124
|
if (!task.output)
|
|
@@ -267,6 +270,7 @@ export function parsePlannerResult(value) {
|
|
|
267
270
|
unblockKeys.add(key);
|
|
268
271
|
return { repository, prNumber: Number(prNumber), jobId, taskId, resolutionMode: resolutionMode };
|
|
269
272
|
});
|
|
273
|
+
const history_cleanup = parsePlannerHistoryCleanup(root.history_cleanup);
|
|
270
274
|
const ids = new Set();
|
|
271
275
|
const next_tasks = root.next_tasks.map((item, index) => {
|
|
272
276
|
const task = record(item, `next_tasks[${index}]`);
|
|
@@ -319,7 +323,7 @@ export function parsePlannerResult(value) {
|
|
|
319
323
|
continuation: task.continuation, maxAttempts, timeoutMs, resolution: resolutionProposal(task.resolution, `next_tasks[${index}].resolution`)
|
|
320
324
|
};
|
|
321
325
|
});
|
|
322
|
-
return { objective_status: status, assessment: root.assessment, next_tasks, unblocks };
|
|
326
|
+
return { objective_status: status, assessment: root.assessment, next_tasks, unblocks, ...(history_cleanup ? { history_cleanup } : {}) };
|
|
323
327
|
}
|
|
324
328
|
export function validatePlannerProgress(job, result) {
|
|
325
329
|
if (result.objective_status !== 'in_progress' || result.next_tasks.length > 0 || result.unblocks.length > 0)
|
|
@@ -386,7 +390,7 @@ export function validatePlannerHierarchy(existing, specs) {
|
|
|
386
390
|
/** Planner may only materialize a new business Task while unresolved work is either
|
|
387
391
|
* explicitly resolved in this round or already owned by an active resolution path. */
|
|
388
392
|
export function validatePlannerResolutionMaterialization(existing, specs) {
|
|
389
|
-
const unresolved = existing.filter(task => unresolvedTask(task) && ['FAILED', 'BLOCKED'].includes(task.state));
|
|
393
|
+
const unresolved = existing.filter(task => unresolvedTask(task) && ['FAILED', 'BLOCKED'].includes(task.state) && !task.resolutionClassification);
|
|
390
394
|
if (unresolved.length === 0)
|
|
391
395
|
return;
|
|
392
396
|
const resolvedIds = new Set(specs.map(spec => spec.resolution?.targetTaskId).filter((id) => Boolean(id)));
|
|
@@ -416,8 +420,11 @@ export function plannerInput(context) {
|
|
|
416
420
|
const project = context.executionContext?.project;
|
|
417
421
|
return JSON.stringify({
|
|
418
422
|
objective: context.objective,
|
|
423
|
+
apply_cli: context.planExecution ? `agents-relay plan apply --repo ${context.job.repository} --pr ${context.job.prNumber} --id ${context.job.id} --plan-id ${context.planExecution.planId} --execution-id ${context.planExecution.executionId} --file PATH` : undefined,
|
|
419
424
|
plan_execution: context.planExecution, retry_feedback: context.retryFeedback,
|
|
420
425
|
caller_prompt: context.guidance,
|
|
426
|
+
force_clean_history: context.forceCleanHistory === true,
|
|
427
|
+
clean_history_task_ids: context.cleanHistoryTaskIds ?? [],
|
|
421
428
|
previous_planner_task_id: context.previousPlannerTaskId,
|
|
422
429
|
plan_trigger: context.trigger,
|
|
423
430
|
legacy_change_cycle: context.job.changeCycle,
|
|
@@ -436,23 +443,11 @@ export function plannerInput(context) {
|
|
|
436
443
|
})),
|
|
437
444
|
project: project ? { name: project.name, path: project.path } : undefined,
|
|
438
445
|
visible_agents: visibleAgents ?? [],
|
|
439
|
-
tasks: context.tasks.map(task => ({
|
|
440
|
-
id: task.id,
|
|
441
|
-
title: task.title,
|
|
442
|
-
kind: task.kind ?? 'work',
|
|
443
|
-
parent_task_id: task.parentTaskId,
|
|
444
|
-
state: task.state,
|
|
445
|
-
input: task.kind === 'planner' ? undefined : boundedText(task.input),
|
|
446
|
-
result_summary: boundedText(task.result?.summary),
|
|
447
|
-
planner_result: task.plannerResult ? {
|
|
446
|
+
tasks: context.tasks.map(task => ({ ...plannerTaskContext(task), planner_result: task.plannerResult ? {
|
|
448
447
|
objective_status: task.plannerResult.objective_status,
|
|
449
448
|
assessment: boundedText(task.plannerResult.assessment),
|
|
450
449
|
next_task_ids: task.plannerResult.next_tasks.map(item => item.id),
|
|
451
|
-
} : undefined,
|
|
452
|
-
error: boundedText(task.error, 2000),
|
|
453
|
-
blocked_baseline: task.blockedBaseline,
|
|
454
|
-
unblock: task.unblock,
|
|
455
|
-
})),
|
|
450
|
+
} : undefined })),
|
|
456
451
|
});
|
|
457
452
|
}
|
|
458
453
|
export class CommandObjectivePlanner {
|
|
@@ -470,12 +465,10 @@ export class CommandObjectivePlanner {
|
|
|
470
465
|
child.stdout?.on('data', (chunk) => { stdout += chunk.toString(); });
|
|
471
466
|
child.stderr?.on('data', (chunk) => { stderr += chunk.toString(); });
|
|
472
467
|
child.on('error', reject);
|
|
473
|
-
child.on('close', code => code === 0 ? (() => {
|
|
474
|
-
|
|
475
|
-
|
|
476
|
-
|
|
477
|
-
reject(error);
|
|
478
|
-
} })() : reject(new Error(stderr.trim() || `Planner exited ${code}`)));
|
|
468
|
+
child.on('close', code => code === 0 ? (() => { const expected = context.planExecution ? `PLAN_APPLIED:${context.planExecution.planId}:${context.planExecution.executionId}` : ''; if (stdout.trim() !== expected || !expected) {
|
|
469
|
+
reject(new Error('Planner command must commit its proposal through agents-relay plan apply and return the matching PLAN_APPLIED receipt'));
|
|
470
|
+
return;
|
|
471
|
+
} resolve({ objective_status: 'satisfied', assessment: 'Plan committed through the authorized CLI action.', next_tasks: [], unblocks: [], cliApplied: true }); })() : reject(new Error(stderr.trim() || `Planner exited ${code}`)));
|
|
479
472
|
child.stdin?.end(plannerInput(context));
|
|
480
473
|
});
|
|
481
474
|
}
|
|
@@ -539,18 +532,12 @@ export class AgentObjectivePlanner {
|
|
|
539
532
|
resolve(finalMessage);
|
|
540
533
|
});
|
|
541
534
|
});
|
|
542
|
-
|
|
543
|
-
|
|
544
|
-
|
|
545
|
-
|
|
546
|
-
|
|
547
|
-
|
|
548
|
-
|
|
549
|
-
if (signal.aborted || retry === 3)
|
|
550
|
-
throw new Error(`Planner validation failed after ${retry} corrections: ${error instanceof Error ? error.message : String(error)}`);
|
|
551
|
-
response = await runAgent(`${prompt}\n\n## Fix validation error (correction ${retry + 1} of 3)\n${error instanceof Error ? error.message : String(error)}\nPrevious JSON:\n${response.slice(0, 12000)}\nReturn exactly one complete Planner JSON object. Correct all schema, title, resolution, and dependency requirements; do not remove valid work or bypass safeguards.`);
|
|
552
|
-
}
|
|
553
|
-
}
|
|
554
|
-
throw new Error('Planner correction exhausted');
|
|
535
|
+
if (!context.planExecution)
|
|
536
|
+
throw new Error('Planner execution is missing its durable Plan and execution identity');
|
|
537
|
+
const response = await runAgent(prompt);
|
|
538
|
+
const expected = `PLAN_APPLIED:${context.planExecution.planId}:${context.planExecution.executionId}`;
|
|
539
|
+
if (response !== expected)
|
|
540
|
+
throw new Error('Planner must commit its proposal with the authorized `agents-relay plan apply` CLI action and return only the matching PLAN_APPLIED receipt');
|
|
541
|
+
return { objective_status: 'satisfied', assessment: 'Plan committed through the authorized CLI action.', next_tasks: [], unblocks: [], cliApplied: true };
|
|
555
542
|
}
|
|
556
543
|
}
|
|
@@ -146,14 +146,15 @@ export class ReconcilerReviewBase extends ReconcilerRuntimeBase {
|
|
|
146
146
|
reviewPrNumber > 0 ? `Review PR #${reviewPrNumber} at exactly head SHA ${headSha}.` : `Review the local Job artifact at exactly head SHA ${headSha}.`,
|
|
147
147
|
`Review head SHA: ${headSha}`,
|
|
148
148
|
'Job objective: ' + (job.objective ?? job.description ?? job.title),
|
|
149
|
+
'Review the complete PR diff at this exact immutable head SHA, not only the dependency Tasks.',
|
|
149
150
|
'Use the selected reviewer agent contract as the authoritative review protocol.',
|
|
150
151
|
'This is a PRE-MERGE artifact approval gate: inspect exact PR head, requirements, tests and genuine blocking defects.',
|
|
151
152
|
'Do not block approval because automatic merge, Job COMPLETED, or post-merge E2E evidence has not occurred yet.',
|
|
152
153
|
'Do not treat historical failed attempts as current artifact defects; distinguish preserved baseline files from implementation.',
|
|
153
154
|
].join('\n\n');
|
|
154
155
|
const task = createTaskEntity(job, {
|
|
155
|
-
jobId: job.id, id, title:
|
|
156
|
-
parentTaskId: null, dependencies: reviewGateTasks.map(item => item.id), capabilities: ['review'],
|
|
156
|
+
jobId: job.id, id, title: 'Review', kind: 'work', priority: job.priority, storySize: 'M', projectName: job.title, agentName: 'reviewer',
|
|
157
|
+
parentTaskId: reviewGateTasks.find(item => !item.parentTaskId)?.id ?? null, dependencies: reviewGateTasks.map(item => item.id), capabilities: ['review'],
|
|
157
158
|
workerSelection: reviewerRouting.selection, adapter: reviewerRouting.candidate.adapter,
|
|
158
159
|
input,
|
|
159
160
|
output: { kind: 'task_pr' }, routing: reviewerRouting.candidate.routing, continuation: null, continuationDeliveredAt: null,
|
|
@@ -163,7 +164,7 @@ export class ReconcilerReviewBase extends ReconcilerRuntimeBase {
|
|
|
163
164
|
error: null, timeoutMs: this.options.reviewer?.timeoutMs ?? storySizeTimeoutMs('M'), createdAt: now, updatedAt: now
|
|
164
165
|
});
|
|
165
166
|
await this.store.appendTask(task);
|
|
166
|
-
await this.emit(eventFor(job.id, id,
|
|
167
|
+
await this.emit(eventFor(job.id, id, task.parentTaskId, 'review.created', 'queued', 'Final review task ' + id + ' created for ' + headSha + ' using ' + reviewerRouting.reason, 'orchestrator', { headSha, agentName: 'reviewer', reviewerRouting: { workerId: reviewerRouting.selection.workerId, reason: reviewerRouting.reason, selectedBy: reviewerRouting.selection.selectedBy } }));
|
|
167
168
|
return id;
|
|
168
169
|
}
|
|
169
170
|
async projectedReviewHeadReady(job, headSha) {
|
package/dist/reconciler.js
CHANGED
|
@@ -137,7 +137,7 @@ export class Reconciler extends ReconcilerReviewBase {
|
|
|
137
137
|
}
|
|
138
138
|
for (const recovery of pendingTroubleshooterTasks(await this.store.load(jobId))) {
|
|
139
139
|
await this.store.appendTask(recovery);
|
|
140
|
-
await this.emit(eventFor(job.id, recovery.id,
|
|
140
|
+
await this.emit(eventFor(job.id, recovery.id, recovery.parentTaskId, "troubleshooter.created", "queued", "Troubleshooter task " + recovery.id + " created for failed/blocked work", "orchestrator", { agentName: "troubleshooter", originalTaskId: recovery.parentTaskId }));
|
|
141
141
|
}
|
|
142
142
|
{
|
|
143
143
|
const recoveryJob = await this.store.load(jobId);
|
|
@@ -273,8 +273,8 @@ export class Reconciler extends ReconcilerReviewBase {
|
|
|
273
273
|
}
|
|
274
274
|
return this.store.load(jobId);
|
|
275
275
|
}
|
|
276
|
-
async planJob(jobId, guidance) {
|
|
277
|
-
return executeManualPlan(this.store, jobId, guidance, this.options.plannerTimeoutMs ?? storySizeTimeoutMs('M'), (job, plan) => this.launchPlanner(job, plan));
|
|
276
|
+
async planJob(jobId, guidance, forceCleanHistory = false) {
|
|
277
|
+
return executeManualPlan(this.store, jobId, guidance, this.options.plannerTimeoutMs ?? storySizeTimeoutMs('M'), (job, plan) => this.launchPlanner(job, plan), forceCleanHistory);
|
|
278
278
|
}
|
|
279
279
|
async maybeCreatePlanner(job) {
|
|
280
280
|
const pending = pendingPlan(job);
|
package/dist/relayd-control.js
CHANGED
|
@@ -4,7 +4,7 @@ import { openLocalManagedStore } from './local-managed-store.js';
|
|
|
4
4
|
import { relayStoreDirectory } from './relay-store.js';
|
|
5
5
|
import { normalizeThreadId } from './reviewer-thread.js';
|
|
6
6
|
import { publishTaskBlocked } from './cli-support.js';
|
|
7
|
-
import { supersedeCancelledTask } from './task-actions.js';
|
|
7
|
+
import { classifyFailedTask, supersedeCancelledTask } from './task-actions.js';
|
|
8
8
|
import { requestTroubleshooterEscalation, resumeTroubleshooter } from './task-recovery.js';
|
|
9
9
|
import { adoptExistingPullRequestJob } from './cli-job-create.js';
|
|
10
10
|
export function createWorkspaceControl(deps) {
|
|
@@ -109,7 +109,7 @@ export function createWorkspaceControl(deps) {
|
|
|
109
109
|
if (command === 'job.plan') {
|
|
110
110
|
const reconciler = makeReconciler(store, concurrency);
|
|
111
111
|
try {
|
|
112
|
-
const planned = await reconciler.planJob(jobId, typeof body.guidance === 'string' && body.guidance.trim() ? body.guidance : undefined);
|
|
112
|
+
const planned = await reconciler.planJob(jobId, typeof body.guidance === 'string' && body.guidance.trim() ? body.guidance : undefined, body.forceCleanHistory === true);
|
|
113
113
|
await pool.reconcileLocalJob(jobId, 'control.job.plan');
|
|
114
114
|
return planned.round;
|
|
115
115
|
}
|
|
@@ -150,6 +150,15 @@ export function createWorkspaceControl(deps) {
|
|
|
150
150
|
await pool.reconcileLocalJob(jobId, 'control.task.supersede');
|
|
151
151
|
return await store.load(jobId);
|
|
152
152
|
}
|
|
153
|
+
if (command === 'task.classify') {
|
|
154
|
+
const taskId = typeof body.taskId === 'string' ? body.taskId : '';
|
|
155
|
+
const raw = body.classification;
|
|
156
|
+
if (!taskId || !raw || (raw.kind !== 'superseded' && raw.kind !== 'invalid') || typeof raw.targetTaskId !== 'string' || typeof raw.evidence !== 'string' || typeof raw.reason !== 'string')
|
|
157
|
+
throw new Error('task classify requires taskId, classification, targetTaskId, evidence, and reason');
|
|
158
|
+
await classifyFailedTask(store, jobId, taskId, { kind: raw.kind, targetTaskId: raw.targetTaskId, evidence: raw.evidence, reason: raw.reason });
|
|
159
|
+
await pool.reconcileLocalJob(jobId, 'control.task.classify');
|
|
160
|
+
return await store.load(jobId);
|
|
161
|
+
}
|
|
153
162
|
if (command === 'task.recover') {
|
|
154
163
|
const taskId = typeof body.taskId === 'string' ? body.taskId : '';
|
|
155
164
|
const recoveryTaskId = typeof body.recoveryTaskId === 'string' ? body.recoveryTaskId : '';
|
|
@@ -17,7 +17,7 @@ export async function createSingleJobTaskControl(jobId, store, reconciler, bus)
|
|
|
17
17
|
unblockAutonomousTasks: unblock.unblockAutonomousTasks, unblockTask: unblock.unblockTask,
|
|
18
18
|
publishTaskUnblocked, dispatchTaskAction,
|
|
19
19
|
});
|
|
20
|
-
const allowed = new Set(['task.unblock', 'task.recover', 'task.escalate', 'task.resume-troubleshooter']);
|
|
20
|
+
const allowed = new Set(['task.unblock', 'task.recover', 'task.escalate', 'task.resume-troubleshooter', 'task.classify']);
|
|
21
21
|
return async (body) => {
|
|
22
22
|
if (!allowed.has(String(body.command ?? '')))
|
|
23
23
|
throw new Error('Single-Job control command not supported');
|
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
|
import { dispatchTaskResolutionSync, materializeResolution } from './workflow/task-resolution.js';
|
|
4
|
+
import { classifyTaskResolution } from './workflow/task-resolution.js';
|
|
4
5
|
export const TASK_RETRY_ACTION = 'task.retry';
|
|
5
6
|
export const TASK_RECOVER_ACTION = 'task.recover';
|
|
6
7
|
export const TASK_UNBLOCK_RETRY_ACTION = 'task.unblock.retry';
|
|
@@ -8,6 +9,24 @@ export const TASK_CANCEL_ACTION = 'task.cancel';
|
|
|
8
9
|
export const TASK_RELEASE_ACTION = 'task.release';
|
|
9
10
|
export const TASK_HOLD_ACTION = 'task.hold';
|
|
10
11
|
export const TASK_QUEUE_ACTION = 'task.queue';
|
|
12
|
+
export async function classifyFailedTask(store, jobId, taskId, classification) {
|
|
13
|
+
if (!store.mutateJob)
|
|
14
|
+
throw new Error('Task resolution classification requires atomic SQLite mutation');
|
|
15
|
+
let changed;
|
|
16
|
+
const job = await store.mutateJob(jobId, current => {
|
|
17
|
+
const task = current.tasks.find(item => item.id === taskId);
|
|
18
|
+
if (!task)
|
|
19
|
+
throw new Error(`Task ${taskId} not found`);
|
|
20
|
+
if (classification.targetTaskId === task.id || !current.tasks.some(item => item.id === classification.targetTaskId))
|
|
21
|
+
throw new Error('Task classification targetTaskId must identify a different durable Task in this Job');
|
|
22
|
+
classifyTaskResolution(task, classification);
|
|
23
|
+
changed = structuredClone(task);
|
|
24
|
+
return current;
|
|
25
|
+
});
|
|
26
|
+
if (!changed)
|
|
27
|
+
throw new Error(`Task ${taskId} classification was not committed`);
|
|
28
|
+
return { job, task: changed };
|
|
29
|
+
}
|
|
11
30
|
function lifecycleRejection(task, action) {
|
|
12
31
|
return new Error(`Task ${task.id} is not ${action} (${task.state})`);
|
|
13
32
|
}
|
package/dist/troubleshooter.js
CHANGED
|
@@ -7,16 +7,45 @@ export const TASK_RESOLUTION_PATHS = [
|
|
|
7
7
|
const source = readFileSync(new URL('../../workflow/task-resolution.mmd', import.meta.url), 'utf8');
|
|
8
8
|
const boolGuard = (name) => (facts) => facts.durableFacts[name] === true;
|
|
9
9
|
export const taskResolutionV2 = new GraphRuntime(source, {
|
|
10
|
-
states: new Set(['unresolved', 'check_owner', 'check_path', 'check_fresh_execution', 'resolved', 'rejected']),
|
|
10
|
+
states: new Set(['unresolved', 'check_owner', 'check_path', 'check_fresh_execution', 'check_classification', 'classificationKind', 'resolved', 'rejected']),
|
|
11
11
|
guards: {
|
|
12
12
|
recoveryOwnerAvailable: boolGuard('recoveryOwnerAvailable'), plannerOwnerAvailable: boolGuard('plannerOwnerAvailable'),
|
|
13
13
|
exactOriginalRetry: boolGuard('exactOriginalRetry'), sameJobDependency: boolGuard('sameJobDependency'),
|
|
14
14
|
blockerJobDependency: boolGuard('blockerJobDependency'), escalationGate: boolGuard('escalationGate'), genuinelyObsolete: boolGuard('genuinelyObsolete'),
|
|
15
15
|
freshRecoveryExecution: boolGuard('freshRecoveryExecution'),
|
|
16
|
+
classifiableFailure: boolGuard('classifiableFailure'),
|
|
17
|
+
classificationSuperseded: boolGuard('classificationSuperseded'),
|
|
18
|
+
supersededEvidenceComplete: boolGuard('supersededEvidenceComplete'),
|
|
19
|
+
invalidEvidenceComplete: boolGuard('invalidEvidenceComplete'),
|
|
16
20
|
},
|
|
17
|
-
actions: Object.fromEntries(['resolution.activateRecovery', 'resolution.attachSameJobDependency', 'resolution.registerBlockerDependency', 'resolution.createEscalationGate', 'resolution.cancelSupersede'].map(id => [id, { id }])),
|
|
18
|
-
events: { 'resolution.propose': { id: 'resolution.propose' } },
|
|
21
|
+
actions: Object.fromEntries(['resolution.activateRecovery', 'resolution.attachSameJobDependency', 'resolution.registerBlockerDependency', 'resolution.createEscalationGate', 'resolution.cancelSupersede', 'resolution.classifySuperseded', 'resolution.classifyInvalid'].map(id => [id, { id }])),
|
|
22
|
+
events: { 'resolution.propose': { id: 'resolution.propose' }, 'resolution.classify': { id: 'resolution.classify' } },
|
|
19
23
|
});
|
|
24
|
+
export function classifyTaskResolution(task, classification, now = new Date().toISOString()) {
|
|
25
|
+
const complete = Boolean(classification.targetTaskId.trim() && classification.evidence.trim() && classification.reason.trim());
|
|
26
|
+
let applied;
|
|
27
|
+
const decision = dispatchWorkflowGraphSync(taskResolutionV2, {
|
|
28
|
+
initialState: 'unresolved', input: { transport: 'event', id: 'resolution.classify', payload: classification },
|
|
29
|
+
durableFacts: {
|
|
30
|
+
classifiableFailure: task.kind !== 'planner' && task.state === 'FAILED' && task.failureReason !== 'worker_outcome_unknown' && !task.executionId && !task.leaseOwner && !task.leaseExpiresAt && !task.resolutionClassification && !['active', 'waiting'].includes(task.resolution?.state ?? ''),
|
|
31
|
+
classificationSuperseded: classification.kind === 'superseded',
|
|
32
|
+
supersededEvidenceComplete: classification.kind === 'superseded' && complete,
|
|
33
|
+
invalidEvidenceComplete: classification.kind === 'invalid' && complete,
|
|
34
|
+
}, graphName: 'task-resolution', isTransientState: state => state === 'check_classification' || state === 'classificationKind', maxSteps: 6,
|
|
35
|
+
executeAction: actionId => {
|
|
36
|
+
const expectedAction = classification.kind === 'superseded' ? 'resolution.classifySuperseded' : 'resolution.classifyInvalid';
|
|
37
|
+
if (actionId !== expectedAction)
|
|
38
|
+
throw new Error(`Unexpected Task Resolution action ${actionId}`);
|
|
39
|
+
applied = { ...classification, updatedAt: now };
|
|
40
|
+
task.resolutionClassification = applied;
|
|
41
|
+
task.updatedAt = now;
|
|
42
|
+
return {};
|
|
43
|
+
},
|
|
44
|
+
});
|
|
45
|
+
if (!decision.accepted || decision.target !== 'resolved' || !applied)
|
|
46
|
+
throw new Error(`Task resolution graph rejected ${classification.kind} classification for ${task.id}`);
|
|
47
|
+
return applied;
|
|
48
|
+
}
|
|
20
49
|
export function unresolvedTask(task) { return task.kind !== 'planner' && !['SUCCEEDED', 'CANCELLED'].includes(task.state); }
|
|
21
50
|
export function validateResolutionProposal(job, original, proposal) {
|
|
22
51
|
if (!TASK_RESOLUTION_PATHS.includes(proposal.path))
|
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "agents-relay",
|
|
3
|
-
"version": "2.0.
|
|
3
|
+
"version": "2.0.159",
|
|
4
4
|
"description": "Durable async agent jobs coordinated through GitHub pull requests",
|
|
5
5
|
"repository": {
|
|
6
6
|
"type": "git",
|
|
@@ -19,8 +19,8 @@
|
|
|
19
19
|
"test:publish": "npm run build && node --import ./test/test-store-env.mjs --test --test-concurrency=1 --test-force-exit test/*.test.js",
|
|
20
20
|
"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",
|
|
21
21
|
"test:full": "npm run build && node --import ./test/test-store-env.mjs --test --test-concurrency=1 test/*.test.js && npm run test:verification:l3",
|
|
22
|
-
"test:verification:l1": "npm run build && node --test test/verification/harness.test.mjs test/verification/contract.test.mjs test/verification/graph-inventory.test.mjs test/verification/dashboard-drilldown.test.mjs test/verification/evidence-matrix.test.mjs",
|
|
23
|
-
"test:verification:l3": "npm run build && node --test --test-concurrency=1 test/verification/harness.test.mjs test/verification/l3-sqlite.test.mjs test/verification/l3-ingress.test.mjs test/verification/l3-replay.test.mjs test/verification/objective-authority.test.mjs test/verification/l3-convergence.test.mjs test/verification/l3-review-convergence.test.mjs test/verification/l3-human-escalation.test.mjs test/verification/l3-escalation-notifier.test.mjs test/verification/l3-unknown-effect.test.mjs test/verification/l3-cli-actions.test.mjs test/verification/l3-planner-round.test.mjs test/verification/full-recovery-gates.test.mjs test/verification/production-wiring.test.mjs test/verification/l3-full-path.test.mjs test/verification/l3-projection-recovery.test.mjs",
|
|
22
|
+
"test:verification:l1": "npm run build && node --test test/verification/harness.test.mjs test/verification/contract.test.mjs test/verification/graph-inventory.test.mjs test/verification/dashboard-drilldown.test.mjs test/verification/evidence-matrix.test.mjs test/verification/task-resolution-classification.test.mjs",
|
|
23
|
+
"test:verification:l3": "npm run build && node --test --test-concurrency=1 test/verification/harness.test.mjs test/verification/l3-sqlite.test.mjs test/verification/l3-ingress.test.mjs test/verification/l3-replay.test.mjs test/verification/objective-authority.test.mjs test/verification/l3-convergence.test.mjs test/verification/l3-review-convergence.test.mjs test/verification/l3-human-escalation.test.mjs test/verification/l3-escalation-notifier.test.mjs test/verification/l3-unknown-effect.test.mjs test/verification/l3-cli-actions.test.mjs test/verification/l3-planner-round.test.mjs test/verification/l3-task-resolution.test.mjs test/verification/full-recovery-gates.test.mjs test/verification/production-wiring.test.mjs test/verification/l3-full-path.test.mjs test/verification/l3-projection-recovery.test.mjs",
|
|
24
24
|
"test:verification": "npm run test:verification:l1 && npm run test:verification:l2 && npm run test:verification:l3 && npm run test:verification:cross-a && node --test test/workflow-production-outbox.test.js && npm run test:verification:matrix",
|
|
25
25
|
"e2e": "node e2e/runner/run.mjs",
|
|
26
26
|
"test:verification:cross-a": "npm run build && RELAY_A_DIST=$PWD/dist node --test test/verification/action-ledger-cross-branch.test.mjs test/verification/outbox-cross-a.test.mjs",
|
|
@@ -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 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.
|
|
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. Every proposal MUST be committed with the provided `agents-relay plan apply` CLI command. Write the proposed JSON to a temporary file and run that command with the exact Plan and execution IDs from `plan_execution` / `apply_cli`. If the CLI rejects it, correct the reported error and resubmit within the SAME Plan, up to three corrections. Do not edit SQLite or other durable storage directly. Do not treat a JSON/text response as a plan submission and do not return the proposal as your final answer. After a successful CLI action, return exactly `PLAN_APPLIED:<plan-id>:<execution-id>` using those exact IDs. Never invent that receipt. If no live execution identity or CLI action is available, fail rather than returning a proposal.
|
|
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
|
|
|
@@ -96,9 +96,15 @@ Do not create separate Tasks merely for "analyze", "format", "call tool", "check
|
|
|
96
96
|
- If satisfied, return `objective_status: "satisfied"` and `next_tasks: []`.
|
|
97
97
|
- Otherwise return only the next useful executable batch from the graph-derived Task boundaries.
|
|
98
98
|
- Do not repeat completed work or recreate an existing Task unless replacement work is explicitly needed.
|
|
99
|
-
- **Resolution is mandatory for replacement work:** before returning any `next_tasks`, inspect every existing `FAILED` or `BLOCKED` work Task. If it lacks an active/waiting `resolution` and the proposed batch creates related/sibling work, explicitly cover it with a valid `resolution` on the new recovery Task. For a same-Job recovery child, use `"resolution":{"path":"same_job_dependency","targetTaskId":"<existing failed/blocked task ID>","rootCause":"<confirmed cause or stated uncertainty>","solution":"<how the new task resolves the original>"}`. `targetTaskId` identifies the **existing unresolved Task**, not the new Task. Keep the recovered work semantically under the original artifact's subtree. Do not invent a resolution or silently cancel unrelated failed work.
|
|
99
|
+
- **Resolution is mandatory for replacement work:** before returning any `next_tasks`, inspect every existing `FAILED` or `BLOCKED` work Task. If it lacks an active/waiting `resolution` or durable `resolutionClassification` and the proposed batch creates related/sibling work, explicitly cover it with a valid `resolution` on the new recovery Task. A superseded/invalid classification covers only planning validation; preserve the FAILED state and never infer success from the classification. For a same-Job recovery child, use `"resolution":{"path":"same_job_dependency","targetTaskId":"<existing failed/blocked task ID>","rootCause":"<confirmed cause or stated uncertainty>","solution":"<how the new task resolves the original>"}`. `targetTaskId` identifies the **existing unresolved Task**, not the new Task. Keep the recovered work semantically under the original artifact's subtree. Do not invent a resolution or silently cancel unrelated failed work.
|
|
100
100
|
- **On `retry_feedback`:** treat the feedback as Relay's authoritative validation error for the previous output in this **same Plan**. Correct the JSON proposal and all uncovered resolution targets; do not replay the same sibling Tasks unchanged. Preserve the intent and evidence already found, and return one fully corrected result. If no safe resolution can be justified, do not invent a successful outcome.
|
|
101
101
|
|
|
102
|
+
### Forced failed-history cleanup
|
|
103
|
+
|
|
104
|
+
When `force_clean_history` is true, inspect every ID in `clean_history_task_ids` against the authoritative Task records and dependency/evidence context before planning. Do not infer a result from text alone. For a failed review that is demonstrably stale, run the existing guarded `agents-relay task cancel` command. For a failed work Task with evidence that replacement work covers it, run the existing guarded `agents-relay task classify --classification superseded`; for proven invalid work, use `--classification invalid`. Supply a different durable `--target-task-id`, and the same concrete `--evidence` and `--reason` in your report. Use the normal repository, PR, and Job options from this invocation. Never change task state directly or claim an action succeeded unless the CLI command succeeds and a fresh `task show` confirms it.
|
|
105
|
+
|
|
106
|
+
If evidence is absent or execution outcome is unknown, leave the Task unresolved and do not classify it, cancel ordinary failed work, or claim success. Do not create related replacement work for an unresolved failure. After all selected guarded commands succeed, refresh authoritative Task state and submit the Plan through the standard `plan apply` command. Include exactly one `history_cleanup` entry for each `clean_history_task_ids` item: `{ "taskId", "outcome": "superseded"|"invalid"|"cancelled_review"|"unresolved", "evidence", "reason", "targetTaskId?" }`. For unresolved items, state what evidence is missing. Relay validates that reported mutations match durable SQLite state; an ordinary text response is never a Plan receipt.
|
|
107
|
+
|
|
102
108
|
- Prefer independently verifiable Tasks with stable IDs, but do not split work solely to make Tasks smaller.
|
|
103
109
|
- When a Task depends on mutable repository or product state, distinguish a frozen evidence target from the current execution head. A SHA may freeze that Task's own review/source evidence, but do not copy an upstream SHA into a dependent release, deploy, publication, or other follow-on Task unless the objective explicitly requires that same frozen target. After dependencies complete, downstream Tasks must resolve and verify the current durable Job/PR head before acting.
|
|
104
110
|
- When fresh UI, browser, store, runtime, or visual evidence is material to the Task outcome, say so explicitly in the Task input and require evidence from the real current state. Existing screenshots, prepared assets, or prior observations are not substitutes for fresh evidence unless the objective explicitly allows reuse.
|
|
@@ -117,7 +123,7 @@ Do not create separate Tasks merely for "analyze", "format", "call tool", "check
|
|
|
117
123
|
|
|
118
124
|
## Output
|
|
119
125
|
|
|
120
|
-
Return exactly one JSON object with `objective_status`, `assessment`, `next_tasks`, and optional top-level `unblocks`.
|
|
126
|
+
Return exactly one JSON object with `objective_status`, `assessment`, `next_tasks`, and optional top-level `unblocks`. In forced cleanup mode, also return required `history_cleanup` as specified above.
|
|
121
127
|
|
|
122
128
|
For `objective_status: "in_progress"`, `assessment` must be Markdown containing:
|
|
123
129
|
|
|
@@ -114,6 +114,18 @@ A tag must describe something specific to the Job contract, not Relay lifecycle.
|
|
|
114
114
|
For the ChatGPT Browser Worker guardian, for example, the Job defines `pass -> ✅` and `fail -> ❌`; each hourly E2E child Task reports `data.tag` after running the fixed deterministic E2E, while the Task lifecycle independently records whether Relay execution itself completed, failed, or became blocked.
|
|
115
115
|
|
|
116
116
|
|
|
117
|
+
### Task resolution classifications
|
|
118
|
+
|
|
119
|
+
`Task.resolutionClassification` is durable diagnostic metadata, separate from
|
|
120
|
+
`Task.state` and the recovery `Task.resolution` path. The canonical Task
|
|
121
|
+
Resolution Graph and `task classify` CLI action may classify a known FAILED
|
|
122
|
+
work Task as `superseded` or `invalid`; each record requires a different
|
|
123
|
+
durable target Task, evidence, and reason. Classification never changes the
|
|
124
|
+
FAILED lifecycle state, execution history, or result and never proves success.
|
|
125
|
+
Unknown outcomes, active executions, and Tasks with active recovery ownership
|
|
126
|
+
cannot be classified. Planners must preserve these fields in their assessment
|
|
127
|
+
and may treat classified history as covered when validating a fresh Plan.
|
|
128
|
+
|
|
117
129
|
### Optional Review Chair Tasks
|
|
118
130
|
|
|
119
131
|
Projects may use the shared `review-chair` agent as a normal Task or Agent
|
|
@@ -23,6 +23,12 @@ fresh["guard: freshRecoveryExecution"]
|
|
|
23
23
|
blocker["guard: blockerJobDependency"]
|
|
24
24
|
gate["guard: escalationGate"]
|
|
25
25
|
obsoleteGuard["guard: genuinelyObsolete"]
|
|
26
|
+
classifiable["guard: classifiableFailure"]
|
|
27
|
+
classificationKind["guard: classificationSuperseded"]
|
|
28
|
+
classifySuperseded["action: resolution.classifySuperseded"]
|
|
29
|
+
classifyInvalid["action: resolution.classifyInvalid"]
|
|
30
|
+
supersededEvidence["guard: supersededEvidenceComplete"]
|
|
31
|
+
invalidEvidence["guard: invalidEvidenceComplete"]
|
|
26
32
|
|
|
27
33
|
unresolved -->|on: resolution.propose| checkOwner
|
|
28
34
|
checkOwner -->|on: resolution.propose| recoveryOwner
|
|
@@ -49,3 +55,12 @@ sameJob -->|to: resolved| resolved
|
|
|
49
55
|
blockerJob -->|to: resolved| resolved
|
|
50
56
|
escalate -->|to: resolved| resolved
|
|
51
57
|
obsolete -->|to: resolved| resolved
|
|
58
|
+
unresolved -->|on: resolution.classify| classifiable
|
|
59
|
+
classifiable -->|true| classificationKind
|
|
60
|
+
classifiable -->|false| rejected
|
|
61
|
+
classificationKind -->|true| supersededEvidence
|
|
62
|
+
classificationKind -->|false| invalidEvidence
|
|
63
|
+
supersededEvidence -->|true| classifySuperseded
|
|
64
|
+
invalidEvidence -->|true| classifyInvalid
|
|
65
|
+
classifySuperseded --> resolved
|
|
66
|
+
classifyInvalid --> resolved
|