agents-relay 2.0.76 → 2.0.77

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/blockers.js CHANGED
@@ -1,6 +1,7 @@
1
1
  import { applyTaskReleaseAction, applyTaskUnblockRetryAction, dispatchTaskAction } from './task-actions.js';
2
2
  import { isEffectivelyBlocked, unblockAutonomousTasks, unblockTask } from './unblock.js';
3
3
  import { dispatchBlockerResolution } from './workflow/blocker-resolution-v2.js';
4
+ import { dispatchTaskResolutionSync, materializeResolution } from './workflow/task-resolution.js';
4
5
  function dependentKey(value) {
5
6
  return `${value.repository}#${value.prNumber}:${value.jobId}:${value.taskId}:a${value.blockedAttempt}`;
6
7
  }
@@ -16,6 +17,12 @@ export async function registerBlockerDependency(options) {
16
17
  if (!['DRAFT', 'FAILED', 'BLOCKED'].includes(task.state))
17
18
  throw new Error(`Dependent task ${task.id} is not waiting/blocked/failed (${task.state})`);
18
19
  const now = options.now ?? new Date().toISOString();
20
+ const resolutionProposal = { path: 'blocker_job_dependency', targetTaskId: task.id, targetJobId: blocker.id, mode: options.resolutionMode ?? blocker.blockerResolutionMode ?? 'merge' };
21
+ const resolutionDecision = dispatchTaskResolutionSync(task, resolutionProposal, 'blocker-job', { registerBlockerDependency: () => {
22
+ task.resolution = { ...materializeResolution(task, resolutionProposal, 'blocker-job', now), state: 'waiting' };
23
+ } });
24
+ if (!resolutionDecision.accepted)
25
+ throw new Error(`Task resolution graph rejected blocker dependency for ${task.id}`);
19
26
  const relation = {
20
27
  repository: dependentJob.repository,
21
28
  prNumber: dependentJob.prNumber,
package/dist/cli-help.js CHANGED
@@ -60,7 +60,7 @@ Optional: --thread-id THREAD_ID persists a reviewer override; omitted uses Job r
60
60
 
61
61
  Required: --repo OWNER/REPO, --id JOB_ID, and either --head BRANCH or --pr NUMBER.
62
62
  With --head, bind the local source branch and use --base as its local merge target without a GitHub query. With --pr, bind an existing external PR reference as optional metadata without a GitHub query.
63
- Common: --base BRANCH (default: main), --title TEXT, --objective-file PATH|- (preferred), --objective MARKDOWN (short/simple only), --project-path RELATIVE_PATH, --priority P0|P1|P2|P3, --mode fixed|autonomous, --initial-plan, --no-review, --tag JSON, --reviewer-thread-id ID, --creator-thread-id ID. --objective and --objective-file are mutually exclusive. For a new --head Job, one is required and must contain enough standalone context to complete the Job without the originating chat/thread. Objective Markdown is also the default PR body; --body is an explicit override.
63
+ Common: --base BRANCH (default: main), --title TEXT, --objective-file PATH|- (preferred), --objective MARKDOWN (short/simple only), --project-path RELATIVE_PATH, --priority P0|P1|P2|P3, --mode fixed|autonomous, --initial-plan, --no-review, --tag JSON, --reviewer-thread-id ID, --creator-thread-id ID, --implementation-thread-id ID. --objective and --objective-file are mutually exclusive. For a new --head Job, one is required and must contain enough standalone context to complete the Job without the originating chat/thread. Objective Markdown is also the default PR body; --body is an explicit override.
64
64
  Job creation always enables auto-merge. By default it is create-only; --initial-plan follows durable Job creation with the first real Planner round, activates the Job, and publishes the normal wake so Relay can continue without a second orchestrator command. Re-running create against an already healthy Job is idempotent and does not mutate its metadata.
65
65
 
66
66
  Example:
@@ -68,7 +68,7 @@ Example:
68
68
  'job update': `Partially update mutable metadata on a healthy existing Job.
69
69
 
70
70
  Required: --repo OWNER/REPO, --pr NUMBER, and --id JOB_ID.
71
- Mutable: --title, --objective-file PATH|- (preferred) or --objective MARKDOWN, --project-path RELATIVE_PATH, --priority, --mode fixed|autonomous, --schedule, --tag JSON, --auto-merge|--no-auto-merge, --review|--no-review, --reviewer-thread-id ID, --creator-thread-id ID, and continuation options. The objective updates local SQLite; PR body changes are independent external actions.
71
+ Mutable: --title, --objective-file PATH|- (preferred) or --objective MARKDOWN, --project-path RELATIVE_PATH, --priority, --mode fixed|autonomous, --schedule, --tag JSON, --auto-merge|--no-auto-merge, --review|--no-review, --reviewer-thread-id ID, --creator-thread-id ID, --implementation-thread-id ID, and continuation options. The objective updates local SQLite; PR body changes are independent external actions.
72
72
  Unspecified fields are preserved. Local Job/PR identity mismatch is rejected.
73
73
 
74
74
  Example:
@@ -58,6 +58,9 @@ export async function ensureManagedGitHubJob(_client, repositoryName, _trustedAu
58
58
  ...(options.creatorThreadId
59
59
  ? { creatorThreadId: normalizeThreadId(options.creatorThreadId, 'creator thread ID') }
60
60
  : {}),
61
+ ...(options.implementationThreadId
62
+ ? { implementationThreadId: normalizeThreadId(options.implementationThreadId, 'implementation thread ID') }
63
+ : {}),
61
64
  prNumber: pr.number,
62
65
  repository: repositoryName,
63
66
  continuation: options.continuation ?? null,
package/dist/cli.js CHANGED
@@ -380,7 +380,7 @@ async function runJobCommand(action, args) {
380
380
  const result = await relayControlCommand(args, { command: 'job.create', repository: repo, initialPlan: hasArg(args, '--initial-plan'), options: {
381
381
  id, title: arg(args, '--title') || undefined, objective: objective || undefined, projectPath: arg(args, '--project-path') || undefined,
382
382
  priority: priority(args), executionMode: requestedExecutionMode(args), schedule: scheduleFromArgs(args), tag: jobTagFromArgs(args), reviewEnabled: !hasArg(args, '--no-review'), continuation: continuation(args),
383
- reviewerThreadId: hasArg(args, '--reviewer-thread-id') ? arg(args, '--reviewer-thread-id') : undefined, creatorThreadId: hasArg(args, '--creator-thread-id') ? arg(args, '--creator-thread-id') : undefined,
383
+ reviewerThreadId: hasArg(args, '--reviewer-thread-id') ? arg(args, '--reviewer-thread-id') : undefined, creatorThreadId: hasArg(args, '--creator-thread-id') ? arg(args, '--creator-thread-id') : undefined, implementationThreadId: hasArg(args, '--implementation-thread-id') ? arg(args, '--implementation-thread-id') : undefined,
384
384
  prNumber, head: arg(args, '--head') || undefined, base: arg(args, '--base', 'main'), body: arg(args, '--body') || objective || undefined,
385
385
  localMergeSource: arg(args, '--local-merge-source') || undefined, localMergeTarget: arg(args, '--local-merge-target') || undefined,
386
386
  } });
@@ -448,6 +448,8 @@ async function runJobCommand(action, args) {
448
448
  job.reviewerThreadId = normalizeThreadId(arg(args, '--reviewer-thread-id'), '--reviewer-thread-id');
449
449
  if (hasArg(args, '--creator-thread-id'))
450
450
  job.creatorThreadId = normalizeThreadId(arg(args, '--creator-thread-id'), '--creator-thread-id');
451
+ if (hasArg(args, '--implementation-thread-id'))
452
+ job.implementationThreadId = normalizeThreadId(arg(args, '--implementation-thread-id'), '--implementation-thread-id');
451
453
  if (hasArg(args, '--continue-kind') || hasArg(args, '--continue-target')) {
452
454
  const next = continuation(args);
453
455
  if (!next)
@@ -455,7 +457,7 @@ async function runJobCommand(action, args) {
455
457
  job.continuation = next;
456
458
  }
457
459
  const patch = {};
458
- for (const key of ['title', 'objective', 'projectPath', 'priority', 'executionMode', 'schedule', 'tag', 'autoMerge', 'reviewEnabled', 'reviewerThreadId', 'creatorThreadId', 'continuation', 'localMerge'])
460
+ for (const key of ['title', 'objective', 'projectPath', 'priority', 'executionMode', 'schedule', 'tag', 'autoMerge', 'reviewEnabled', 'reviewerThreadId', 'creatorThreadId', 'implementationThreadId', 'continuation', 'localMerge'])
459
461
  patch[key] = job[key];
460
462
  console.log(JSON.stringify(await relayControlCommand(args, { command: 'job.update', jobId: id, patch }), null, 2));
461
463
  }
@@ -403,7 +403,7 @@ function taskStateField(task){const box=element('div','detail-group');box.append
403
403
  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):'—');}
404
404
  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('Task',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));taskDetailRoot.append(groups);}
405
405
  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();}
406
- 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)),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();}
406
+ 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||'—'),field('Implementation thread',job.implementationThreadId||'—'),field('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();}
407
407
  function renderAll(){jobs.sort(compareJobs);syncFilters();renderJobList();renderSelectedJob();syncMobileJobsView();}
408
408
  function isCompleteUnmerged(job){return job.runtimeStatus==='MERGE_PENDING';}
409
409
  function isTerminalJob(job){return ['COMPLETED','CANCELLED'].includes(job.state)||['MERGED','CLOSED'].includes(job.githubState);}
@@ -0,0 +1,12 @@
1
+ export function authoritativeBranchPushArgs(options) {
2
+ const refspec = `${options.sourceRef}:refs/heads/${options.branch}`;
3
+ if (options.remoteHeadSha && options.remoteTreeMatchesBase) {
4
+ return [
5
+ 'push',
6
+ 'origin',
7
+ `--force-with-lease=refs/heads/${options.branch}:${options.remoteHeadSha}`,
8
+ refspec,
9
+ ];
10
+ }
11
+ return ['push', 'origin', refspec];
12
+ }
@@ -36,6 +36,13 @@ ${projectedReview
36
36
  : isLocalJob
37
37
  ? `Treat the following durable task input as the authoritative work contract; do not query GitHub.\n\n${task.input}`
38
38
  : `Retrieve the durable job and task from GitHub before doing the work. Use whatever GitHub access this environment provides: the URLs, a GitHub integration/tool, or GitHub CLI/API. GitHub is the source of truth for task intent and declared output.`}
39
+ \n[Authoritative execution context]
40
+ Job objective: ${job.objective ?? '(none recorded)'}
41
+ Plan: ${task.planningDecisionId ? `${task.planningDecisionId} (round ${task.planningRound ?? 'unknown'})` : 'directly created Task'}
42
+ Task attempt/execution: ${task.attempt}/${task.maxAttempts} / ${task.executionId}
43
+ Dependencies: ${task.dependencies.length ? task.dependencies.join(', ') : 'none'}
44
+ ${task.attempt > 1 || task.error ? `Retry summary: prior error=${task.error ?? 'none'}; prior generations=${(task.executionHistory ?? []).map(item => `${item.id}@${item.attempt}`).join(', ') || 'none'}` : 'Retry summary: first attempt'}
45
+ SQLite/RelayStore and the canonical Mermaid Graphs own lifecycle, merge, recovery, and completion decisions. Do not perform Relay-owned merge or completion actions from this worker.
39
46
  ${context ? executionContextPrompt(context) + '\n\n' : ''}${selectedAgent ? selectedAgent + '\n\n' : ''}${selectedGraph ? selectedGraph + '\n\n' : ''}${chairPlan ? `[Review Chair execution plan]\n${JSON.stringify(chairPlan, null, 2)}\n\nUse this runtime-discovered panel to create sibling durable specialist Tasks. Preserve each specialistTasks entry as review metadata, keep the target immutable, and synthesize structured results with synthesizeReview semantics before returning the unified revision plan.\n\n` : ''}Follow the resolved project instructions and Agents Relay/events-bus worker contracts.
40
47
  [Output contract]
41
48
  ${output}
@@ -1,7 +1,8 @@
1
1
  import { randomUUID } from 'node:crypto';
2
2
  import { jobHasActiveExecution } from './job-execution-slot.js';
3
3
  import { eventFor } from './events.js';
4
- import { materializePlannerChild, parsePlannerResult, validatePlannerHierarchy, validatePlannerLifecycleOwnership, validatePlannerProgress } from './planner.js';
4
+ import { materializePlannerChild, parsePlannerResult, validatePlannerHierarchy, validatePlannerLifecycleOwnership, validatePlannerProgress, validatePlannerResolutionMaterialization } from './planner.js';
5
+ import { attachResolutionOwnership } from './workflow/task-resolution.js';
5
6
  import { samePlannerChildDefinition } from './planner-child.js';
6
7
  import { dispatchJobLifecycleSync } from './workflow/lifecycle.js';
7
8
  import { withExecutionBoundary } from './reconciler-runtime.js';
@@ -121,6 +122,7 @@ async function finishPlanner(ports, jobId, planId, executionId, error, raw) {
121
122
  const result = parsePlannerResult(raw);
122
123
  const executionContext = await ports.plannerContext(job, plan);
123
124
  validatePlannerHierarchy(job.tasks, result.next_tasks);
125
+ validatePlannerResolutionMaterialization(job.tasks, result.next_tasks);
124
126
  validatePlannerLifecycleOwnership(job, result.next_tasks);
125
127
  validatePlannerProgress(job, result);
126
128
  if (result.unblocks.length > 0 && !ports.store.registerBlockerDependent)
@@ -154,6 +156,9 @@ async function finishPlanner(ports, jobId, planId, executionId, error, raw) {
154
156
  current.changeCycle.consumedByPlannerTaskId = durablePlan.id;
155
157
  }
156
158
  for (const child of children) {
159
+ const spec = result.next_tasks.find(item => item.id === child.id);
160
+ if (spec?.resolution)
161
+ attachResolutionOwnership(current, child, spec.resolution, 'planner');
157
162
  const existing = current.tasks.find(item => item.id === child.id);
158
163
  if (!existing)
159
164
  current.tasks.push(structuredClone(child));
@@ -193,8 +198,12 @@ async function finishPlanner(ports, jobId, planId, executionId, error, raw) {
193
198
  await ports.store.saveJob(job);
194
199
  }
195
200
  for (const child of children)
196
- if (!job.tasks.some(item => item.id === child.id))
201
+ if (!job.tasks.some(item => item.id === child.id)) {
202
+ const spec = result.next_tasks.find(item => item.id === child.id);
203
+ if (spec?.resolution)
204
+ await ports.store.saveTask(attachResolutionOwnership(job, child, spec.resolution, 'planner'));
197
205
  await ports.store.appendTask(child);
206
+ }
198
207
  }
199
208
  planCommitted = true;
200
209
  // Cross-Job registrations cannot join this Job's SQLite transaction. The
package/dist/planner.js CHANGED
@@ -3,10 +3,12 @@ import { parseTestInjection } from './e2e-injection.js';
3
3
  import { fileURLToPath } from 'node:url';
4
4
  import { dirname, join } from 'node:path';
5
5
  import { storySizeTimeoutMs, taskOverallTimeoutMs } from './types.js';
6
+ import { validateResolutionProposal, unresolvedTask } from './workflow/task-resolution.js';
6
7
  import { executionContextPrompt } from './execution-context.js';
7
8
  import { parseAgentGraph, validateAgentSelection } from './agent-graph.js';
8
9
  import { dispatchPlannerTrigger } from './workflow/planner-trigger-v2.js';
9
10
  import { createTaskEntity } from './workflow/entity-creation.js';
11
+ import { prepareReviewerThreadDelivery, chatgptThreadRequirements } from './reviewer-thread.js';
10
12
  import { spawnOwnedProcess } from './owned-process.js';
11
13
  export function semanticTaskTree(tasks) {
12
14
  const work = tasks.filter(task => task.kind !== 'planner');
@@ -102,14 +104,22 @@ export function materializePlannerChild(job, planner, spec, executionContext) {
102
104
  const round = 'round' in planner ? planner.round : planner.planningRound ?? 1;
103
105
  const plannerHeadSha = 'round' in planner ? planner.headSha : undefined;
104
106
  const reviewHeadSha = spec.agentName === 'reviewer' && !(spec.dependencies?.length) ? plannerHeadSha ?? job.headSha : undefined;
105
- return createTaskEntity(job, { jobId: job.id, id: spec.id, 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: spec.workerRequirements, testInjection: spec.testInjection, adapter: 'codex', 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 });
107
+ const resolution = spec.resolution;
108
+ if (resolution) {
109
+ const target = resolution.targetTaskId ? job.tasks.find(task => task.id === resolution.targetTaskId) : undefined;
110
+ if (!target || !unresolvedTask(target))
111
+ throw new Error(`Planner resolution for ${spec.id} must target one unresolved durable Task`);
112
+ validateResolutionProposal(job, target, resolution);
113
+ }
114
+ const reviewerThread = spec.agentName === 'reviewer' ? prepareReviewerThreadDelivery(job, { id: spec.id, agentName: spec.agentName, reviewHeadSha }) : undefined;
115
+ return createTaskEntity(job, { jobId: job.id, id: spec.id, 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: reviewerThread ? chatgptThreadRequirements(spec.workerRequirements) : spec.workerRequirements, testInjection: spec.testInjection, adapter: reviewerThread ? 'chatgpt' : '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 });
106
116
  }
107
117
  export function planningSpec(task) {
108
118
  if (!task.output && task.adapter !== 'orchestrator')
109
119
  throw new Error(`Initial task ${task.id} requires an output contract`);
110
120
  if (!['codex', 'chatgpt', 'orchestrator'].includes(task.adapter))
111
121
  throw new Error(`Initial task ${task.id} requires a supported adapter`);
112
- return { id: task.id, parentTaskId: task.parentTaskId, input: task.input, priority: task.priority, storySize: task.storySize, projectName: task.projectName, agentName: task.agentName, agentGraph: task.agentGraph, review: task.review, dependencies: task.dependencies, capabilities: task.capabilities, workerRequirements: task.workerRequirements, testInjection: task.testInjection, adapter: task.adapter, output: task.output ?? { kind: 'task_pr' }, routing: task.routing, continuation: task.continuation, maxAttempts: task.maxAttempts, timeoutMs: task.timeoutMs };
122
+ return { id: task.id, parentTaskId: task.parentTaskId, input: task.input, priority: task.priority, storySize: task.storySize, projectName: task.projectName, agentName: task.agentName, agentGraph: task.agentGraph, review: task.review, dependencies: task.dependencies, capabilities: task.capabilities, workerRequirements: task.workerRequirements, testInjection: task.testInjection, adapter: task.adapter, output: task.output ?? { kind: 'task_pr' }, routing: task.routing, continuation: task.continuation, maxAttempts: task.maxAttempts, timeoutMs: task.timeoutMs, resolution: task.resolution ? { ...task.resolution, state: undefined, owner: undefined, updatedAt: undefined } : undefined };
113
123
  }
114
124
  function record(value, field = 'result') {
115
125
  if (!value || typeof value !== 'object' || Array.isArray(value))
@@ -204,6 +214,20 @@ function routing(value, field) {
204
214
  throw new Error(`Planner ${field}.${key} is required`);
205
215
  return { provider: route.provider, model: route.model, profile: optionalString(route.profile, `${field}.profile`), reasoning: optionalString(route.reasoning, `${field}.reasoning`), cwd: optionalString(route.cwd, `${field}.cwd`), projectId: optionalString(route.projectId, `${field}.projectId`), decidedBy: route.decidedBy, decidedAt: route.decidedAt };
206
216
  }
217
+ function resolutionProposal(value, field) {
218
+ if (value === undefined || value === null)
219
+ return undefined;
220
+ const root = record(value, field);
221
+ const path = root.path;
222
+ if (typeof path !== 'string' || !['exact_original_retry', 'same_job_dependency', 'blocker_job_dependency', 'escalation_gate', 'cancel_supersede'].includes(path))
223
+ throw new Error(`Planner ${field}.path is invalid`);
224
+ const stringField = (name) => optionalString(root[name], `${field}.${name}`);
225
+ const mode = root.mode === undefined ? undefined : String(root.mode);
226
+ if (mode !== undefined && mode !== 'merge' && mode !== 'verified')
227
+ throw new Error(`Planner ${field}.mode must be merge or verified`);
228
+ const list = (name) => root[name] === undefined ? undefined : stringArray(root[name], `${field}.${name}`);
229
+ return { path: path, targetTaskId: stringField('targetTaskId'), targetJobId: stringField('targetJobId'), mode: mode, rootCause: stringField('rootCause'), solution: stringField('solution'), deviations: list('deviations'), validationEvidence: list('validationEvidence') };
230
+ }
207
231
  export function parsePlannerResult(value) {
208
232
  const root = record(value, 'result');
209
233
  const status = root.objective_status;
@@ -289,7 +313,7 @@ export function parsePlannerResult(value) {
289
313
  dependencies: stringArray(task.dependencies, 'dependencies'), capabilities: stringArray(task.capabilities, 'capabilities'), workerRequirements: requirements,
290
314
  testInjection: parseTestInjection(task.testInjection ?? task['--test-injection']) ?? optionalString(task.testInjection ?? task['--test-injection'], `next_tasks[${index}].--test-injection`),
291
315
  adapter: adapter, output: output, routing: route,
292
- continuation: task.continuation, maxAttempts, timeoutMs
316
+ continuation: task.continuation, maxAttempts, timeoutMs, resolution: resolutionProposal(task.resolution, `next_tasks[${index}].resolution`)
293
317
  };
294
318
  });
295
319
  return { objective_status: status, assessment: root.assessment, next_tasks, unblocks };
@@ -351,6 +375,18 @@ export function validatePlannerHierarchy(existing, specs) {
351
375
  }
352
376
  }
353
377
  }
378
+ /** Planner may only materialize a new business Task while unresolved work is either
379
+ * explicitly resolved in this round or already owned by an active resolution path. */
380
+ export function validatePlannerResolutionMaterialization(existing, specs) {
381
+ const unresolved = existing.filter(task => unresolvedTask(task) && ['FAILED', 'BLOCKED'].includes(task.state));
382
+ if (unresolved.length === 0)
383
+ return;
384
+ const resolvedIds = new Set(specs.map(spec => spec.resolution?.targetTaskId).filter((id) => Boolean(id)));
385
+ const owned = unresolved.filter(task => task.resolution && ['active', 'waiting'].includes(task.resolution.state));
386
+ const uncovered = unresolved.filter(task => !resolvedIds.has(task.id) && !owned.some(candidate => candidate.id === task.id));
387
+ if (uncovered.length > 0 && specs.length > 0)
388
+ throw new Error(`Planner cannot create sibling replacement work while unresolved Task(s) lack a graph resolution: ${uncovered.map(task => task.id).join(', ')}`);
389
+ }
354
390
  export function parsePlannerOutput(output) {
355
391
  const trimmed = output.trim();
356
392
  const candidate = trimmed.replace(/^```(?:json)?\s*/i, '').replace(/\s*```$/, '').trim();
@@ -9,7 +9,7 @@ import { dispatchReviewLifecycle, reviewOutcome } from './workflow/review-v2.js'
9
9
  import { dispatchTaskLifecycleSync } from './workflow/lifecycle.js';
10
10
  import { createTaskEntity } from './workflow/entity-creation.js';
11
11
  import { localSourceHead, localWorkingHead } from './local-git-merge.js';
12
- import { chatgptThreadRequirements, resolveReviewerThreadDestination } from './reviewer-thread.js';
12
+ import { chatgptThreadRequirements, resolveReviewerThreadDestination, resolveImplementationTaskThreadDestination, prepareReviewerThreadDelivery } from './reviewer-thread.js';
13
13
  import { reviewGenerationMatches } from './review-generation-fence.js';
14
14
  export class ReconcilerReviewBase extends ReconcilerRuntimeBase {
15
15
  async currentHeadSha(job) {
@@ -74,7 +74,8 @@ export class ReconcilerReviewBase extends ReconcilerRuntimeBase {
74
74
  }).filter(Boolean);
75
75
  }
76
76
  static normalizeReviewEvidence(event, task) {
77
- const headSha = ReconcilerReviewBase.reviewHeadSha(event);
77
+ const explicitHeadSha = ReconcilerReviewBase.reviewHeadSha(event);
78
+ const headSha = explicitHeadSha ?? (event.type === 'review.approved' ? task.reviewHeadSha : undefined);
78
79
  if (!headSha)
79
80
  return null;
80
81
  return { headSha, issues: ReconcilerReviewBase.reviewIssues(event) };
@@ -130,16 +131,9 @@ export class ReconcilerReviewBase extends ReconcilerRuntimeBase {
130
131
  const reviewGateTasks = implementationTasks(job);
131
132
  const reviewThread = resolveReviewerThreadDestination(job, this.options.reviewer?.threadId);
132
133
  const reviewerRouting = await this.selectReviewer(reviewGateTasks, reviewThread ? chatgptThreadRequirements(this.options.reviewer?.requirements) : undefined);
133
- const chatgptThreadDelivery = reviewThread ? {
134
- kind: 'new-turn',
135
- threadId: reviewThread.threadId,
136
- threadSource: reviewThread.source,
137
- requestId: `${job.id}:${id}:${headSha}`,
138
- jobId: job.id,
139
- prNumber: reviewPrNumber,
140
- reviewHeadSha: headSha,
141
- projectId: reviewerRouting.candidate.routing.projectId ?? null,
142
- } : undefined;
134
+ const chatgptThreadDelivery = prepareReviewerThreadDelivery(job, {
135
+ id, agentName: 'reviewer', reviewHeadSha: headSha, routing: reviewerRouting.candidate.routing,
136
+ }, this.options.reviewer?.threadId);
143
137
  const input = [
144
138
  `Job ID: ${job.id}`,
145
139
  `Review PR #${reviewPrNumber} at exactly head SHA ${headSha}.`,
@@ -165,7 +159,23 @@ export class ReconcilerReviewBase extends ReconcilerRuntimeBase {
165
159
  await this.emit(eventFor(job.id, id, null, '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 } }));
166
160
  return id;
167
161
  }
162
+ async projectedReviewHeadReady(job, headSha) {
163
+ // Local-first Jobs may expose a review-only GitHub PR. Once such a PR is
164
+ // bound, its projected head must match the authoritative local head before
165
+ // a reviewer is asked to review that immutable PR identity.
166
+ if (!job.externalPrNumber || job.externalPrNumber <= 0 || !this.store.pullRequestHead)
167
+ return true;
168
+ try {
169
+ const projected = await this.store.pullRequestHead();
170
+ return projected?.sha === headSha;
171
+ }
172
+ catch {
173
+ return false;
174
+ }
175
+ }
168
176
  async ensureFinalReview(job, headSha) {
177
+ if (headSha && !(await this.projectedReviewHeadReady(job, headSha)))
178
+ return;
169
179
  let reviewTaskId = null;
170
180
  const decision = await dispatchReviewLifecycle(job.review?.outcome, 'reconcile', this.reviewFacts(job, headSha), async (actionId) => {
171
181
  if (actionId !== 'review.create')
@@ -239,15 +249,17 @@ export class ReconcilerReviewBase extends ReconcilerRuntimeBase {
239
249
  };
240
250
  }
241
251
  async routeTaskForExecution(job, task) {
242
- if (task.routing)
252
+ const implementationThread = resolveImplementationTaskThreadDestination(job, task);
253
+ const reviewerThread = task.agentName === 'reviewer' ? resolveReviewerThreadDestination(job, this.options.reviewer?.threadId) : null;
254
+ if (task.routing && !((implementationThread || reviewerThread) && task.adapter !== 'chatgpt'))
243
255
  return { task, routed: false };
244
256
  if (!task.output || task.adapter === 'orchestrator')
245
257
  return { task, routed: false };
246
258
  const usage = this.options.usageSnapshotProvider ? await this.options.usageSnapshotProvider() : undefined;
247
259
  const registered = await this.store.listAgents();
248
- const candidates = workerCandidates(this.options.adapters, registered, usage);
260
+ const candidates = workerCandidates(this.options.adapters, registered, usage).filter(candidate => (implementationThread || reviewerThread) ? candidate.adapter === 'chatgpt' : true);
249
261
  const runtimeSummary = this.options.runtimeSummaryProvider?.();
250
- const requirements = task.workerRequirements ?? { capabilities: [] };
262
+ const requirements = (implementationThread || reviewerThread) ? chatgptThreadRequirements(task.workerRequirements) : (task.workerRequirements ?? { capabilities: [] });
251
263
  const taskFacts = {
252
264
  job: {
253
265
  id: job.id, title: job.title, objective: job.objective ?? null,
@@ -313,9 +325,7 @@ export class ReconcilerReviewBase extends ReconcilerRuntimeBase {
313
325
  return { applied: false, reconcile: false, reason: event.type + '.stale' };
314
326
  // Planner-created reviewer tasks predate the final-review Job gate. Bind
315
327
  // their first trustworthy review event to PENDING before dispatching it.
316
- if (!job.review && task.planningDecisionId) {
317
- job.review = { outcome: 'pending', headSha, taskId: task.id, evidence: null, updatedAt: event.timestamp || new Date().toISOString() };
318
- }
328
+ const initialReviewOutcome = !job.review && task.planningDecisionId ? 'pending' : job.review?.outcome;
319
329
  const taskDecision = this.store.mutateJob
320
330
  ? dispatchTaskLifecycleSync(structuredClone(task), event.type)
321
331
  : tryTransitionTaskEvent(task, event.type);
@@ -332,7 +342,7 @@ export class ReconcilerReviewBase extends ReconcilerRuntimeBase {
332
342
  }
333
343
  let needsReconcile = false;
334
344
  let pendingFixTask = null;
335
- const reviewDecision = await dispatchReviewLifecycle(job.review?.outcome, event.type, { autoMerge: job.autoMerge !== false }, async (actionId) => {
345
+ const reviewDecision = await dispatchReviewLifecycle(initialReviewOutcome, event.type, { autoMerge: job.autoMerge !== false }, async (actionId) => {
336
346
  if (actionId === 'review.createFix') {
337
347
  needsReconcile = true;
338
348
  const issues = normalizedEvidence.issues;
@@ -371,13 +381,22 @@ export class ReconcilerReviewBase extends ReconcilerRuntimeBase {
371
381
  const freshTask = current.tasks.find(item => item.id === task.id);
372
382
  if (!freshTask || !reviewGenerationMatches(current, job, task, headSha))
373
383
  return current;
374
- const freshDecision = tryTransitionTaskEvent(freshTask, event.type);
384
+ const candidate = structuredClone(freshTask);
385
+ const freshDecision = tryTransitionTaskEvent(candidate, event.type);
375
386
  if (!freshDecision.accepted || !freshDecision.target)
376
387
  return current;
377
388
  if (freshDecision.target !== 'RUNNING') {
378
- if (!this.finalizeReviewExecution(job.id, freshTask, event, false))
379
- return current;
389
+ // Capture the execution generation before finalization clears it.
390
+ // Post-commit cleanup must cancel the matching live watchdog.
380
391
  terminalExecutionId = freshTask.executionId;
392
+ if (!this.finalizeReviewExecution(job.id, candidate, event, false))
393
+ return current;
394
+ }
395
+ Object.assign(freshTask, candidate);
396
+ // Bind the first Planner review only after the generation fence has
397
+ // checked the unmodified outer snapshot and authoritative SQLite state.
398
+ if (!current.review && freshTask.planningDecisionId) {
399
+ current.review = { outcome: 'pending', headSha, taskId: freshTask.id, evidence: null, updatedAt: reviewUpdatedAt };
381
400
  }
382
401
  freshTask.error = null;
383
402
  if (freshDecision.target !== 'RUNNING')
@@ -93,8 +93,6 @@ export class ReconcilerRuntimeBase {
93
93
  const result = await this.applyTerminalEvent(event);
94
94
  if (event.type !== 'task.failed' || event.source?.watchdog !== true || event.data?.workflowEvent !== 'watchdog.failed' || result.reconcile)
95
95
  return result;
96
- // The watchdog has already committed FAILED. The graph owns the recovery
97
- // action; this correlated event wakes the graph evaluation, not a retry.
98
96
  const durable = (await this.store.load(event.job_id)).tasks.find(task => task.id === event.task_id);
99
97
  return durable?.state === 'FAILED' && durable.executionHistory?.some(history => history.id === event.data?.executionId)
100
98
  ? { applied: true, reconcile: true, reason: 'watchdog.failure-fact' } : result;
@@ -199,7 +197,6 @@ export class ReconcilerRuntimeBase {
199
197
  }
200
198
  async markWorkerStarted(event) {
201
199
  const live = this.live.get(event.task_id);
202
- // Event timestamps are worker-authored and may be wrong; exact execution identity is authoritative.
203
200
  const transition = await this.taskTransitions.apply(event.job_id, event.task_id, 'task.started', {
204
201
  eligible: (job, task) => {
205
202
  if (!task.executionId || eventSourceId(event) !== executionEventSource(job.id, task.id, task.executionId))
@@ -258,6 +255,10 @@ export class ReconcilerRuntimeBase {
258
255
  return true;
259
256
  }
260
257
  async failLaunch(jobId, taskId, executionId, message, phase = 'launch') {
258
+ const failureReason = phase === 'start-ack' ? 'worker_start_timeout'
259
+ : /BROWSER_WORKSPACE_UNAVAILABLE|browser-workspace runtime dependency unavailable|browser-workspace session start returned no session_id|no close frame received or sent/i.test(message)
260
+ ? 'browser_workspace_unavailable'
261
+ : undefined;
261
262
  const claim = executionId ?? `${jobId}:${taskId}`;
262
263
  if (this.launchFailureClaims.has(claim))
263
264
  return;
@@ -270,7 +271,7 @@ export class ReconcilerRuntimeBase {
270
271
  mutate: (_job, task) => {
271
272
  this.clearExecution(task.id, executionId);
272
273
  task.error = `Worker ${phase} failed: ${message}`;
273
- task.failureReason = phase === 'start-ack' ? 'worker_start_timeout' : undefined;
274
+ task.failureReason = failureReason;
274
275
  task.leaseOwner = null;
275
276
  task.leaseExpiresAt = null;
276
277
  },
@@ -281,12 +282,24 @@ export class ReconcilerRuntimeBase {
281
282
  await this.emit(eventFor(jobId, taskId, transition.task.parentTaskId, 'task.process.delivery_failed', 'failed', `Worker ${phase} reported after lifecycle moved to ${transition.from}: ${message}`, 'orchestrator', { phase, authoritative: false, executionId, workflowEvent }, { agent: 'relay', component: 'agents-relay' }));
282
283
  return;
283
284
  }
284
- const event = eventFor(jobId, taskId, transition.task.parentTaskId, 'task.failed', 'failed', transition.task.error ?? `Worker ${phase} failed: ${message}`, 'user', { phase, reason: transition.task.failureReason, workflowEvent }, { agent: 'relay', component: 'agents-relay', watchdog: true });
285
+ let runtimeRetry = null;
286
+ if (failureReason === 'browser_workspace_unavailable' && transition.task.attempt < transition.task.maxAttempts) {
287
+ const current = (await this.store.load(jobId)).tasks.find(task => task.id === taskId);
288
+ if (current?.state === 'FAILED' && current.failureReason === failureReason && current.attempt === transition.task.attempt) {
289
+ await applyTaskRetryAction(current);
290
+ current.routing = undefined;
291
+ current.workerSelection = undefined;
292
+ await this.store.saveTask(current);
293
+ runtimeRetry = current;
294
+ }
295
+ }
296
+ const event = eventFor(jobId, taskId, transition.task.parentTaskId, 'task.failed', 'failed', transition.task.error ?? `Worker ${phase} failed: ${message}`, 'user', { phase, reason: failureReason, workflowEvent }, { agent: 'relay', component: 'agents-relay', watchdog: true });
285
297
  this.detach(this.emit(event), jobId, taskId, 'worker failure event');
286
- // failLaunch already committed the authoritative FAILED state. The emitted
287
- // task.failed observation may be ignored as a duplicate, so explicitly wake
288
- // graph reconciliation from the durable failure fact. This is required for
289
- // automatic Troubleshooter recovery, including reviewer start-ack failures.
298
+ if (runtimeRetry) {
299
+ this.detach(this.emit(eventFor(jobId, taskId, runtimeRetry.parentTaskId, 'task.runtime_retry', 'queued', 'Browser Workspace runtime unavailable; retrying the same Task', 'orchestrator', { previousFailure: failureReason, attempt: runtimeRetry.attempt, maxAttempts: runtimeRetry.maxAttempts })), jobId, taskId, 'runtime retry event');
300
+ this.detach(this.eventCoordinator.accept({ jobId, reason: 'browser-workspace-runtime-retry' }), jobId, taskId, 'runtime retry reconcile wake');
301
+ return;
302
+ }
290
303
  this.detach(this.eventCoordinator.accept({ jobId, reason: `worker-${phase}-failure` }), jobId, taskId, 'worker failure reconcile wake');
291
304
  }
292
305
  async runtimeSettled(jobId, taskId, executionId, result, error) {
@@ -297,9 +310,6 @@ export class ReconcilerRuntimeBase {
297
310
  }
298
311
  const job = await this.store.load(jobId);
299
312
  const task = job.tasks.find(item => item.id === taskId);
300
- // Async transport settlement is an independent, non-authoritative observation.
301
- // A worker terminal event may already have claimed the Task; do not suppress
302
- // transport-exit evidence merely because business completion won the race.
303
313
  if (!task)
304
314
  return;
305
315
  const correlated = task.executionId === executionId || task.executionHistory?.some(item => item.id === executionId);
@@ -338,9 +348,6 @@ export class ReconcilerRuntimeBase {
338
348
  return;
339
349
  }
340
350
  let reroutedTask = null;
341
- // Provider quota failures are recoverable. Commit the retry + route exclusion
342
- // before publishing task.failed so event-driven reconciliation can never see
343
- // an intermediate FAILED state and spawn a Troubleshooter for quota exhaustion.
344
351
  if (reroutableWorkerRejections.has(failureReason) && transition.task.attempt < transition.task.maxAttempts) {
345
352
  const current = (await this.store.load(jobId)).tasks.find(task => task.id === taskId);
346
353
  if (current?.state === 'FAILED' && current.failureReason === failureReason && current.attempt === transition.task.attempt) {
@@ -353,7 +360,7 @@ export class ReconcilerRuntimeBase {
353
360
  provider: failedRoute.provider,
354
361
  model: failedRoute.model,
355
362
  ...(failedRoute.profile ? { profile: failedRoute.profile } : {}),
356
- reason: 'rate_limit_exceeded',
363
+ reason: failureReason,
357
364
  at: new Date().toISOString(),
358
365
  }];
359
366
  }
@@ -377,17 +384,12 @@ export class ReconcilerRuntimeBase {
377
384
  eligible: (_job, task) => {
378
385
  if (!['codex', 'chatgpt', 'orchestrator'].includes(task.adapter))
379
386
  return false;
380
- // Reviewer business terminal state is owned by review.approved / review.changes_required / review.blocked.
381
- // A generic task.completed may arrive before the review decision and must not consume the execution generation.
382
387
  if (task.agentName === 'reviewer' && event.type === 'task.completed')
383
388
  return false;
384
389
  const modelBacked = task.adapter === 'codex' || task.adapter === 'chatgpt';
385
390
  if (!trustedInternal && modelBacked && !this.isCorrelatedWorkerEvent(task, event)) {
386
391
  const eventExecutionId = executionIdFromSource(eventSourceId(event), _job.id, task.id);
387
392
  const prior = eventExecutionId ? task.executionHistory?.find(item => item.id === eventExecutionId) : undefined;
388
- // A duplicate launch can leave one worker orphaned after another worker
389
- // has already produced the terminal fact. Accept only a known execution
390
- // from the same durable attempt. A prior retry attempt cannot complete a newer generation.
391
393
  if (!prior || prior.attempt !== task.attempt)
392
394
  return false;
393
395
  }
@@ -8,12 +8,12 @@ import { dependencyReady, schedule, transitionTaskEvent } from './scheduler.js';
8
8
  import { applyTaskRetryAction } from './task-actions.js';
9
9
  import { LaunchFailure, StartAckTimeout } from './adapters.js';
10
10
  import { eventFor } from './events.js';
11
- import { createPlanRound, materializePlannerChild, pendingPlan } from './planner.js';
11
+ import { createPlanRound, materializePlannerChild, pendingPlan, validatePlannerResolutionMaterialization } from './planner.js';
12
12
  import { retryPendingPlannerUnblocks } from './planner-runtime.js';
13
13
  import { dispatchPlannerOrchestration, plannerOrchestrationFacts } from './workflow/planner-orchestration-v2.js';
14
14
  import { resolveExecutionContext } from './execution-context.js';
15
15
  import { ReconcilerReviewBase } from './reconciler-review.js';
16
- import { recordThreadDelivery } from './reviewer-thread.js';
16
+ import { recordThreadDelivery, resolveImplementationTaskThreadDestination, prepareReviewerThreadDelivery, resolveReviewerThreadDestination } from './reviewer-thread.js';
17
17
  import { executeManualPlan } from './manual-plan.js';
18
18
  import { executionEventSource, withExecutionBoundary } from './reconciler-runtime.js';
19
19
  export { executionEventSource } from './reconciler-runtime.js';
@@ -23,6 +23,7 @@ import { taskUsesCanonicalProjectRun } from './workspace.js';
23
23
  import { cleanupMergedJobWorktree } from './job-worktree-cleanup.js';
24
24
  import { jobHasActiveExecution } from './job-execution-slot.js';
25
25
  import { managedWorkerInput } from './managed-worker-input.js';
26
+ import { attachResolutionOwnership } from './workflow/task-resolution.js';
26
27
  export { managedWorkerInput } from './managed-worker-input.js';
27
28
  import { recoverExpiredExecutions, recoverStartupOrphans as recoverStartupExecutionOrphans } from './execution-recovery.js';
28
29
  import { dispatchJobReconcile } from './workflow/job-reconcile-v2.js';
@@ -77,7 +78,6 @@ export class Reconciler extends ReconcilerReviewBase {
77
78
  const previousState = job.state;
78
79
  const lifecycleFingerprint = (value) => `${value.state}|${value.tasks.map(task => `${task.id}:${task.state}`).join(',')}`;
79
80
  const beforeFingerprint = lifecycleFingerprint(job);
80
- // GitHub state is audit-only and never participates in workflow decisions.
81
81
  const headSha = await this.currentHeadSha(job);
82
82
  if (headSha && job.headSha !== headSha) {
83
83
  if (job.localMerge && job.localMerge.status !== 'succeeded') {
@@ -116,8 +116,6 @@ export class Reconciler extends ReconcilerReviewBase {
116
116
  }
117
117
  {
118
118
  const recoveryJob = await this.store.load(jobId);
119
- // A failed recovery worker is itself recoverable: retry the SAME Task
120
- // within its budget. No recursive Troubleshooter is generated.
121
119
  for (const recovery of recoveryJob.tasks) {
122
120
  if (recovery.agentName !== 'troubleshooter' || recovery.state !== 'FAILED' || recovery.attempt >= recovery.maxAttempts)
123
121
  continue;
@@ -275,18 +273,19 @@ export class Reconciler extends ReconcilerReviewBase {
275
273
  async recoverPlannerChildren(job) {
276
274
  for (const plan of (job.plans ?? []).filter(item => item.status === 'SUCCEEDED' && item.plannerResult)) {
277
275
  const executionContext = await this.plannerContext(job, plan);
276
+ validatePlannerResolutionMaterialization(job.tasks, plan.plannerResult.next_tasks);
278
277
  const children = plan.plannerResult.next_tasks.map(spec => materializePlannerChild(job, plan, spec, executionContext));
279
278
  for (const child of children) {
280
279
  const existing = job.tasks.find(item => item.id === child.id);
281
280
  if (existing)
282
281
  continue;
282
+ const spec = plan.plannerResult.next_tasks.find(item => item.id === child.id);
283
+ if (spec?.resolution)
284
+ await this.store.saveTask(attachResolutionOwnership(job, child, spec.resolution, 'planner'));
283
285
  await this.store.appendTask(child);
284
286
  job.tasks.push(child);
285
287
  }
286
288
  }
287
- // Only legacy successful Planner Tasks need execution context. Ordinary
288
- // failed work must not fetch a GitHub PR head merely to check for legacy
289
- // planner records; offline Troubleshooter recovery must remain runnable.
290
289
  const historicalPlanners = job.tasks.filter(task => task.kind === 'planner' && task.state === 'SUCCEEDED' && task.plannerResult);
291
290
  if (historicalPlanners.length === 0)
292
291
  return;
@@ -303,6 +302,9 @@ export class Reconciler extends ReconcilerReviewBase {
303
302
  const children = result.next_tasks.map(spec => materializePlannerChild(job, plan, spec, executionContext));
304
303
  for (const child of children)
305
304
  if (!job.tasks.some(item => item.id === child.id)) {
305
+ const spec = result.next_tasks.find(item => item.id === child.id);
306
+ if (spec?.resolution)
307
+ await this.store.saveTask(attachResolutionOwnership(job, child, spec.resolution, 'planner'));
306
308
  await this.store.appendTask(child);
307
309
  job.tasks.push(child);
308
310
  }
@@ -346,7 +348,8 @@ export class Reconciler extends ReconcilerReviewBase {
346
348
  return;
347
349
  if (freshTask.agentName === 'reviewer' && freshTask.planningDecisionId && freshTask.dependencies.length)
348
350
  await bindPlannedReviewHead(this.store, freshTask, await this.currentHeadSha(freshJob));
349
- if (!freshTask.routing && freshTask.adapter !== 'orchestrator') {
351
+ const implementationThread = resolveImplementationTaskThreadDestination(freshJob, freshTask);
352
+ if ((!freshTask.routing || ((implementationThread || (freshTask.agentName === 'reviewer' && resolveReviewerThreadDestination(freshJob, this.options.reviewer?.threadId))) && freshTask.adapter !== 'chatgpt')) && freshTask.adapter !== 'orchestrator') {
350
353
  freshTask = (await this.routeTaskForExecution(freshJob, freshTask)).task;
351
354
  }
352
355
  if (['codex', 'chatgpt'].includes(freshTask.adapter) && !freshTask.routing) {
@@ -363,6 +366,16 @@ export class Reconciler extends ReconcilerReviewBase {
363
366
  attempt: freshTask.attempt,
364
367
  startedAt: new Date(startedAt).toISOString(),
365
368
  }].slice(-8);
369
+ if (freshTask.agentName === 'reviewer')
370
+ freshTask.chatgptThreadDelivery = prepareReviewerThreadDelivery(freshJob, freshTask, this.options.reviewer?.threadId);
371
+ if (implementationThread && freshTask.adapter === 'chatgpt') {
372
+ freshTask.chatgptThreadDelivery = {
373
+ kind: 'new-turn', threadId: implementationThread, threadSource: 'implementation',
374
+ requestId: `${freshJob.id}:${freshTask.id}:${freshTask.executionId}`,
375
+ jobId: freshJob.id, prNumber: freshJob.prNumber, executionId: freshTask.executionId,
376
+ projectId: freshTask.routing?.projectId ?? null, deliveredAt: null,
377
+ };
378
+ }
366
379
  if (this.store.mutateJob) {
367
380
  let claimed;
368
381
  await this.store.mutateJob(job.id, current => {
@@ -370,8 +383,6 @@ export class Reconciler extends ReconcilerReviewBase {
370
383
  if (!durable || durable.state !== 'QUEUED' || durable.executionId !== null
371
384
  || !dependencyReady(durable, new Map(current.tasks.map(item => [item.id, item]))))
372
385
  return current;
373
- // Routing is a pre-claim fact; copy only that fact into the fresh
374
- // authoritative snapshot, then claim the generation atomically.
375
386
  if (freshTask.routing)
376
387
  durable.routing = structuredClone(freshTask.routing);
377
388
  if (freshTask.workerSelection)
@@ -383,6 +394,17 @@ export class Reconciler extends ReconcilerReviewBase {
383
394
  durable.executionHistory = [...(durable.executionHistory ?? []), {
384
395
  id: durable.executionId, attempt: durable.attempt, startedAt: new Date(startedAt).toISOString(),
385
396
  }].slice(-8);
397
+ const implementationThread = resolveImplementationTaskThreadDestination(current, durable);
398
+ if (durable.agentName === 'reviewer')
399
+ durable.chatgptThreadDelivery = prepareReviewerThreadDelivery(current, { ...durable, routing: freshTask.routing }, this.options.reviewer?.threadId);
400
+ if (implementationThread && durable.adapter === 'chatgpt') {
401
+ durable.chatgptThreadDelivery = {
402
+ kind: 'new-turn', threadId: implementationThread, threadSource: 'implementation',
403
+ requestId: `${current.id}:${durable.id}:${durable.executionId}`,
404
+ jobId: current.id, prNumber: current.prNumber, executionId: durable.executionId,
405
+ projectId: durable.routing?.projectId ?? null, deliveredAt: null,
406
+ };
407
+ }
386
408
  claimed = structuredClone(durable);
387
409
  return current;
388
410
  });
@@ -429,10 +451,17 @@ export class Reconciler extends ReconcilerReviewBase {
429
451
  const startAckSetup = task.adapter === 'chatgpt' ? await this.startAck(job.id, task.id, sourceId, Date.now(), this.options.startAckTimeoutMs ?? chatGptStartAckTimeoutMs(task.timeoutMs)) : null;
430
452
  const closePolicy = task.chatgptTabClosePolicy ?? this.options.chatgptTabClosePolicy ?? adapter.tabClosePolicy ?? 'after-start';
431
453
  const terminalWait = task.adapter === 'chatgpt' && closePolicy === 'after-terminal' ? this.terminalWait(task.id, durableExecutionId, startedAt - 1) : null;
432
- // Consume E2E injection before execution: a retry will not repeat it.
433
- // Production repositories ignore the option entirely.
434
454
  const declaration = authorizedInjection(job.repository, task.testInjection);
435
- const testInjection = declaration && ((task.adapter === 'codex' && declaration.method === 'worker.codex.launch') || (task.adapter === 'chatgpt' && declaration.method === 'browserWorkspace.command')) ? declaration : undefined;
455
+ if (task.testInjection && !declaration) {
456
+ await this.failLaunch(job.id, task.id, durableExecutionId, `E2E test injection refused for repository ${job.repository}; configure E2E_REPO on relayd`, 'delivery');
457
+ return;
458
+ }
459
+ const workerCheckpoint = declaration?.method === 'worker.codex.launch' || declaration?.method === 'browserWorkspace.command';
460
+ const testInjection = workerCheckpoint && ((task.adapter === 'codex' && declaration.method === 'worker.codex.launch') || (task.adapter === 'chatgpt' && declaration.method === 'browserWorkspace.command')) ? declaration : undefined;
461
+ if (workerCheckpoint && !testInjection) {
462
+ await this.failLaunch(job.id, task.id, durableExecutionId, `E2E test injection ${declaration.method} is incompatible with adapter ${task.adapter}`, 'delivery');
463
+ return;
464
+ }
436
465
  if (testInjection) {
437
466
  const durable = (await this.store.load(job.id)).tasks.find(item => item.id === task.id);
438
467
  if (!durable || durable.executionId !== durableExecutionId)
@@ -1,3 +1,4 @@
1
+ import { normalizeThreadId } from './reviewer-thread.js';
1
2
  export function createWorkspaceControl(deps) {
2
3
  const { client, trusted, noGitHub, repositories, refreshAll, requireLocalJobBinding, makeReconciler, concurrency, pool, bus, isEffectivelyBlocked, unblockAutonomousTasks, unblockTask, publishTaskUnblocked, dispatchTaskAction, registerBlockerDependency, discoverAgents, ensureManagedGitHubJob } = deps;
3
4
  const workspaceControl = async (body) => {
@@ -27,6 +28,7 @@ export function createWorkspaceControl(deps) {
27
28
  body: typeof options.body === 'string' ? options.body : undefined,
28
29
  reviewerThreadId: typeof options.reviewerThreadId === 'string' ? options.reviewerThreadId : undefined,
29
30
  creatorThreadId: typeof options.creatorThreadId === 'string' ? options.creatorThreadId : undefined,
31
+ implementationThreadId: typeof options.implementationThreadId === 'string' ? options.implementationThreadId : undefined,
30
32
  noGitHub,
31
33
  localMergeSource: typeof options.localMergeSource === 'string' ? options.localMergeSource : undefined,
32
34
  localMergeTarget: typeof options.localMergeTarget === 'string' ? options.localMergeTarget : undefined,
@@ -55,10 +57,16 @@ export function createWorkspaceControl(deps) {
55
57
  const job = await store.load(jobId);
56
58
  if (command === 'job.update') {
57
59
  const patch = (body.patch && typeof body.patch === 'object' ? body.patch : {});
58
- const mutable = ['title', 'objective', 'projectPath', 'priority', 'executionMode', 'schedule', 'tag', 'autoMerge', 'reviewEnabled', 'reviewerThreadId', 'creatorThreadId', 'continuation', 'localMerge'];
60
+ const mutable = ['title', 'objective', 'projectPath', 'priority', 'executionMode', 'schedule', 'tag', 'autoMerge', 'reviewEnabled', 'reviewerThreadId', 'creatorThreadId', 'implementationThreadId', 'continuation', 'localMerge'];
59
61
  for (const key of mutable)
60
- if (Object.prototype.hasOwnProperty.call(patch, key))
61
- job[key] = patch[key];
62
+ if (Object.prototype.hasOwnProperty.call(patch, key)) {
63
+ const value = patch[key];
64
+ if (['reviewerThreadId', 'creatorThreadId', 'implementationThreadId'].includes(key)) {
65
+ job[key] = value === undefined || value === null ? undefined : normalizeThreadId(value, `Job ${key}`);
66
+ }
67
+ else
68
+ job[key] = value;
69
+ }
62
70
  job.updatedAt = new Date().toISOString();
63
71
  await store.saveJob(job);
64
72
  await refreshAll();
@@ -132,6 +140,9 @@ export function createWorkspaceControl(deps) {
132
140
  const dependentTaskId = typeof body.dependentTaskId === 'string' ? body.dependentTaskId : '';
133
141
  const dependent = await requireLocalJobBinding(dependentJobId);
134
142
  const relation = await registerBlockerDependency({ blockerStore: store, blockerJobId: jobId, dependentStore: dependent.store, dependentJobId, dependentTaskId, resolutionMode: body.resolutionMode === 'verified' ? 'verified' : 'merge' });
143
+ // Registering a blocker is itself runnable-work creation. Wake the blocker
144
+ // Job immediately instead of waiting for inventory polling/stale rescue.
145
+ await pool.reconcileLocalJob(jobId, 'control.blocker.add');
135
146
  await refreshAll();
136
147
  return relation;
137
148
  }
package/dist/relayd.js CHANGED
@@ -18,6 +18,7 @@ import { createJobIdRegistryResolver } from './local-job-registry.js';
18
18
  import { projectAuthoritativeLocalMerge } from './local-merge-projection.js';
19
19
  import { archiveDisplayPullRequest } from './github-pr-archive.js';
20
20
  import { orderGitHubBindingCandidates } from './github-binding-order.js';
21
+ import { authoritativeBranchPushArgs } from './github-branch-projection.js';
21
22
  import { githubAuthContext } from './github-auth.js';
22
23
  import { DisabledGitHubClient, noGitHubMode } from './no-github.js';
23
24
  import { basename } from 'node:path';
@@ -251,7 +252,21 @@ export async function runDaemon(argv) {
251
252
  const sourceSha = await gitText(canonical, ['rev-parse', '--verify', sourceRef]);
252
253
  const facts = await relay.loadPullRequestFacts();
253
254
  if (facts?.head?.sha !== sourceSha) {
254
- await gitText(canonical, ['push', 'origin', `${branch}:${branch}`]);
255
+ await gitText(canonical, ['fetch', '--no-tags', 'origin',
256
+ '+refs/heads/main:refs/remotes/origin/main']);
257
+ await gitText(canonical, ['fetch', '--no-tags', 'origin',
258
+ `+refs/heads/${branch}:refs/remotes/origin/${branch}`]).catch(() => '');
259
+ const remoteRef = `refs/remotes/origin/${branch}`;
260
+ const remoteHeadSha = await gitText(canonical, ['rev-parse', '--verify', remoteRef]).catch(() => '');
261
+ const remoteTreeMatchesBase = remoteHeadSha
262
+ ? await gitText(canonical, ['diff', '--quiet', 'refs/remotes/origin/main', remoteRef]).then(() => true, () => false)
263
+ : false;
264
+ await gitText(canonical, authoritativeBranchPushArgs({
265
+ sourceRef,
266
+ branch,
267
+ remoteHeadSha: remoteHeadSha || null,
268
+ remoteTreeMatchesBase,
269
+ }));
255
270
  }
256
271
  let job = (await relay.loadJob(discovered.id)) ?? discovered;
257
272
  let externalPrNumber = job.externalPrNumber ?? (job.prNumber > 0 ? job.prNumber : 0);
@@ -1,4 +1,10 @@
1
1
  import { arg, hasArg } from './cli-support.js';
2
+ export function resolveImplementationThreadDestination(job) {
3
+ return job.implementationThreadId ? normalizeThreadId(job.implementationThreadId, 'Job implementation thread ID') ?? null : null;
4
+ }
5
+ export function resolveImplementationTaskThreadDestination(job, task) {
6
+ return task.agentName === 'implementation' ? resolveImplementationThreadDestination(job) : null;
7
+ }
2
8
  export function normalizeThreadId(value, label = 'thread ID') {
3
9
  if (value === undefined || value === null)
4
10
  return undefined;
@@ -21,6 +27,27 @@ export function resolveReviewerThreadDestination(job, override) {
21
27
  const creatorThreadId = job.creatorThreadId ? normalizeThreadId(job.creatorThreadId, 'Job creator thread ID') : undefined;
22
28
  return creatorThreadId ? { threadId: creatorThreadId, source: 'creator' } : null;
23
29
  }
30
+ export function prepareReviewerThreadDelivery(job, task, override) {
31
+ if (task.agentName !== 'reviewer')
32
+ return undefined;
33
+ const destination = resolveReviewerThreadDestination(job, override);
34
+ if (!destination)
35
+ return undefined;
36
+ const existing = task.chatgptThreadDelivery;
37
+ if (existing?.deliveredAt) {
38
+ if (existing.threadId !== destination.threadId)
39
+ throw new Error('Cannot redirect an already delivered reviewer task');
40
+ return existing;
41
+ }
42
+ return {
43
+ kind: 'new-turn', threadId: destination.threadId, threadSource: destination.source,
44
+ requestId: existing?.requestId ?? `${job.id}:${task.id}:${task.reviewHeadSha ?? job.headSha ?? 'review'}`,
45
+ jobId: job.id, prNumber: job.externalPrNumber && job.externalPrNumber > 0 ? job.externalPrNumber : job.prNumber,
46
+ ...(task.reviewHeadSha ? { reviewHeadSha: task.reviewHeadSha } : {}),
47
+ projectId: task.routing?.projectId ?? null,
48
+ ...(existing?.executionId ? { executionId: existing.executionId } : {}),
49
+ };
50
+ }
24
51
  export function chatgptThreadRequirements(configured) {
25
52
  const base = configured ?? { capabilities: ['model'] };
26
53
  const capabilities = new Set([...base.capabilities, 'model', 'chatgpt', 'browser-workspace']);
@@ -35,11 +62,23 @@ export function runtimeReviewer(args = []) {
35
62
  }
36
63
  export async function recordThreadDelivery(store, jobId, taskId, executionId, workerTask) {
37
64
  const delivery = workerTask.chatgptThreadDelivery;
38
- if (!delivery || delivery.deliveredAt)
65
+ if (!delivery || delivery.deliveredAt || (delivery.executionId !== undefined && delivery.executionId !== executionId))
66
+ return;
67
+ const mark = (job) => {
68
+ const current = job.tasks.find(item => item.id === taskId);
69
+ if (!current || current.executionId !== executionId || current.state !== 'RUNNING' || !current.chatgptThreadDelivery
70
+ || current.chatgptThreadDelivery.requestId !== delivery.requestId || current.chatgptThreadDelivery.deliveredAt)
71
+ return job;
72
+ current.chatgptThreadDelivery = { ...current.chatgptThreadDelivery, executionId, deliveredAt: new Date().toISOString() };
73
+ return job;
74
+ };
75
+ if (store.mutateJob) {
76
+ await store.mutateJob(jobId, mark);
39
77
  return;
78
+ }
40
79
  const current = (await store.load(jobId)).tasks.find(item => item.id === taskId);
41
- if (current?.executionId !== executionId || current.state !== 'RUNNING' || !current.chatgptThreadDelivery)
80
+ if (!current || current.executionId !== executionId || current.state !== 'RUNNING' || !current.chatgptThreadDelivery || current.chatgptThreadDelivery.requestId !== delivery.requestId || current.chatgptThreadDelivery.deliveredAt)
42
81
  return;
43
- current.chatgptThreadDelivery = { ...current.chatgptThreadDelivery, deliveredAt: new Date().toISOString() };
82
+ current.chatgptThreadDelivery = { ...current.chatgptThreadDelivery, executionId, deliveredAt: new Date().toISOString() };
44
83
  await store.saveTask(current);
45
84
  }
@@ -1,5 +1,6 @@
1
1
  import { extendRetryBudget, transitionTaskEvent, tryTransitionTaskEvent } from './scheduler.js';
2
2
  import { selectRelatedTaskMutations } from './workflow/task-action-policy.js';
3
+ import { dispatchTaskResolutionSync, materializeResolution } from './workflow/task-resolution.js';
3
4
  export const TASK_RETRY_ACTION = 'task.retry';
4
5
  export const TASK_RECOVER_ACTION = 'task.recover';
5
6
  export const TASK_UNBLOCK_RETRY_ACTION = 'task.unblock.retry';
@@ -161,8 +162,14 @@ export async function dispatchTaskAction(store, request) {
161
162
  throw new Error(`task recover requires an active Troubleshooter recovery for ${task.id}`);
162
163
  }
163
164
  const relatedMutations = request.id === TASK_RECOVER_ACTION ? [] : selectRelatedTaskMutations(current, task, request.id);
164
- if (request.id === TASK_RETRY_ACTION || request.id === TASK_RECOVER_ACTION)
165
+ if (request.id === TASK_RETRY_ACTION)
165
166
  applyTaskRetrySnapshot(task, { extendBudget: true });
167
+ else if (request.id === TASK_RECOVER_ACTION) {
168
+ const proposal = { path: 'exact_original_retry', targetTaskId: task.id, rootCause: task.failureReason ?? task.error ?? 'durable failure', solution: `Recover exact original Task via ${request.recoveryTaskId}` };
169
+ const decision = dispatchTaskResolutionSync(task, proposal, 'troubleshooter', { activateRecovery: () => { applyTaskRetrySnapshot(task, { extendBudget: true }); task.resolution = { ...materializeResolution(task, proposal, 'troubleshooter'), state: 'resolved' }; } });
170
+ if (!decision.accepted)
171
+ throw new Error(`Task resolution graph rejected exact-original recovery for ${task.id}`);
172
+ }
166
173
  else if (request.id === TASK_CANCEL_ACTION)
167
174
  cancelTaskSnapshot(task);
168
175
  for (const mutation of relatedMutations) {
@@ -189,7 +196,10 @@ export async function dispatchTaskAction(store, request) {
189
196
  const recovery = request.recoveryTaskId ? job.tasks.find(item => item.id === request.recoveryTaskId) : undefined;
190
197
  if (!recovery || recovery.agentName !== 'troubleshooter' || recovery.parentTaskId !== task.id || !['QUEUED', 'RUNNING'].includes(recovery.state))
191
198
  throw new Error(`task recover requires an active Troubleshooter recovery for ${task.id}`);
192
- await applyTaskRetryAction(task, { extendBudget: true });
199
+ const proposal = { path: 'exact_original_retry', targetTaskId: task.id, rootCause: task.failureReason ?? task.error ?? 'durable failure', solution: `Recover exact original Task via ${request.recoveryTaskId}` };
200
+ const decision = dispatchTaskResolutionSync(task, proposal, 'troubleshooter', { activateRecovery: () => { applyTaskRetrySnapshot(task, { extendBudget: true }); task.resolution = { ...materializeResolution(task, proposal, 'troubleshooter'), state: 'resolved' }; } });
201
+ if (!decision.accepted)
202
+ throw new Error(`Task resolution graph rejected exact-original recovery for ${task.id}`);
193
203
  }
194
204
  else if (request.id === TASK_UNBLOCK_RETRY_ACTION)
195
205
  await applyTaskUnblockRetryAction(task);
@@ -26,7 +26,7 @@ Retrieve this task's lifecycle event history before classifying the root cause o
26
26
  GET http://127.0.0.1:8787/api/events?job=${encodeURIComponent(job.id)}&task=${encodeURIComponent(task.id)}&limit=200
27
27
  Inspect event order, type/status, source, data/phase, duplicate events, missing expected lifecycle events, late events, and execution identifiers. If the event stream points to delivery/runtime failure, inspect the corresponding Relay/worker/tool logs too. Do not treat the final task.error string as the complete root cause.
28
28
 
29
- Follow the selected Troubleshooter agent contract. Diagnose root cause from durable task state + event history + relevant runtime logs before retry. The final task.error string alone is never sufficient. Classify the evidence as model/provider/runtime, worker/tool/infrastructure, semantic task failure, or timeout/unknown. Only model/provider/runtime evidence may call the narrow model re-decision action; that action gathers fresh usage, reloads current policy, updates this exact Task's routing, and does not retry it. Troubleshooter must then reactivate/retry this exact original task and verify progress. Use the dedicated agents-relay task recover path with --task-id ${task.id} and --recovery-task-id ${troubleshooterTaskId(task)}; never use ordinary task retry from a Troubleshooter because manual retry intentionally supersedes active recovery. Do not manually choose a provider/model or create a replacement task. If the blocker is a shared worker/runtime/tool defect, create and directly complete a P0/self-job in the owning repository, including required release/deploy verification. If diagnosis suggests a genuine human/external blocker, do not publish task.blocked directly and do not implement the result branch procedurally. Call agents-relay escalation with the original intent, blocker, and relevant context. Pass any caller-defined notification plan through --on-needs-user/--on-needs-user-file; do not hard-code a channel. The canonical escalation graph invokes Neo escalation and owns the resolved/failed/needs_user branch; follow its selected outcome. Retry this exact original task only after the blocker is removed, reconcile the original job, and verify durable progress.`;
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 ${troubleshooterTaskId(task)}; never use ordinary task retry from a Troubleshooter because manual retry intentionally supersedes active recovery. Do not manually choose a provider/model or create a replacement task; 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;
@@ -13,7 +13,9 @@ export const orchestratorRescueV2 = new GraphRuntime(source, {
13
13
  export function orchestratorRescueFacts(task, now) {
14
14
  const createdAt = Date.parse(task.createdAt);
15
15
  const oldEnough = Number.isFinite(createdAt) && now - createdAt >= STALE_ORCHESTRATOR_TASK_MS;
16
- return { rescuable: task.kind !== 'planner' && task.adapter === 'orchestrator' && task.state === 'QUEUED' && task.executionId === null && oldEnough };
16
+ const urgentRepair = task.priority === 'P0' && task.agentName === 'troubleshooter';
17
+ return { rescuable: task.kind !== 'planner' && task.adapter === 'orchestrator' && task.state === 'QUEUED'
18
+ && task.executionId === null && (oldEnough || urgentRepair) };
17
19
  }
18
20
  export function decideOrchestratorRescue(task, now) {
19
21
  return dispatchWorkflowGraphSync(orchestratorRescueV2, {
@@ -28,8 +30,10 @@ export function applyOrchestratorRescue(task, now) {
28
30
  const ageMs = now - Date.parse(task.createdAt);
29
31
  if (!rescued)
30
32
  return { rescued: false, ageMs };
31
- task.adapter = 'chatgpt';
32
- task.workerRequirements = { capabilities: [...task.capabilities], runtime: 'chatgpt' };
33
+ const urgentRepair = task.priority === 'P0' && task.agentName === 'troubleshooter';
34
+ task.adapter = urgentRepair ? 'codex' : 'chatgpt';
35
+ task.workerRequirements = { capabilities: [...task.capabilities, ...(urgentRepair ? ['local_repo'] : [])],
36
+ runtime: urgentRepair ? 'codex' : 'chatgpt' };
33
37
  task.workerSelection = undefined;
34
38
  task.routing = undefined;
35
39
  task.timeoutMs = 0;
@@ -6,7 +6,7 @@ const source = readFileSync(new URL('../../workflow/planner-orchestration.mmd',
6
6
  const boolGuard = (name) => (facts) => facts.durableFacts[name] === true;
7
7
  export const plannerOrchestrationV2 = new GraphRuntime(source, {
8
8
  states: new Set([
9
- 'idle', 'check_legacy_pending', 'check_recover', 'check_seed', 'check_planner',
9
+ 'idle', 'check_legacy_pending', 'check_recover', 'check_seed', 'check_planner', 'check_resolution_ownership',
10
10
  'check_blocked', 'check_work', 'check_materialized', 'check_objective', 'wait', 'recovered', 'created',
11
11
  ]),
12
12
  guards: {
@@ -15,6 +15,7 @@ export const plannerOrchestrationV2 = new GraphRuntime(source, {
15
15
  recoverablePendingPlan: boolGuard('recoverablePendingPlan'),
16
16
  hasSeedWork: boolGuard('hasSeedWork'),
17
17
  noActivePlanner: boolGuard('noActivePlanner'),
18
+ resolutionOwnedByTroubleshooter: boolGuard('resolutionOwnedByTroubleshooter'),
18
19
  noBlockedWork: boolGuard('noBlockedWork'),
19
20
  noActiveImplementation: boolGuard('noActiveImplementation'),
20
21
  continuationMaterialized: boolGuard('continuationMaterialized'),
@@ -27,10 +28,10 @@ export const plannerOrchestrationV2 = new GraphRuntime(source, {
27
28
  events: { 'planner.reconcile': { id: 'planner.reconcile' } },
28
29
  });
29
30
  export async function dispatchPlannerOrchestration(facts, executeAction) {
30
- return dispatchWorkflowGraph(plannerOrchestrationV2, { initialState: 'idle', input: { transport: 'event', id: 'planner.reconcile', payload: {} }, durableFacts: facts, graphName: 'planner-orchestration', isTransientState: state => state.startsWith('check_'), maxSteps: 9, executeAction });
31
+ return dispatchWorkflowGraph(plannerOrchestrationV2, { initialState: 'idle', input: { transport: 'event', id: 'planner.reconcile', payload: {} }, durableFacts: facts, graphName: 'planner-orchestration', isTransientState: state => state.startsWith('check_'), maxSteps: 14, executeAction });
31
32
  }
32
33
  export function decidePlannerOrchestration(facts) {
33
- return dispatchWorkflowGraphSync(plannerOrchestrationV2, { initialState: 'idle', input: { transport: 'event', id: 'planner.reconcile', payload: {} }, durableFacts: facts, graphName: 'planner-orchestration', isTransientState: state => state.startsWith('check_'), maxSteps: 9 });
34
+ return dispatchWorkflowGraphSync(plannerOrchestrationV2, { initialState: 'idle', input: { transport: 'event', id: 'planner.reconcile', payload: {} }, durableFacts: facts, graphName: 'planner-orchestration', isTransientState: state => state.startsWith('check_'), maxSteps: 14 });
34
35
  }
35
36
  export function plannerOrchestrationFacts(job, recoverablePendingPlan) {
36
37
  const slot = job.schedule?.currentRunSlot ? Date.parse(job.schedule.currentRunSlot) : Number.NaN;
@@ -52,6 +53,7 @@ export function plannerOrchestrationFacts(job, recoverablePendingPlan) {
52
53
  noActivePlanner: !scopedPlans.some(plan => plan.status === 'PLANNING'),
53
54
  noBlockedWork: !scopedTasks.some(task => task.kind !== 'planner' && isEffectivelyBlocked(task)),
54
55
  noActiveImplementation: !scopedTasks.some(task => task.kind !== 'planner' && ['DRAFT', 'QUEUED', 'RUNNING'].includes(task.state)),
56
+ resolutionOwnedByTroubleshooter: scopedTasks.some(task => task.kind !== 'planner' && task.resolution?.owner === 'troubleshooter' && ['active', 'waiting'].includes(task.resolution.state)),
55
57
  continuationMaterialized: !(latestObjective === 'in_progress' && latestId && !job.tasks.some(task => task.kind !== 'planner' && task.planningDecisionId === latestId)),
56
58
  objectiveUnsatisfied: latestObjective !== 'satisfied',
57
59
  };
@@ -0,0 +1,104 @@
1
+ import { readFileSync } from 'node:fs';
2
+ import { GraphRuntime } from './graph-v2.js';
3
+ import { dispatchWorkflowGraph, dispatchWorkflowGraphSync } from './dispatcher.js';
4
+ export const TASK_RESOLUTION_PATHS = [
5
+ 'exact_original_retry', 'same_job_dependency', 'blocker_job_dependency', 'escalation_gate', 'cancel_supersede',
6
+ ];
7
+ const source = readFileSync(new URL('../../workflow/task-resolution.mmd', import.meta.url), 'utf8');
8
+ const boolGuard = (name) => (facts) => facts.durableFacts[name] === true;
9
+ export const taskResolutionV2 = new GraphRuntime(source, {
10
+ states: new Set(['unresolved', 'check_owner', 'check_path', 'resolved']),
11
+ guards: {
12
+ recoveryOwnerAvailable: boolGuard('recoveryOwnerAvailable'), plannerOwnerAvailable: boolGuard('plannerOwnerAvailable'),
13
+ exactOriginalRetry: boolGuard('exactOriginalRetry'), sameJobDependency: boolGuard('sameJobDependency'),
14
+ blockerJobDependency: boolGuard('blockerJobDependency'), escalationGate: boolGuard('escalationGate'), genuinelyObsolete: boolGuard('genuinelyObsolete'),
15
+ },
16
+ actions: Object.fromEntries(['resolution.activateRecovery', 'resolution.attachSameJobDependency', 'resolution.registerBlockerDependency', 'resolution.createEscalationGate', 'resolution.cancelSupersede'].map(id => [id, { id }])),
17
+ events: { 'resolution.propose': { id: 'resolution.propose' } },
18
+ });
19
+ export function unresolvedTask(task) { return task.kind !== 'planner' && !['SUCCEEDED', 'CANCELLED'].includes(task.state); }
20
+ export function validateResolutionProposal(job, original, proposal) {
21
+ if (!TASK_RESOLUTION_PATHS.includes(proposal.path))
22
+ throw new Error(`Unknown task resolution path ${proposal.path}`);
23
+ if (proposal.path === 'exact_original_retry' && original.state !== 'FAILED')
24
+ throw new Error('Exact-original retry requires a FAILED original Task');
25
+ if (proposal.path === 'same_job_dependency' && (!proposal.targetTaskId || !job.tasks.some(task => task.id === proposal.targetTaskId && task.jobId === job.id)))
26
+ throw new Error('Same-Job dependency resolution requires an existing target Task in the same Job');
27
+ if (proposal.path === 'blocker_job_dependency' && (!proposal.targetJobId || !proposal.mode))
28
+ throw new Error('Blocker-Job dependency resolution requires targetJobId and merge or verified mode');
29
+ if (proposal.path === 'escalation_gate' && (!proposal.rootCause || !proposal.solution))
30
+ throw new Error('Escalation gate resolution requires rootCause and solution');
31
+ if (proposal.path === 'cancel_supersede' && !proposal.rootCause)
32
+ throw new Error('Cancellation/supersession requires an explicit obsolescence rootCause');
33
+ }
34
+ export function resolutionFacts(task, proposal, owner) {
35
+ return { recoveryOwnerAvailable: owner === 'troubleshooter', plannerOwnerAvailable: owner !== null && owner !== 'troubleshooter', exactOriginalRetry: proposal.path === 'exact_original_retry', sameJobDependency: proposal.path === 'same_job_dependency', blockerJobDependency: proposal.path === 'blocker_job_dependency', escalationGate: proposal.path === 'escalation_gate', genuinelyObsolete: proposal.path === 'cancel_supersede', unresolved: unresolvedTask(task) };
36
+ }
37
+ export function decideTaskResolution(task, proposal, owner) {
38
+ return dispatchWorkflowGraphSync(taskResolutionV2, { initialState: 'unresolved', input: { transport: 'event', id: 'resolution.propose', payload: proposal }, durableFacts: resolutionFacts(task, proposal, owner), graphName: 'task-resolution', isTransientState: state => state === 'check_owner' || state === 'check_path', maxSteps: 4 });
39
+ }
40
+ export function dispatchTaskResolutionSync(task, proposal, owner, effects) {
41
+ const handlers = {
42
+ 'resolution.activateRecovery': effects.activateRecovery,
43
+ 'resolution.attachSameJobDependency': effects.attachSameJobDependency,
44
+ 'resolution.registerBlockerDependency': effects.registerBlockerDependency,
45
+ 'resolution.createEscalationGate': effects.createEscalationGate,
46
+ 'resolution.cancelSupersede': effects.cancelSupersede,
47
+ };
48
+ return dispatchWorkflowGraphSync(taskResolutionV2, {
49
+ initialState: 'unresolved', input: { transport: 'event', id: 'resolution.propose', payload: proposal },
50
+ durableFacts: resolutionFacts(task, proposal, owner), graphName: 'task-resolution',
51
+ isTransientState: state => state === 'check_owner' || state === 'check_path', maxSteps: 6,
52
+ executeAction: actionId => { const handler = handlers[actionId]; if (!handler)
53
+ throw new Error(`Task resolution effect ${actionId} is unavailable`); handler(); return {}; },
54
+ });
55
+ }
56
+ export async function dispatchTaskResolution(task, proposal, owner, effects) {
57
+ const handlers = {
58
+ 'resolution.activateRecovery': effects.activateRecovery,
59
+ 'resolution.attachSameJobDependency': effects.attachSameJobDependency,
60
+ 'resolution.registerBlockerDependency': effects.registerBlockerDependency,
61
+ 'resolution.createEscalationGate': effects.createEscalationGate,
62
+ 'resolution.cancelSupersede': effects.cancelSupersede,
63
+ };
64
+ return dispatchWorkflowGraph(taskResolutionV2, {
65
+ initialState: 'unresolved',
66
+ input: { transport: 'event', id: 'resolution.propose', payload: proposal },
67
+ durableFacts: resolutionFacts(task, proposal, owner),
68
+ graphName: 'task-resolution',
69
+ isTransientState: state => state === 'check_owner' || state === 'check_path',
70
+ maxSteps: 6,
71
+ executeAction: async (actionId) => {
72
+ const handler = handlers[actionId];
73
+ if (!handler)
74
+ throw new Error(`Task resolution effect ${actionId} is unavailable`);
75
+ await handler();
76
+ return {};
77
+ },
78
+ });
79
+ }
80
+ export function materializeResolution(task, proposal, owner, now = new Date().toISOString()) {
81
+ return { ...proposal, state: proposal.path === 'escalation_gate' || proposal.path === 'blocker_job_dependency' ? 'waiting' : 'active', owner, updatedAt: now };
82
+ }
83
+ /** Apply the graph-selected ownership to the original durable Task under the
84
+ * caller's existing atomic store boundary. This does not create replacement
85
+ * identity; the original remains the semantic owner of the outcome. */
86
+ export function attachResolutionOwnership(job, child, proposal, owner) {
87
+ const original = proposal.targetTaskId ? job.tasks.find(task => task.id === proposal.targetTaskId) : undefined;
88
+ if (!original)
89
+ throw new Error(`Resolution target ${proposal.targetTaskId ?? '(missing)'} not found`);
90
+ if (proposal.path !== 'same_job_dependency')
91
+ throw new Error(`Planner child resolution ${proposal.path} cannot materialize replacement work; use the dedicated resolution effect`);
92
+ const normalized = { ...proposal, targetTaskId: child.id };
93
+ const decision = dispatchTaskResolutionSync(original, normalized, owner, { attachSameJobDependency: () => {
94
+ if (!original.dependencies.includes(child.id))
95
+ original.dependencies = [...original.dependencies, child.id];
96
+ original.resolution = { ...materializeResolution(original, normalized, owner), state: 'waiting' };
97
+ } });
98
+ if (!decision.accepted)
99
+ throw new Error(`Task resolution graph rejected same-Job dependency for ${original.id}`);
100
+ return original;
101
+ }
102
+ export function claimExactOriginalRecovery(original, recovery) {
103
+ original.resolution = materializeResolution(original, { path: 'exact_original_retry', targetTaskId: original.id, rootCause: original.failureReason ?? original.error ?? 'durable failure', solution: `Troubleshooter ${recovery.id} diagnosis and exact-original recovery` }, 'troubleshooter');
104
+ }
package/dist/workspace.js CHANGED
@@ -233,6 +233,17 @@ export async function prepareLocalJobExecutionWorktree(projectPath, job, workspa
233
233
  if (remoteRepository && remoteRepository !== job.repository)
234
234
  throw new Error(`Primary checkout ${primary} does not match ${job.repository}`);
235
235
  const sourceSha = await git(primary, ['rev-parse', '--verify', sourceRef]);
236
+ // If the canonical checkout already owns this exact source branch, Git
237
+ // cannot create a second worktree for it. Reuse the canonical checkout
238
+ // instead. This is especially important for repair Jobs created from an
239
+ // existing dirty worktree: the uncommitted evidence must remain available.
240
+ const primaryBranch = await git(primary, ['symbolic-ref', '-q', 'HEAD']).catch(() => '');
241
+ if (primaryBranch === sourceRef) {
242
+ const primaryHead = await git(primary, ['rev-parse', 'HEAD']);
243
+ if (primaryHead !== sourceSha)
244
+ throw new Error(`Canonical source branch ${sourceRef} changed while preparing execution checkout`);
245
+ return projectRelative ? join(primary, projectRelative) : primary;
246
+ }
236
247
  const worktree = managedLocalJobWorktreePath(job.repository, job.id, workspaceRoot);
237
248
  await mkdir(dirname(worktree), { recursive: true });
238
249
  try {
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "agents-relay",
3
- "version": "2.0.76",
3
+ "version": "2.0.77",
4
4
  "description": "Durable async agent jobs coordinated through GitHub pull requests",
5
5
  "type": "module",
6
6
  "bin": {
@@ -5,6 +5,7 @@ checkLegacy["state: check_legacy_pending"]
5
5
  checkRecover["state: check_recover"]
6
6
  checkSeed["state: check_seed"]
7
7
  checkPlanner["state: check_planner"]
8
+ checkResolution["state: check_resolution_ownership"]
8
9
  checkBlocked["state: check_blocked"]
9
10
  checkWork["state: check_work"]
10
11
  checkMaterialized["state: check_materialized"]
@@ -18,6 +19,7 @@ legacyPending["guard: legacyPlanningPending"]
18
19
  recoverablePending["guard: recoverablePendingPlan"]
19
20
  hasSeed["guard: hasSeedWork"]
20
21
  noActivePlanner["guard: noActivePlanner"]
22
+ resolutionOwned["guard: resolutionOwnedByTroubleshooter"]
21
23
  noBlocked["guard: noBlockedWork"]
22
24
  noActiveWork["guard: noActiveImplementation"]
23
25
  materialized["guard: continuationMaterialized"]
@@ -45,7 +47,11 @@ hasSeed -->|true| checkPlanner
45
47
 
46
48
  checkPlanner -->|on: planner.reconcile| noActivePlanner
47
49
  noActivePlanner -->|false| wait
48
- noActivePlanner -->|true| checkBlocked
50
+ noActivePlanner -->|true| checkResolution
51
+
52
+ checkResolution -->|on: planner.reconcile| resolutionOwned
53
+ resolutionOwned -->|true| wait
54
+ resolutionOwned -->|false| checkBlocked
49
55
 
50
56
  checkBlocked -->|on: planner.reconcile| noBlocked
51
57
  noBlocked -->|false| wait
@@ -0,0 +1,41 @@
1
+ flowchart LR
2
+ %% relay: initial unresolved
3
+ unresolved["state: unresolved"]
4
+ checkOwner["state: check_owner"]
5
+ checkPath["state: check_path"]
6
+ activeRecovery["action: resolution.activateRecovery"]
7
+ sameJob["action: resolution.attachSameJobDependency"]
8
+ blockerJob["action: resolution.registerBlockerDependency"]
9
+ escalate["action: resolution.createEscalationGate"]
10
+ obsolete["action: resolution.cancelSupersede"]
11
+ resolved["state: resolved"]
12
+ recoveryOwner["guard: recoveryOwnerAvailable"]
13
+ plannerOwner["guard: plannerOwnerAvailable"]
14
+ exact["guard: exactOriginalRetry"]
15
+ same["guard: sameJobDependency"]
16
+ blocker["guard: blockerJobDependency"]
17
+ gate["guard: escalationGate"]
18
+ obsoleteGuard["guard: genuinelyObsolete"]
19
+
20
+ unresolved -->|on: resolution.propose| checkOwner
21
+ checkOwner -->|on: resolution.propose| recoveryOwner
22
+ recoveryOwner -->|true| checkPath
23
+ recoveryOwner -->|false| plannerOwner
24
+ plannerOwner -->|true| checkPath
25
+ plannerOwner -->|false| resolved
26
+ checkPath -->|on: resolution.propose| exact
27
+ exact -->|true| activeRecovery
28
+ exact -->|false| same
29
+ same -->|true| sameJob
30
+ same -->|false| blocker
31
+ blocker -->|true| blockerJob
32
+ blocker -->|false| gate
33
+ gate -->|true| escalate
34
+ gate -->|false| obsoleteGuard
35
+ obsoleteGuard -->|true| obsolete
36
+ obsoleteGuard -->|false| resolved
37
+ activeRecovery -->|to: resolved| resolved
38
+ sameJob -->|to: resolved| resolved
39
+ blockerJob -->|to: resolved| resolved
40
+ escalate -->|to: resolved| resolved
41
+ obsolete -->|to: resolved| resolved