agents-relay 2.0.156 → 2.0.158
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 +6 -27
- package/dist/chatgpt-platform-fallback.js +1 -1
- package/dist/cli-help.js +5 -6
- package/dist/cli-plan-clean-history.js +16 -0
- package/dist/cli-unblock.js +3 -14
- package/dist/cli-worker-task.js +11 -0
- package/dist/cli.js +27 -16
- package/dist/dashboard-actions.js +9 -19
- package/dist/dashboard-data.js +45 -0
- package/dist/dashboard-html.js +1 -1
- package/dist/dashboard.js +2 -52
- package/dist/host-invocation-store.js +2 -2
- package/dist/interactive-invocation.js +1 -1
- package/dist/interactive-resume.js +5 -5
- package/dist/legacy-blocked-task-migration.js +14 -0
- package/dist/legacy-orchestrator-repair.js +6 -6
- package/dist/manual-plan.js +19 -1
- package/dist/planner-recovery.js +1 -1
- package/dist/planner-runtime.js +9 -1
- package/dist/planner.js +18 -23
- package/dist/projection-outbox.js +1 -1
- package/dist/reconciler-review.js +4 -3
- package/dist/reconciler-runtime.js +15 -19
- package/dist/reconciler.js +3 -3
- package/dist/relay-store.js +2 -0
- package/dist/relayd-control.js +14 -7
- package/dist/single-job-task-control.js +1 -1
- package/dist/task-actions.js +28 -3
- package/dist/task-recovery.js +1 -1
- package/dist/troubleshooter.js +1 -1
- package/dist/types.js +12 -6
- package/dist/unblock.js +5 -44
- package/dist/workflow/authoritative-unblock-v2.js +6 -7
- package/dist/workflow/contracts.js +3 -0
- package/dist/workflow/definitions-v2.js +3 -2
- package/dist/workflow/lifecycle.js +5 -5
- package/dist/workflow/orchestrator-rescue.js +13 -12
- package/dist/workflow/runtime.js +13 -2
- package/dist/workflow/task-resolution.js +52 -15
- package/dist/workflow/troubleshooter-v2.js +5 -3
- package/package.json +3 -3
- package/skills/agents-relay/agents/planner.agent.md +2 -2
- package/skills/agents-relay/agents/troubleshooter.agent.md +5 -3
- package/skills/agents-relay/contracts/task-job-contract.md +18 -6
- package/workflow/authoritative-unblock.mmd +6 -9
- package/workflow/blocker-resolution.mmd +6 -16
- package/workflow/orchestrator-rescue.mmd +3 -3
- package/workflow/task-lifecycle.mmd +45 -59
- package/workflow/task-resolution.mmd +24 -3
- package/workflow/troubleshooter.mmd +20 -1
package/dist/planner-recovery.js
CHANGED
|
@@ -9,7 +9,7 @@ export function createFailedPlanRecovery(job, plan) {
|
|
|
9
9
|
throw Error('Plan recovery identity already exists');
|
|
10
10
|
const now = new Date().toISOString();
|
|
11
11
|
return createTaskEntity(job, {
|
|
12
|
-
jobId: job.id, id, title: '
|
|
12
|
+
jobId: job.id, id, title: 'Troubleshoot',
|
|
13
13
|
recoveryPlanId: plan.id,
|
|
14
14
|
kind: 'work', priority: 'P0', storySize: 'M', projectName: job.title,
|
|
15
15
|
agentName: 'troubleshooter', parentTaskId: null, dependencies: [],
|
package/dist/planner-runtime.js
CHANGED
|
@@ -71,7 +71,7 @@ export async function finishPlanner(ports, jobId, planId, executionId, error, ra
|
|
|
71
71
|
ports.live.delete(planId); };
|
|
72
72
|
const job = await ports.store.load(jobId);
|
|
73
73
|
const plan = (job.plans ?? []).find(item => item.id === planId);
|
|
74
|
-
if (!plan || plan.
|
|
74
|
+
if (!plan || (plan.executionId !== executionId && !(raw?.cliApplied && plan.status === 'SUCCEEDED'))) {
|
|
75
75
|
releaseLive();
|
|
76
76
|
if (cliApply)
|
|
77
77
|
throw new Error(`Plan ${planId} is not owned by this active execution`);
|
|
@@ -118,6 +118,14 @@ export async function finishPlanner(ports, jobId, planId, executionId, error, ra
|
|
|
118
118
|
if (retry && !plan.manualPlanning)
|
|
119
119
|
ports.detach(ports.reconcile(jobId), jobId, planId, 'planner retry reconciliation');
|
|
120
120
|
};
|
|
121
|
+
if (raw?.cliApplied) {
|
|
122
|
+
if (plan.status !== 'SUCCEEDED') {
|
|
123
|
+
releaseLive();
|
|
124
|
+
throw new Error(`Planner reported CLI application, but Plan ${planId} is ${plan.status}`);
|
|
125
|
+
}
|
|
126
|
+
releaseLive();
|
|
127
|
+
return;
|
|
128
|
+
}
|
|
121
129
|
if (error) {
|
|
122
130
|
if (cliApply)
|
|
123
131
|
throw error;
|
package/dist/planner.js
CHANGED
|
@@ -18,12 +18,12 @@ export function semanticTaskTree(tasks) {
|
|
|
18
18
|
const ids = new Set(work.map(task => task.id));
|
|
19
19
|
return {
|
|
20
20
|
roots: work.filter(task => !task.parentTaskId || !ids.has(task.parentTaskId)).map(task => task.id),
|
|
21
|
-
tasks: work.map(task => ({ id: task.id, title: task.title, parent_task_id: task.parentTaskId, state: task.state, dependencies: [...task.dependencies], planning_decision_id: task.planningDecisionId, planning_round: task.planningRound, review: task.review })),
|
|
21
|
+
tasks: work.map(task => ({ id: task.id, title: task.title, parent_task_id: task.parentTaskId, state: task.state, dependencies: [...task.dependencies], planning_decision_id: task.planningDecisionId, planning_round: task.planningRound, review: task.review, resolution_classification: task.resolutionClassification })),
|
|
22
22
|
};
|
|
23
23
|
}
|
|
24
24
|
function plannerTrigger(job, manual = false) {
|
|
25
25
|
const consumed = new Set((job.plans ?? []).filter(plan => plan.status === 'SUCCEEDED').flatMap(plan => plan.trigger.source === 'unblock' ? plan.trigger.taskIds ?? [] : []));
|
|
26
|
-
const unblocked = job.tasks.filter(task => ['DRAFT', 'QUEUED'
|
|
26
|
+
const unblocked = job.tasks.filter(task => ['DRAFT', 'QUEUED'].includes(task.state) && task.unblock && !consumed.has(task.id));
|
|
27
27
|
const hasUnblock = unblocked.length > 0 || Boolean(job.changeCycle && !job.changeCycle.consumedByPlannerTaskId);
|
|
28
28
|
const decision = dispatchPlannerTrigger({
|
|
29
29
|
manualPlan: manual && !hasUnblock,
|
|
@@ -107,6 +107,7 @@ export function materializePlannerChild(job, planner, spec, executionContext) {
|
|
|
107
107
|
const round = 'round' in planner ? planner.round : planner.planningRound ?? 1;
|
|
108
108
|
const plannerHeadSha = 'round' in planner ? planner.headSha : undefined;
|
|
109
109
|
const reviewHeadSha = spec.agentName === 'reviewer' && !(spec.dependencies?.length) ? plannerHeadSha ?? job.headSha : undefined;
|
|
110
|
+
const objectiveRoot = spec.agentName === 'reviewer' ? job.tasks.find(task => task.kind !== 'planner' && task.agentName !== 'reviewer' && task.agentName !== 'troubleshooter' && !task.parentTaskId) : undefined;
|
|
110
111
|
const resolution = spec.resolution;
|
|
111
112
|
if (resolution) {
|
|
112
113
|
const target = resolution.targetTaskId ? job.tasks.find(task => task.id === resolution.targetTaskId) : undefined;
|
|
@@ -115,7 +116,7 @@ export function materializePlannerChild(job, planner, spec, executionContext) {
|
|
|
115
116
|
validateResolutionProposal(job, target, resolution);
|
|
116
117
|
}
|
|
117
118
|
const reviewerThread = spec.agentName === 'reviewer' ? prepareReviewerThreadDelivery(job, { id: spec.id, agentName: spec.agentName, reviewHeadSha }) : undefined, implementationThread = (spec.agentName === 'implementation' || spec.agentName == null) && job.implementationThreadId ? job.implementationThreadId : undefined, threadOwned = Boolean(reviewerThread || implementationThread);
|
|
118
|
-
return createTaskEntity(job, { jobId: job.id, id: spec.id, title: spec.title, kind: 'work', priority: spec.priority ?? job.priority, storySize: spec.storySize ?? 'M', projectName: spec.projectName, agentName: spec.agentName, agentGraph: spec.agentGraph, review: spec.review, parentTaskId: spec.parentTaskId ?? null, dependencies: spec.dependencies ?? [], capabilities: spec.capabilities ?? [], workerRequirements: threadOwned ? chatgptThreadRequirements(spec.workerRequirements) : spec.workerRequirements, testInjection: spec.testInjection, adapter: threadOwned ? 'chatgpt' : (spec.adapter ?? 'codex'), chatgptThreadDelivery: reviewerThread, input: spec.input, output: spec.output, continuation: spec.continuation ?? null, continuationDeliveredAt: null, attempt: 0, maxAttempts: spec.maxAttempts ?? 3, leaseOwner: null, leaseExpiresAt: null, executionId: null, threadId: null, result: null, plannerResult: null, error: null, timeoutMs: spec.timeoutMs ?? taskOverallTimeoutMs(spec.storySize ?? 'M'), createdAt: now, updatedAt: now, planningDecisionId: decisionId, planningSource: source, planningRound: round, reviewHeadSha, resolution: resolution ? { ...resolution, state: 'proposed', owner: 'planner', updatedAt: now } : undefined });
|
|
119
|
+
return createTaskEntity(job, { jobId: job.id, id: spec.id, title: spec.agentName === 'reviewer' ? 'Review' : spec.title, kind: 'work', priority: spec.priority ?? job.priority, storySize: spec.storySize ?? 'M', projectName: spec.projectName, agentName: spec.agentName, agentGraph: spec.agentGraph, review: spec.review, parentTaskId: objectiveRoot?.id ?? spec.parentTaskId ?? null, dependencies: spec.dependencies ?? [], capabilities: spec.capabilities ?? [], workerRequirements: threadOwned ? chatgptThreadRequirements(spec.workerRequirements) : spec.workerRequirements, testInjection: spec.testInjection, adapter: threadOwned ? 'chatgpt' : (spec.adapter ?? 'codex'), chatgptThreadDelivery: reviewerThread, input: spec.input, output: spec.output, continuation: spec.continuation ?? null, continuationDeliveredAt: null, attempt: 0, maxAttempts: spec.maxAttempts ?? 3, leaseOwner: null, leaseExpiresAt: null, executionId: null, threadId: null, result: null, plannerResult: null, error: null, timeoutMs: spec.timeoutMs ?? taskOverallTimeoutMs(spec.storySize ?? 'M'), createdAt: now, updatedAt: now, planningDecisionId: decisionId, planningSource: source, planningRound: round, reviewHeadSha, resolution: resolution ? { ...resolution, state: 'proposed', owner: 'planner', updatedAt: now } : undefined });
|
|
119
120
|
}
|
|
120
121
|
export function planningSpec(task) {
|
|
121
122
|
if (!task.output)
|
|
@@ -386,7 +387,7 @@ export function validatePlannerHierarchy(existing, specs) {
|
|
|
386
387
|
/** Planner may only materialize a new business Task while unresolved work is either
|
|
387
388
|
* explicitly resolved in this round or already owned by an active resolution path. */
|
|
388
389
|
export function validatePlannerResolutionMaterialization(existing, specs) {
|
|
389
|
-
const unresolved = existing.filter(task => unresolvedTask(task) && ['FAILED', 'BLOCKED'].includes(task.state));
|
|
390
|
+
const unresolved = existing.filter(task => unresolvedTask(task) && ['FAILED', 'BLOCKED'].includes(task.state) && !task.resolutionClassification);
|
|
390
391
|
if (unresolved.length === 0)
|
|
391
392
|
return;
|
|
392
393
|
const resolvedIds = new Set(specs.map(spec => spec.resolution?.targetTaskId).filter((id) => Boolean(id)));
|
|
@@ -416,6 +417,7 @@ export function plannerInput(context) {
|
|
|
416
417
|
const project = context.executionContext?.project;
|
|
417
418
|
return JSON.stringify({
|
|
418
419
|
objective: context.objective,
|
|
420
|
+
apply_cli: context.planExecution ? `agents-relay plan apply --repo ${context.job.repository} --pr ${context.job.prNumber} --id ${context.job.id} --plan-id ${context.planExecution.planId} --execution-id ${context.planExecution.executionId} --file PATH` : undefined,
|
|
419
421
|
plan_execution: context.planExecution, retry_feedback: context.retryFeedback,
|
|
420
422
|
caller_prompt: context.guidance,
|
|
421
423
|
previous_planner_task_id: context.previousPlannerTaskId,
|
|
@@ -449,6 +451,7 @@ export function plannerInput(context) {
|
|
|
449
451
|
assessment: boundedText(task.plannerResult.assessment),
|
|
450
452
|
next_task_ids: task.plannerResult.next_tasks.map(item => item.id),
|
|
451
453
|
} : undefined,
|
|
454
|
+
resolution_classification: task.resolutionClassification,
|
|
452
455
|
error: boundedText(task.error, 2000),
|
|
453
456
|
blocked_baseline: task.blockedBaseline,
|
|
454
457
|
unblock: task.unblock,
|
|
@@ -470,12 +473,10 @@ export class CommandObjectivePlanner {
|
|
|
470
473
|
child.stdout?.on('data', (chunk) => { stdout += chunk.toString(); });
|
|
471
474
|
child.stderr?.on('data', (chunk) => { stderr += chunk.toString(); });
|
|
472
475
|
child.on('error', reject);
|
|
473
|
-
child.on('close', code => code === 0 ? (() => {
|
|
474
|
-
|
|
475
|
-
|
|
476
|
-
|
|
477
|
-
reject(error);
|
|
478
|
-
} })() : reject(new Error(stderr.trim() || `Planner exited ${code}`)));
|
|
476
|
+
child.on('close', code => code === 0 ? (() => { const expected = context.planExecution ? `PLAN_APPLIED:${context.planExecution.planId}:${context.planExecution.executionId}` : ''; if (stdout.trim() !== expected || !expected) {
|
|
477
|
+
reject(new Error('Planner command must commit its proposal through agents-relay plan apply and return the matching PLAN_APPLIED receipt'));
|
|
478
|
+
return;
|
|
479
|
+
} resolve({ objective_status: 'satisfied', assessment: 'Plan committed through the authorized CLI action.', next_tasks: [], unblocks: [], cliApplied: true }); })() : reject(new Error(stderr.trim() || `Planner exited ${code}`)));
|
|
479
480
|
child.stdin?.end(plannerInput(context));
|
|
480
481
|
});
|
|
481
482
|
}
|
|
@@ -539,18 +540,12 @@ export class AgentObjectivePlanner {
|
|
|
539
540
|
resolve(finalMessage);
|
|
540
541
|
});
|
|
541
542
|
});
|
|
542
|
-
|
|
543
|
-
|
|
544
|
-
|
|
545
|
-
|
|
546
|
-
|
|
547
|
-
|
|
548
|
-
|
|
549
|
-
if (signal.aborted || retry === 3)
|
|
550
|
-
throw new Error(`Planner validation failed after ${retry} corrections: ${error instanceof Error ? error.message : String(error)}`);
|
|
551
|
-
response = await runAgent(`${prompt}\n\n## Fix validation error (correction ${retry + 1} of 3)\n${error instanceof Error ? error.message : String(error)}\nPrevious JSON:\n${response.slice(0, 12000)}\nReturn exactly one complete Planner JSON object. Correct all schema, title, resolution, and dependency requirements; do not remove valid work or bypass safeguards.`);
|
|
552
|
-
}
|
|
553
|
-
}
|
|
554
|
-
throw new Error('Planner correction exhausted');
|
|
543
|
+
if (!context.planExecution)
|
|
544
|
+
throw new Error('Planner execution is missing its durable Plan and execution identity');
|
|
545
|
+
const response = await runAgent(prompt);
|
|
546
|
+
const expected = `PLAN_APPLIED:${context.planExecution.planId}:${context.planExecution.executionId}`;
|
|
547
|
+
if (response !== expected)
|
|
548
|
+
throw new Error('Planner must commit its proposal with the authorized `agents-relay plan apply` CLI action and return only the matching PLAN_APPLIED receipt');
|
|
549
|
+
return { objective_status: 'satisfied', assessment: 'Plan committed through the authorized CLI action.', next_tasks: [], unblocks: [], cliApplied: true };
|
|
555
550
|
}
|
|
556
551
|
}
|
|
@@ -146,14 +146,15 @@ export class ReconcilerReviewBase extends ReconcilerRuntimeBase {
|
|
|
146
146
|
reviewPrNumber > 0 ? `Review PR #${reviewPrNumber} at exactly head SHA ${headSha}.` : `Review the local Job artifact at exactly head SHA ${headSha}.`,
|
|
147
147
|
`Review head SHA: ${headSha}`,
|
|
148
148
|
'Job objective: ' + (job.objective ?? job.description ?? job.title),
|
|
149
|
+
'Review the complete PR diff at this exact immutable head SHA, not only the dependency Tasks.',
|
|
149
150
|
'Use the selected reviewer agent contract as the authoritative review protocol.',
|
|
150
151
|
'This is a PRE-MERGE artifact approval gate: inspect exact PR head, requirements, tests and genuine blocking defects.',
|
|
151
152
|
'Do not block approval because automatic merge, Job COMPLETED, or post-merge E2E evidence has not occurred yet.',
|
|
152
153
|
'Do not treat historical failed attempts as current artifact defects; distinguish preserved baseline files from implementation.',
|
|
153
154
|
].join('\n\n');
|
|
154
155
|
const task = createTaskEntity(job, {
|
|
155
|
-
jobId: job.id, id, title:
|
|
156
|
-
parentTaskId: null, dependencies: reviewGateTasks.map(item => item.id), capabilities: ['review'],
|
|
156
|
+
jobId: job.id, id, title: 'Review', kind: 'work', priority: job.priority, storySize: 'M', projectName: job.title, agentName: 'reviewer',
|
|
157
|
+
parentTaskId: reviewGateTasks.find(item => !item.parentTaskId)?.id ?? null, dependencies: reviewGateTasks.map(item => item.id), capabilities: ['review'],
|
|
157
158
|
workerSelection: reviewerRouting.selection, adapter: reviewerRouting.candidate.adapter,
|
|
158
159
|
input,
|
|
159
160
|
output: { kind: 'task_pr' }, routing: reviewerRouting.candidate.routing, continuation: null, continuationDeliveredAt: null,
|
|
@@ -163,7 +164,7 @@ export class ReconcilerReviewBase extends ReconcilerRuntimeBase {
|
|
|
163
164
|
error: null, timeoutMs: this.options.reviewer?.timeoutMs ?? storySizeTimeoutMs('M'), createdAt: now, updatedAt: now
|
|
164
165
|
});
|
|
165
166
|
await this.store.appendTask(task);
|
|
166
|
-
await this.emit(eventFor(job.id, id,
|
|
167
|
+
await this.emit(eventFor(job.id, id, task.parentTaskId, 'review.created', 'queued', 'Final review task ' + id + ' created for ' + headSha + ' using ' + reviewerRouting.reason, 'orchestrator', { headSha, agentName: 'reviewer', reviewerRouting: { workerId: reviewerRouting.selection.workerId, reason: reviewerRouting.reason, selectedBy: reviewerRouting.selection.selectedBy } }));
|
|
167
168
|
return id;
|
|
168
169
|
}
|
|
169
170
|
async projectedReviewHeadReady(job, headSha) {
|
|
@@ -13,7 +13,7 @@ import { createWorkerStartAck } from './worker-start-ack.js';
|
|
|
13
13
|
import { monitorExecutionProcess } from './execution-process-tracker.js';
|
|
14
14
|
import { HostInteractiveInvocationCoordinator, hostInvocationPorts } from './interactive-invocation.js';
|
|
15
15
|
import { resumeInteractiveTask } from './interactive-resume.js';
|
|
16
|
-
import {
|
|
16
|
+
import { persistChatGptPlatformFailure } from './chatgpt-platform-fallback.js';
|
|
17
17
|
import { acceptTaskProgress, armInactivityWatchdog, inactivityDeadline, isCorrelatedWorkerEvent, isRecoverableWatchdogFailure } from './task-inactivity-watchdog.js';
|
|
18
18
|
const activeExecutionOwners = new Map();
|
|
19
19
|
import { withExecutionBoundary } from './execution-boundary.js';
|
|
@@ -257,7 +257,10 @@ export class ReconcilerRuntimeBase {
|
|
|
257
257
|
const message = failure instanceof Error ? failure.message : String(failure);
|
|
258
258
|
const currentJob = await this.store.load(jobId);
|
|
259
259
|
const currentTask = currentJob.tasks.find(task => task.id === taskId);
|
|
260
|
+
const activeExecution = this.live.get(taskId);
|
|
260
261
|
const platformFailure = currentTask?.adapter === 'chatgpt' ? classifyChatGptPlatformFailure(failure, phase) : undefined;
|
|
262
|
+
const unknownOutcome = phase === 'start-ack' || ['outcome_unknown', 'start_ack_timeout'].includes(platformFailure?.classification ?? '')
|
|
263
|
+
|| (phase === 'delivery' && activeExecution?.executionId === executionId && activeExecution.workerStarted);
|
|
261
264
|
const failureReason = phase === 'start-ack' ? 'worker_start_timeout'
|
|
262
265
|
: /WEBHELM_UNAVAILABLE|webhelm runtime dependency unavailable|webhelm session start returned no session_id|no close frame received or sent/i.test(message)
|
|
263
266
|
? 'browser_workspace_unavailable'
|
|
@@ -269,19 +272,14 @@ export class ReconcilerRuntimeBase {
|
|
|
269
272
|
const cleanup = setTimeout(() => this.launchFailureClaims.delete(claim), 60000);
|
|
270
273
|
cleanup.unref();
|
|
271
274
|
const workflowEvent = platformFailure ? 'worker.platform_failure' : phase === 'start-ack' ? 'worker.start_ack_failed' : 'worker.delivery_failed';
|
|
272
|
-
const usage = this.options.usageSnapshotProvider ? await this.options.usageSnapshotProvider() : undefined;
|
|
273
|
-
const platformWorkerCompatible = Boolean(platformFailure?.safeToReroute && currentTask && !(currentTask.agentName === 'reviewer' && this.options.reviewer?.threadId)
|
|
274
|
-
&& await codexFallbackCompatible(currentTask, this.options.adapters, await this.store.listAgents(), usage));
|
|
275
275
|
const transition = await this.taskTransitions.apply(jobId, taskId, platformFailure ? 'worker.platform_failure' : workflowEvent, {
|
|
276
276
|
eligible: (_job, task) => task.executionId === executionId,
|
|
277
|
-
facts: (_job, task) => ({ platformFallbackSafe: task.state === 'QUEUED' && Boolean(platformFailure?.safeToReroute && platformWorkerCompatible && task.attempt < task.maxAttempts) }),
|
|
278
277
|
mutate: (_job, task, decision) => {
|
|
279
278
|
if (platformFailure)
|
|
280
279
|
persistChatGptPlatformFailure(task, currentTask, executionId, platformFailure.classification, platformFailure.evidence, platformFailure.safeToReroute);
|
|
281
280
|
task.error = decision.target === 'QUEUED' ? null : `Worker ${phase} failed: ${message}`;
|
|
282
|
-
task.failureReason =
|
|
283
|
-
?
|
|
284
|
-
: failureReason;
|
|
281
|
+
task.failureReason = unknownOutcome
|
|
282
|
+
? 'worker_outcome_unknown' : platformFailure ? 'worker_platform_failure' : failureReason;
|
|
285
283
|
task.leaseOwner = null;
|
|
286
284
|
task.leaseExpiresAt = null;
|
|
287
285
|
},
|
|
@@ -300,14 +298,8 @@ export class ReconcilerRuntimeBase {
|
|
|
300
298
|
this.clearExecution(taskId, executionId);
|
|
301
299
|
}
|
|
302
300
|
}
|
|
303
|
-
const runtimeRetry = transition.decision.target === 'QUEUED' ? transition.task : null;
|
|
304
301
|
const event = eventFor(jobId, taskId, transition.task.parentTaskId, 'task.failed', 'failed', transition.task.error ?? `Worker ${phase} failed: ${message}`, 'user', { phase, reason: transition.task.failureReason ?? failureReason, workflowEvent, ...(transition.task.platformFailure ? { platformFailure: transition.task.platformFailure } : {}) }, { agent: 'relay', component: 'agents-relay', watchdog: true });
|
|
305
302
|
this.detach(this.emit(event), jobId, taskId, 'worker failure event');
|
|
306
|
-
if (runtimeRetry) {
|
|
307
|
-
this.detach(this.emit(eventFor(jobId, taskId, runtimeRetry.parentTaskId, 'task.rerouting', 'queued', 'ChatGPT platform rejection recorded; routing the same Task through Execution Router', 'orchestrator', { previousFailure: transition.task.platformFailure?.classification, executionId, attempt: runtimeRetry.attempt, maxAttempts: runtimeRetry.maxAttempts })), jobId, taskId, 'runtime retry event');
|
|
308
|
-
this.detach(this.eventCoordinator.accept({ jobId, reason: 'webhelm-runtime-retry' }), jobId, taskId, 'runtime retry reconcile wake');
|
|
309
|
-
return;
|
|
310
|
-
}
|
|
311
303
|
this.detach(this.eventCoordinator.accept({ jobId, reason: `worker-${phase}-failure` }), jobId, taskId, 'worker failure reconcile wake');
|
|
312
304
|
}
|
|
313
305
|
async runtimeSettled(jobId, taskId, executionId, result, error) {
|
|
@@ -403,11 +395,15 @@ export class ReconcilerRuntimeBase {
|
|
|
403
395
|
async applyTerminalEvent(event, trustedInternal = false) {
|
|
404
396
|
const liveAtTerminal = this.live.get(event.task_id);
|
|
405
397
|
const terminalEventExecutionId = executionIdFromSource(eventSourceId(event), event.job_id, event.task_id);
|
|
406
|
-
const
|
|
398
|
+
const lifecycleEvent = event.type === 'task.blocked'
|
|
399
|
+
? (await this.store.load(event.job_id)).tasks.find(task => task.id === event.task_id)?.interactiveInvocation?.status === 'awaiting_decision'
|
|
400
|
+
? 'interactive.waiting' : event.type
|
|
401
|
+
: event.type;
|
|
402
|
+
const transition = await withExecutionBoundary(event.job_id, event.task_id, () => this.taskTransitions.apply(event.job_id, event.task_id, lifecycleEvent, {
|
|
407
403
|
eligible: (_job, task) => {
|
|
408
404
|
if (!['codex', 'chatgpt', 'orchestrator'].includes(task.adapter))
|
|
409
405
|
return false;
|
|
410
|
-
if (event.type === 'task.blocked' && (!trustedInternal || task.agentName !== 'troubleshooter')
|
|
406
|
+
if (event.type === 'task.blocked' && lifecycleEvent !== 'interactive.waiting' && (!trustedInternal || task.agentName !== 'troubleshooter'))
|
|
411
407
|
return false;
|
|
412
408
|
if (task.agentName === 'reviewer' && event.type === 'task.completed')
|
|
413
409
|
return false;
|
|
@@ -426,7 +422,7 @@ export class ReconcilerRuntimeBase {
|
|
|
426
422
|
facts: (_job, task) => {
|
|
427
423
|
const processExitedAfterPublishedTerminal = task.failureReason === 'worker_exit_without_terminal_event';
|
|
428
424
|
const freshEnough = processExitedAfterPublishedTerminal || isCorrelatedWorkerEvent(task, event);
|
|
429
|
-
return { recoverableExecution: isRecoverableWatchdogFailure(task) && freshEnough };
|
|
425
|
+
return { recoverableExecution: isRecoverableWatchdogFailure(task) && freshEnough, awaitingInteractiveDecision: lifecycleEvent === 'interactive.waiting' && task.interactiveInvocation?.status === 'awaiting_decision' };
|
|
430
426
|
},
|
|
431
427
|
mutate: (job, task) => {
|
|
432
428
|
const modelBacked = task.adapter === 'codex' || task.adapter === 'chatgpt';
|
|
@@ -457,7 +453,7 @@ export class ReconcilerRuntimeBase {
|
|
|
457
453
|
task.error = event.message || `Task ${task.id} failed`;
|
|
458
454
|
task.failureReason = typeof event.data?.reason === 'string' ? event.data.reason : undefined;
|
|
459
455
|
}
|
|
460
|
-
else if (event.type === 'task.blocked') {
|
|
456
|
+
else if (event.type === 'task.blocked' && lifecycleEvent !== 'interactive.waiting') {
|
|
461
457
|
task.error = event.message || `Task ${task.id} blocked`;
|
|
462
458
|
const data = event.data ?? {};
|
|
463
459
|
const head = ['candidate_head', 'candidate_head_sha', 'pr_head', 'head_sha']
|
|
@@ -484,7 +480,7 @@ export class ReconcilerRuntimeBase {
|
|
|
484
480
|
if (event.type === 'task.completed')
|
|
485
481
|
await this.deliverContinuation(transition.job, transition.task);
|
|
486
482
|
const reviewWorker = transition.task.agentName === 'reviewer';
|
|
487
|
-
return { applied: true, reconcile: !reviewWorker || event.type === 'task.failed', reason: reviewWorker ? `${event.type}.review-worker` : event.type, reconcileMode: reviewWorker ? undefined : 'detached' };
|
|
483
|
+
return { applied: true, reconcile: lifecycleEvent !== 'interactive.waiting' && (!reviewWorker || event.type === 'task.failed'), reason: reviewWorker ? `${event.type}.review-worker` : event.type, reconcileMode: reviewWorker || lifecycleEvent === 'interactive.waiting' ? undefined : 'detached' };
|
|
488
484
|
}
|
|
489
485
|
terminalWait(taskId, executionId, launchBarrier) {
|
|
490
486
|
let resolve;
|
package/dist/reconciler.js
CHANGED
|
@@ -137,7 +137,7 @@ export class Reconciler extends ReconcilerReviewBase {
|
|
|
137
137
|
}
|
|
138
138
|
for (const recovery of pendingTroubleshooterTasks(await this.store.load(jobId))) {
|
|
139
139
|
await this.store.appendTask(recovery);
|
|
140
|
-
await this.emit(eventFor(job.id, recovery.id,
|
|
140
|
+
await this.emit(eventFor(job.id, recovery.id, recovery.parentTaskId, "troubleshooter.created", "queued", "Troubleshooter task " + recovery.id + " created for failed/blocked work", "orchestrator", { agentName: "troubleshooter", originalTaskId: recovery.parentTaskId }));
|
|
141
141
|
}
|
|
142
142
|
{
|
|
143
143
|
const recoveryJob = await this.store.load(jobId);
|
|
@@ -273,8 +273,8 @@ export class Reconciler extends ReconcilerReviewBase {
|
|
|
273
273
|
}
|
|
274
274
|
return this.store.load(jobId);
|
|
275
275
|
}
|
|
276
|
-
async planJob(jobId, guidance) {
|
|
277
|
-
return executeManualPlan(this.store, jobId, guidance, this.options.plannerTimeoutMs ?? storySizeTimeoutMs('M'), (job, plan) => this.launchPlanner(job, plan));
|
|
276
|
+
async planJob(jobId, guidance, cleanHistory) {
|
|
277
|
+
return executeManualPlan(this.store, jobId, guidance, this.options.plannerTimeoutMs ?? storySizeTimeoutMs('M'), (job, plan) => this.launchPlanner(job, plan), cleanHistory);
|
|
278
278
|
}
|
|
279
279
|
async maybeCreatePlanner(job) {
|
|
280
280
|
const pending = pendingPlan(job);
|
package/dist/relay-store.js
CHANGED
|
@@ -2,6 +2,7 @@ import { loadRelayJob, listRelayJobs } from './relay-job-snapshot-reads.js';
|
|
|
2
2
|
import { SQLiteWorkflowOperations } from './relay-workflow-operations.js';
|
|
3
3
|
import { preserveTaskComments } from './task-comments.js';
|
|
4
4
|
import { DatabaseSync } from 'node:sqlite';
|
|
5
|
+
import { migrateLegacyBlockedTasks } from './legacy-blocked-task-migration.js';
|
|
5
6
|
import { dirname } from 'node:path';
|
|
6
7
|
import { mkdirSync } from 'node:fs';
|
|
7
8
|
import { recordPublicationInDatabase, loadPublicationFromDatabase, listPublicationEventsFromDatabase } from './publication-store.js';
|
|
@@ -70,6 +71,7 @@ export class SQLiteRelayStore {
|
|
|
70
71
|
}
|
|
71
72
|
else
|
|
72
73
|
this.migrate();
|
|
74
|
+
migrateLegacyBlockedTasks(this.db);
|
|
73
75
|
this.outbox = new ProjectionOutbox(this.db, scopeJobId);
|
|
74
76
|
this.outbox.resetProcessing(new Date().toISOString());
|
|
75
77
|
}
|
package/dist/relayd-control.js
CHANGED
|
@@ -4,7 +4,7 @@ import { openLocalManagedStore } from './local-managed-store.js';
|
|
|
4
4
|
import { relayStoreDirectory } from './relay-store.js';
|
|
5
5
|
import { normalizeThreadId } from './reviewer-thread.js';
|
|
6
6
|
import { publishTaskBlocked } from './cli-support.js';
|
|
7
|
-
import { supersedeCancelledTask } from './task-actions.js';
|
|
7
|
+
import { classifyFailedTask, supersedeCancelledTask } from './task-actions.js';
|
|
8
8
|
import { requestTroubleshooterEscalation, resumeTroubleshooter } from './task-recovery.js';
|
|
9
9
|
import { adoptExistingPullRequestJob } from './cli-job-create.js';
|
|
10
10
|
export function createWorkspaceControl(deps) {
|
|
@@ -109,7 +109,8 @@ export function createWorkspaceControl(deps) {
|
|
|
109
109
|
if (command === 'job.plan') {
|
|
110
110
|
const reconciler = makeReconciler(store, concurrency);
|
|
111
111
|
try {
|
|
112
|
-
const
|
|
112
|
+
const cleanHistory = Array.isArray(body.cleanHistory) ? body.cleanHistory : undefined;
|
|
113
|
+
const planned = await reconciler.planJob(jobId, typeof body.guidance === 'string' && body.guidance.trim() ? body.guidance : undefined, cleanHistory);
|
|
113
114
|
await pool.reconcileLocalJob(jobId, 'control.job.plan');
|
|
114
115
|
return planned.round;
|
|
115
116
|
}
|
|
@@ -124,11 +125,8 @@ export function createWorkspaceControl(deps) {
|
|
|
124
125
|
const blocked = job.tasks.filter(isEffectivelyBlocked);
|
|
125
126
|
if (!blocked.length)
|
|
126
127
|
throw new Error(`Job ${job.id} has no effective BLOCKED work task`);
|
|
127
|
-
|
|
128
|
-
await
|
|
129
|
-
else
|
|
130
|
-
for (const task of blocked)
|
|
131
|
-
await unblockTask({ store, job, task, trigger: { source: 'cli', reason } });
|
|
128
|
+
for (const task of blocked)
|
|
129
|
+
await unblockTask({ store, job, task, trigger: { source: 'cli', reason } });
|
|
132
130
|
const refreshed = await store.load(jobId);
|
|
133
131
|
for (const task of blocked)
|
|
134
132
|
await publishTaskUnblocked(bus, refreshed, task.id, reason);
|
|
@@ -153,6 +151,15 @@ export function createWorkspaceControl(deps) {
|
|
|
153
151
|
await pool.reconcileLocalJob(jobId, 'control.task.supersede');
|
|
154
152
|
return await store.load(jobId);
|
|
155
153
|
}
|
|
154
|
+
if (command === 'task.classify') {
|
|
155
|
+
const taskId = typeof body.taskId === 'string' ? body.taskId : '';
|
|
156
|
+
const raw = body.classification;
|
|
157
|
+
if (!taskId || !raw || (raw.kind !== 'superseded' && raw.kind !== 'invalid') || typeof raw.targetTaskId !== 'string' || typeof raw.evidence !== 'string' || typeof raw.reason !== 'string')
|
|
158
|
+
throw new Error('task classify requires taskId, classification, targetTaskId, evidence, and reason');
|
|
159
|
+
await classifyFailedTask(store, jobId, taskId, { kind: raw.kind, targetTaskId: raw.targetTaskId, evidence: raw.evidence, reason: raw.reason });
|
|
160
|
+
await pool.reconcileLocalJob(jobId, 'control.task.classify');
|
|
161
|
+
return await store.load(jobId);
|
|
162
|
+
}
|
|
156
163
|
if (command === 'task.recover') {
|
|
157
164
|
const taskId = typeof body.taskId === 'string' ? body.taskId : '';
|
|
158
165
|
const recoveryTaskId = typeof body.recoveryTaskId === 'string' ? body.recoveryTaskId : '';
|
|
@@ -17,7 +17,7 @@ export async function createSingleJobTaskControl(jobId, store, reconciler, bus)
|
|
|
17
17
|
unblockAutonomousTasks: unblock.unblockAutonomousTasks, unblockTask: unblock.unblockTask,
|
|
18
18
|
publishTaskUnblocked, dispatchTaskAction,
|
|
19
19
|
});
|
|
20
|
-
const allowed = new Set(['task.unblock', 'task.recover', 'task.escalate', 'task.resume-troubleshooter']);
|
|
20
|
+
const allowed = new Set(['task.unblock', 'task.recover', 'task.escalate', 'task.resume-troubleshooter', 'task.classify']);
|
|
21
21
|
return async (body) => {
|
|
22
22
|
if (!allowed.has(String(body.command ?? '')))
|
|
23
23
|
throw new Error('Single-Job control command not supported');
|
package/dist/task-actions.js
CHANGED
|
@@ -1,6 +1,7 @@
|
|
|
1
1
|
import { extendRetryBudget, transitionTaskEvent, tryTransitionTaskEvent } from './scheduler.js';
|
|
2
2
|
import { selectRelatedTaskMutations } from './workflow/task-action-policy.js';
|
|
3
3
|
import { dispatchTaskResolutionSync, materializeResolution } from './workflow/task-resolution.js';
|
|
4
|
+
import { classifyTaskResolution } from './workflow/task-resolution.js';
|
|
4
5
|
export const TASK_RETRY_ACTION = 'task.retry';
|
|
5
6
|
export const TASK_RECOVER_ACTION = 'task.recover';
|
|
6
7
|
export const TASK_UNBLOCK_RETRY_ACTION = 'task.unblock.retry';
|
|
@@ -8,6 +9,24 @@ export const TASK_CANCEL_ACTION = 'task.cancel';
|
|
|
8
9
|
export const TASK_RELEASE_ACTION = 'task.release';
|
|
9
10
|
export const TASK_HOLD_ACTION = 'task.hold';
|
|
10
11
|
export const TASK_QUEUE_ACTION = 'task.queue';
|
|
12
|
+
export async function classifyFailedTask(store, jobId, taskId, classification) {
|
|
13
|
+
if (!store.mutateJob)
|
|
14
|
+
throw new Error('Task resolution classification requires atomic SQLite mutation');
|
|
15
|
+
let changed;
|
|
16
|
+
const job = await store.mutateJob(jobId, current => {
|
|
17
|
+
const task = current.tasks.find(item => item.id === taskId);
|
|
18
|
+
if (!task)
|
|
19
|
+
throw new Error(`Task ${taskId} not found`);
|
|
20
|
+
if (classification.targetTaskId === task.id || !current.tasks.some(item => item.id === classification.targetTaskId))
|
|
21
|
+
throw new Error('Task classification targetTaskId must identify a different durable Task in this Job');
|
|
22
|
+
classifyTaskResolution(task, classification);
|
|
23
|
+
changed = structuredClone(task);
|
|
24
|
+
return current;
|
|
25
|
+
});
|
|
26
|
+
if (!changed)
|
|
27
|
+
throw new Error(`Task ${taskId} classification was not committed`);
|
|
28
|
+
return { job, task: changed };
|
|
29
|
+
}
|
|
11
30
|
function lifecycleRejection(task, action) {
|
|
12
31
|
return new Error(`Task ${task.id} is not ${action} (${task.state})`);
|
|
13
32
|
}
|
|
@@ -186,8 +205,8 @@ export async function dispatchTaskAction(store, request) {
|
|
|
186
205
|
const task = current.tasks.find(item => item.id === request.taskId);
|
|
187
206
|
if (!task)
|
|
188
207
|
throw new Error(`Task ${request.taskId} not found`);
|
|
208
|
+
const recovery = request.id === TASK_RECOVER_ACTION && request.recoveryTaskId ? current.tasks.find(item => item.id === request.recoveryTaskId) : undefined;
|
|
189
209
|
if (request.id === TASK_RECOVER_ACTION) {
|
|
190
|
-
const recovery = request.recoveryTaskId ? current.tasks.find(item => item.id === request.recoveryTaskId) : undefined;
|
|
191
210
|
if (!recovery || recovery.agentName !== 'troubleshooter' || recovery.parentTaskId !== task.id || !['QUEUED', 'RUNNING'].includes(recovery.state))
|
|
192
211
|
throw new Error(`task recover requires an active Troubleshooter recovery for ${task.id}`);
|
|
193
212
|
}
|
|
@@ -198,7 +217,11 @@ export async function dispatchTaskAction(store, request) {
|
|
|
198
217
|
task.workerSelection = undefined;
|
|
199
218
|
}
|
|
200
219
|
else if (request.id === TASK_RECOVER_ACTION) {
|
|
201
|
-
|
|
220
|
+
if (!recovery)
|
|
221
|
+
throw new Error(`task recover requires an active Troubleshooter recovery for ${task.id}`);
|
|
222
|
+
if (!recovery.executionId)
|
|
223
|
+
throw new Error(`Troubleshooter ${recovery.id} has no current execution for a fresh resolution`);
|
|
224
|
+
const proposal = { path: 'exact_original_retry', targetTaskId: task.id, recoveryExecutionId: recovery.executionId, rootCause: task.failureReason ?? task.error ?? 'durable failure', solution: `Recover exact original Task via ${request.recoveryTaskId}` };
|
|
202
225
|
const decision = dispatchTaskResolutionSync(task, proposal, 'troubleshooter', { activateRecovery: () => { applyTaskRetrySnapshot(task, { extendBudget: true }); recordRecoveryRouting(task, request); task.resolution = { ...materializeResolution(task, proposal, 'troubleshooter'), state: 'resolved' }; } });
|
|
203
226
|
if (!decision.accepted)
|
|
204
227
|
throw new Error(`Task resolution graph rejected exact-original recovery for ${task.id}`);
|
|
@@ -232,7 +255,9 @@ export async function dispatchTaskAction(store, request) {
|
|
|
232
255
|
const recovery = request.recoveryTaskId ? job.tasks.find(item => item.id === request.recoveryTaskId) : undefined;
|
|
233
256
|
if (!recovery || recovery.agentName !== 'troubleshooter' || recovery.parentTaskId !== task.id || !['QUEUED', 'RUNNING'].includes(recovery.state))
|
|
234
257
|
throw new Error(`task recover requires an active Troubleshooter recovery for ${task.id}`);
|
|
235
|
-
|
|
258
|
+
if (!recovery.executionId)
|
|
259
|
+
throw new Error(`Troubleshooter ${recovery.id} has no current execution for a fresh resolution`);
|
|
260
|
+
const proposal = { path: 'exact_original_retry', targetTaskId: task.id, recoveryExecutionId: recovery.executionId, rootCause: task.failureReason ?? task.error ?? 'durable failure', solution: `Recover exact original Task via ${request.recoveryTaskId}` };
|
|
236
261
|
const decision = dispatchTaskResolutionSync(task, proposal, 'troubleshooter', { activateRecovery: () => { applyTaskRetrySnapshot(task, { extendBudget: true }); recordRecoveryRouting(task, request); task.resolution = { ...materializeResolution(task, proposal, 'troubleshooter'), state: 'resolved' }; } });
|
|
237
262
|
if (!decision.accepted)
|
|
238
263
|
throw new Error(`Task resolution graph rejected exact-original recovery for ${task.id}`);
|
package/dist/task-recovery.js
CHANGED
|
@@ -52,7 +52,7 @@ export async function requestTroubleshooterEscalation(store, jobId, recoveryTask
|
|
|
52
52
|
if (!['QUEUED', 'RUNNING', 'FAILED'].includes(recovery.state))
|
|
53
53
|
throw new Error(`Troubleshooter ${recoveryTaskId} cannot escalate from ${recovery.state}`);
|
|
54
54
|
if (original) {
|
|
55
|
-
const proposal = { path: 'escalation_gate', targetTaskId: original.id, rootCause: request.context.trim(), solution: request.requiredAction.trim() };
|
|
55
|
+
const proposal = { path: 'escalation_gate', targetTaskId: original.id, recoveryExecutionId: recovery.executionId ?? undefined, rootCause: request.context.trim(), solution: request.requiredAction.trim() };
|
|
56
56
|
const decision = dispatchTaskResolutionSync(original, proposal, 'troubleshooter', {
|
|
57
57
|
createEscalationGate: () => { original.resolution = materializeResolution(original, proposal, 'troubleshooter', now); },
|
|
58
58
|
});
|
package/dist/troubleshooter.js
CHANGED
package/dist/types.js
CHANGED
|
@@ -5,13 +5,19 @@ export function taskOverallTimeoutMs(size, reasoning) { return storySizeTimeoutM
|
|
|
5
5
|
export function chatGptStartAckTimeoutMs(overallTimeoutMs) { return Math.min(600000, Math.max(120000, Math.floor(overallTimeoutMs / 3))); }
|
|
6
6
|
/** Normalize snapshots written before UNBLOCKED was removed from the lifecycle graph. */
|
|
7
7
|
export function normalizeLegacyTaskLifecycle(task, executionMode) {
|
|
8
|
+
void executionMode;
|
|
8
9
|
const legacy = task;
|
|
9
|
-
if (legacy.state
|
|
10
|
-
|
|
11
|
-
|
|
12
|
-
|
|
13
|
-
|
|
14
|
-
|
|
10
|
+
if (legacy.state === 'UNBLOCKED') {
|
|
11
|
+
legacy.state = task.agentName === 'troubleshooter' ? 'QUEUED' : 'FAILED';
|
|
12
|
+
task.failureReason ??= 'legacy_unblocked_task_requires_recovery';
|
|
13
|
+
task.maxAttempts = Math.max(3, task.maxAttempts ?? 0, task.attempt + 1);
|
|
14
|
+
task.unblock = task.unblock ? { ...task.unblock, event: 'task.unblocked' } : undefined;
|
|
15
|
+
}
|
|
16
|
+
if (legacy.state === 'BLOCKED' && task.kind !== 'planner' && task.agentName !== 'troubleshooter') {
|
|
17
|
+
legacy.state = 'FAILED';
|
|
18
|
+
task.failureReason ??= task.failureReason === 'worker_outcome_unknown' ? task.failureReason : 'legacy_ordinary_blocked';
|
|
19
|
+
task.error ??= 'Legacy ordinary Task was BLOCKED; preserved as FAILED pending Troubleshooter diagnosis.';
|
|
20
|
+
}
|
|
15
21
|
return task;
|
|
16
22
|
}
|
|
17
23
|
export function assertJobLifecycleTransition(from, to) { assertV2StateTransition(jobLifecycleV2.graph, from, to, 'job'); }
|
package/dist/unblock.js
CHANGED
|
@@ -2,7 +2,7 @@ import { applyTaskUnblockRetrySnapshot } from './task-actions.js';
|
|
|
2
2
|
import { tryTransitionTaskEvent } from './scheduler.js';
|
|
3
3
|
import { dispatchAuthoritativeUnblock } from './workflow/authoritative-unblock-v2.js';
|
|
4
4
|
import { hasPendingTroubleshooterEscalation } from './task-recovery.js';
|
|
5
|
-
export function isEffectivelyBlocked(task) { return task.kind !== 'planner' && task.state === 'BLOCKED'; }
|
|
5
|
+
export function isEffectivelyBlocked(task) { return task.kind !== 'planner' && task.agentName === 'troubleshooter' && task.state === 'BLOCKED'; }
|
|
6
6
|
function assertNoPendingHumanEscalation(tasks) {
|
|
7
7
|
if (tasks.some(task => task.agentName === 'troubleshooter' && task.escalation?.status === 'pending'))
|
|
8
8
|
throw new Error('Blocked Troubleshooter requires an authorized human response; use task unblock --response');
|
|
@@ -32,7 +32,7 @@ function unblockFacts(job, tasks, currentHeadSha, reason) {
|
|
|
32
32
|
}
|
|
33
33
|
export function unconsumedUnblockTrigger(job) {
|
|
34
34
|
const consumed = new Set((job.plans ?? []).filter(plan => plan.status === 'SUCCEEDED').flatMap(plan => plan.trigger.source === 'unblock' ? plan.trigger.taskIds ?? [] : []));
|
|
35
|
-
const tasks = job.tasks.filter(task => task.unblock && !consumed.has(task.id) && ['DRAFT', 'QUEUED'
|
|
35
|
+
const tasks = job.tasks.filter(task => task.agentName !== 'troubleshooter' && task.unblock && !consumed.has(task.id) && ['DRAFT', 'QUEUED'].includes(task.state));
|
|
36
36
|
if (tasks.length === 0) {
|
|
37
37
|
const legacy = job.changeCycle;
|
|
38
38
|
if (!legacy || legacy.consumedByPlannerTaskId)
|
|
@@ -60,43 +60,8 @@ export function unblockNeedsNewPlan(job) {
|
|
|
60
60
|
&& latest.trigger.taskIds?.some(id => pending.taskIds?.includes(id)));
|
|
61
61
|
}
|
|
62
62
|
export async function unblockAutonomousTasks(options) {
|
|
63
|
-
|
|
64
|
-
|
|
65
|
-
if (tasks.length === 0)
|
|
66
|
-
throw new Error('No effective BLOCKED work task is available to unblock');
|
|
67
|
-
assertNoPendingHumanEscalation(tasks);
|
|
68
|
-
const now = options.now ?? new Date().toISOString();
|
|
69
|
-
const currentHeadSha = await store.headSha?.() ?? job.headSha ?? null;
|
|
70
|
-
const facts = unblockFacts(job, tasks, currentHeadSha, trigger.reason);
|
|
71
|
-
const apply = (current) => {
|
|
72
|
-
for (const requested of tasks) {
|
|
73
|
-
const task = current.tasks.find(item => item.id === requested.id);
|
|
74
|
-
if (!task || !isEffectivelyBlocked(task))
|
|
75
|
-
throw new Error(`Task ${requested.id} changed before authoritative unblock`);
|
|
76
|
-
const baseline = baselineFor(task);
|
|
77
|
-
if (!tryTransitionTaskEvent(task, 'task.unblocked', { autonomousJob: true }).accepted)
|
|
78
|
-
throw new Error(`Task ${task.id} cannot be authoritatively unblocked`);
|
|
79
|
-
task.blockedBaseline = baseline;
|
|
80
|
-
task.unblock = { event: 'task.unblocked', at: now, source: trigger.source, reason: trigger.reason, baseline, currentHeadSha: currentHeadSha ?? undefined, facts, blockerJobId: trigger.blockerJobId, resolution: trigger.resolution };
|
|
81
|
-
task.updatedAt = now;
|
|
82
|
-
delete task.blockedBy;
|
|
83
|
-
}
|
|
84
|
-
current.updatedAt = now;
|
|
85
|
-
};
|
|
86
|
-
if (store.mutateJob) {
|
|
87
|
-
const committed = await store.mutateJob(job.id, current => { apply(current); return current; });
|
|
88
|
-
Object.assign(job, structuredClone(committed));
|
|
89
|
-
}
|
|
90
|
-
else {
|
|
91
|
-
apply(job);
|
|
92
|
-
for (const task of tasks)
|
|
93
|
-
await store.saveTask(task);
|
|
94
|
-
}
|
|
95
|
-
const planTrigger = unconsumedUnblockTrigger(job) ?? {
|
|
96
|
-
source: 'unblock', taskIds: tasks.map(task => task.id), reason: trigger.reason,
|
|
97
|
-
facts, currentHeadSha: currentHeadSha ?? undefined, unblockSource: trigger.source,
|
|
98
|
-
};
|
|
99
|
-
return planTrigger;
|
|
63
|
+
void options;
|
|
64
|
+
throw new Error('Ordinary Tasks cannot be authoritatively unblocked; only a blocked Troubleshooter can resume');
|
|
100
65
|
}
|
|
101
66
|
export async function unblockTask(options) {
|
|
102
67
|
if (!isEffectivelyBlocked(options.task))
|
|
@@ -106,11 +71,7 @@ export async function unblockTask(options) {
|
|
|
106
71
|
throw new Error(`Task ${options.task.id} cannot be directly unblocked while its Troubleshooter is awaiting human action`);
|
|
107
72
|
}
|
|
108
73
|
let result;
|
|
109
|
-
const decision = await dispatchAuthoritativeUnblock(options.
|
|
110
|
-
if (actionId === 'unblock.replan') {
|
|
111
|
-
result = { replanning: true, planTrigger: await unblockAutonomousTasks({ store: options.store, job: options.job, tasks: [options.task], trigger: options.trigger, now: options.now }) };
|
|
112
|
-
return {};
|
|
113
|
-
}
|
|
74
|
+
const decision = await dispatchAuthoritativeUnblock(options.task.agentName === 'troubleshooter', async (actionId) => {
|
|
114
75
|
if (actionId === 'unblock.retry') {
|
|
115
76
|
const now = options.now ?? new Date().toISOString();
|
|
116
77
|
const currentHeadSha = await options.store.headSha?.() ?? options.job.headSha ?? null;
|
|
@@ -4,17 +4,16 @@ import { dispatchWorkflowGraph, dispatchWorkflowGraphSync } from './dispatcher.j
|
|
|
4
4
|
const source = readFileSync(new URL('../../workflow/authoritative-unblock.mmd', import.meta.url), 'utf8');
|
|
5
5
|
const boolGuard = (name) => (facts) => facts.durableFacts[name] === true;
|
|
6
6
|
export const authoritativeUnblockV2 = new GraphRuntime(source, {
|
|
7
|
-
states: new Set(['blocked', '
|
|
8
|
-
guards: {
|
|
7
|
+
states: new Set(['blocked', 'troubleshooter_queued']),
|
|
8
|
+
guards: { blockedTroubleshooter: boolGuard('blockedTroubleshooter') },
|
|
9
9
|
actions: {
|
|
10
|
-
'unblock.replan': { id: 'unblock.replan' },
|
|
11
10
|
'unblock.retry': { id: 'unblock.retry' },
|
|
12
11
|
},
|
|
13
12
|
events: { 'task.authoritative_unblock': { id: 'task.authoritative_unblock' } },
|
|
14
13
|
});
|
|
15
|
-
export async function dispatchAuthoritativeUnblock(
|
|
16
|
-
return dispatchWorkflowGraph(authoritativeUnblockV2, { initialState: 'blocked', input: { transport: 'event', id: 'task.authoritative_unblock', payload: {} }, graphName: 'authoritative-unblock', durableFacts: {
|
|
14
|
+
export async function dispatchAuthoritativeUnblock(blockedTroubleshooter, executeAction) {
|
|
15
|
+
return dispatchWorkflowGraph(authoritativeUnblockV2, { initialState: 'blocked', input: { transport: 'event', id: 'task.authoritative_unblock', payload: {} }, graphName: 'authoritative-unblock', durableFacts: { blockedTroubleshooter }, executeAction });
|
|
17
16
|
}
|
|
18
|
-
export function decideAuthoritativeUnblock(
|
|
19
|
-
return dispatchWorkflowGraphSync(authoritativeUnblockV2, { initialState: 'blocked', input: { transport: 'event', id: 'task.authoritative_unblock', payload: {} }, graphName: 'authoritative-unblock', durableFacts: {
|
|
17
|
+
export function decideAuthoritativeUnblock(blockedTroubleshooter) {
|
|
18
|
+
return dispatchWorkflowGraphSync(authoritativeUnblockV2, { initialState: 'blocked', input: { transport: 'event', id: 'task.authoritative_unblock', payload: {} }, graphName: 'authoritative-unblock', durableFacts: { blockedTroubleshooter } });
|
|
20
19
|
}
|