agents-relay 2.0.76 → 2.0.78
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 +7 -0
- package/dist/cli-help.js +2 -2
- package/dist/cli-job-create.js +3 -0
- package/dist/cli.js +4 -2
- package/dist/dashboard-html.js +1 -1
- package/dist/github-branch-projection.js +12 -0
- package/dist/managed-worker-input.js +7 -0
- package/dist/planner-runtime.js +11 -2
- package/dist/planner.js +39 -3
- package/dist/pool.js +13 -3
- package/dist/reconciler-review.js +41 -22
- package/dist/reconciler-runtime.js +23 -21
- package/dist/reconciler.js +43 -14
- package/dist/relayd-control.js +14 -3
- package/dist/relayd.js +16 -1
- package/dist/reviewer-thread.js +42 -3
- package/dist/task-actions.js +12 -2
- package/dist/troubleshooter.js +1 -1
- package/dist/workflow/orchestrator-rescue.js +7 -3
- package/dist/workflow/planner-orchestration-v2.js +5 -3
- package/dist/workflow/task-resolution.js +104 -0
- package/dist/workspace.js +11 -0
- package/package.json +1 -1
- package/workflow/planner-orchestration.mmd +7 -1
- package/workflow/task-resolution.mmd +41 -0
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:
|
package/dist/cli-job-create.js
CHANGED
|
@@ -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
|
}
|
package/dist/dashboard-html.js
CHANGED
|
@@ -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}
|
package/dist/planner-runtime.js
CHANGED
|
@@ -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
|
-
|
|
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();
|
package/dist/pool.js
CHANGED
|
@@ -152,9 +152,19 @@ export class RepositoryWorkerPool {
|
|
|
152
152
|
if (!needsLifecycleReconciliation(job))
|
|
153
153
|
continue;
|
|
154
154
|
const allowance = Math.max(1, Math.min(capacity, ready || (expiredRunning ? 1 : 0) || 1));
|
|
155
|
-
|
|
156
|
-
await this.
|
|
157
|
-
|
|
155
|
+
try {
|
|
156
|
+
await this.reconcileExclusive(job.id, async () => {
|
|
157
|
+
await this.makeReconciler(store, allowance).reconcile(job.id);
|
|
158
|
+
});
|
|
159
|
+
}
|
|
160
|
+
catch (error) {
|
|
161
|
+
observability().record({
|
|
162
|
+
stage: 'reconcile', event: 'reconcile.job_failed', level: 'error', outcome: 'failed',
|
|
163
|
+
message: `Reconcile failed for ${job.id}: ${errorMessage(error)}`,
|
|
164
|
+
correlation: { jobId: job.id, repository: job.repository, prNumber: job.prNumber },
|
|
165
|
+
data: { classification: 'job-isolated' },
|
|
166
|
+
});
|
|
167
|
+
}
|
|
158
168
|
capacity -= allowance;
|
|
159
169
|
}
|
|
160
170
|
}
|
|
@@ -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
|
|
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 =
|
|
134
|
-
|
|
135
|
-
|
|
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
|
-
|
|
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
|
-
|
|
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(
|
|
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
|
|
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
|
-
|
|
379
|
-
|
|
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 =
|
|
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
|
-
|
|
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
|
-
|
|
287
|
-
|
|
288
|
-
|
|
289
|
-
|
|
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:
|
|
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
|
}
|
package/dist/reconciler.js
CHANGED
|
@@ -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
|
-
|
|
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
|
-
|
|
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)
|
package/dist/relayd-control.js
CHANGED
|
@@ -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
|
-
|
|
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, ['
|
|
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);
|
package/dist/reviewer-thread.js
CHANGED
|
@@ -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
|
|
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
|
}
|
package/dist/task-actions.js
CHANGED
|
@@ -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
|
|
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
|
-
|
|
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);
|
package/dist/troubleshooter.js
CHANGED
|
@@ -26,7 +26,7 @@ Retrieve this task's lifecycle event history before classifying the root cause o
|
|
|
26
26
|
GET http://127.0.0.1:8787/api/events?job=${encodeURIComponent(job.id)}&task=${encodeURIComponent(task.id)}&limit=200
|
|
27
27
|
Inspect event order, type/status, source, data/phase, duplicate events, missing expected lifecycle events, late events, and execution identifiers. If the event stream points to delivery/runtime failure, inspect the corresponding Relay/worker/tool logs too. Do not treat the final task.error string as the complete root cause.
|
|
28
28
|
|
|
29
|
-
Follow the selected Troubleshooter agent contract. Diagnose root cause from durable task state + event history + relevant runtime logs before retry. The final task.error string alone is never sufficient. Classify the evidence as model/provider/runtime, worker/tool/infrastructure, semantic task failure, or timeout/unknown. Only model/provider/runtime evidence may call the narrow model re-decision action; that action gathers fresh usage, reloads current policy, updates this exact Task's routing, and does not retry it. Troubleshooter must then reactivate/retry this exact original task and verify progress. 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
|
-
|
|
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.
|
|
32
|
-
task.
|
|
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:
|
|
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:
|
|
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
|
@@ -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|
|
|
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
|