agents-relay 2.0.155 → 2.0.157

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.
Files changed (39) hide show
  1. package/dist/adapters.js +2 -2
  2. package/dist/blockers.js +6 -27
  3. package/dist/chatgpt-platform-fallback.js +1 -1
  4. package/dist/cli-help.js +1 -1
  5. package/dist/cli-unblock.js +3 -14
  6. package/dist/dashboard-actions.js +9 -19
  7. package/dist/dashboard-data.js +45 -0
  8. package/dist/dashboard.js +2 -52
  9. package/dist/host-invocation-store.js +2 -2
  10. package/dist/interactive-invocation.js +1 -1
  11. package/dist/interactive-resume.js +5 -5
  12. package/dist/legacy-blocked-task-migration.js +14 -0
  13. package/dist/legacy-orchestrator-repair.js +6 -6
  14. package/dist/planner.js +1 -1
  15. package/dist/projection-outbox.js +1 -1
  16. package/dist/reconciler-runtime.js +15 -19
  17. package/dist/relay-store.js +2 -0
  18. package/dist/relayd-control.js +2 -5
  19. package/dist/task-actions.js +9 -3
  20. package/dist/task-recovery.js +1 -1
  21. package/dist/types.js +12 -6
  22. package/dist/unblock.js +5 -44
  23. package/dist/workflow/authoritative-unblock-v2.js +6 -7
  24. package/dist/workflow/contracts.js +3 -0
  25. package/dist/workflow/definitions-v2.js +3 -2
  26. package/dist/workflow/lifecycle.js +5 -5
  27. package/dist/workflow/orchestrator-rescue.js +13 -12
  28. package/dist/workflow/runtime.js +13 -2
  29. package/dist/workflow/task-resolution.js +21 -13
  30. package/dist/workflow/troubleshooter-v2.js +5 -3
  31. package/package.json +1 -1
  32. package/skills/agents-relay/agents/troubleshooter.agent.md +5 -3
  33. package/skills/agents-relay/contracts/task-job-contract.md +6 -6
  34. package/workflow/authoritative-unblock.mmd +6 -9
  35. package/workflow/blocker-resolution.mmd +6 -16
  36. package/workflow/orchestrator-rescue.mmd +3 -3
  37. package/workflow/task-lifecycle.mmd +45 -59
  38. package/workflow/task-resolution.mmd +9 -3
  39. package/workflow/troubleshooter.mmd +20 -1
package/dist/adapters.js CHANGED
@@ -171,7 +171,7 @@ export function parseDriverJson(output) {
171
171
  }
172
172
  throw new Error('browser-worker driver returned no submitted receipt');
173
173
  }
174
- function browserWorkspaceCli(override = process.env.AGENTS_RELAY_BROWSER_WORKSPACE_COMMAND) {
174
+ function browserWorkspaceCli(override = process.env.AGENTS_RELAY_WEBHELM_COMMAND) {
175
175
  return override ? { command: override, prefix: [] } : { command: 'uvx', prefix: ['--from', 'browser-workspace', 'webhelm'] };
176
176
  }
177
177
  const browserWorkspaceInjection = new AsyncLocalStorage();
@@ -274,7 +274,7 @@ async function accessiblePromptReferences(prompt) {
274
274
  }
275
275
  return readable;
276
276
  }
277
- export async function ensureChatGptBrowserWorkspace(command = process.env.AGENTS_RELAY_BROWSER_WORKSPACE_COMMAND) {
277
+ export async function ensureChatGptBrowserWorkspace(command = process.env.AGENTS_RELAY_WEBHELM_COMMAND) {
278
278
  const status = parseWorkspaceJson(await browserWorkspaceCommand(browserWorkspaceCli(command), ['status']));
279
279
  if (typeof status.admin_helper !== 'string' || !status.admin_helper.startsWith('/')) {
280
280
  throw new Error('webhelm status returned no absolute admin_helper path');
package/dist/blockers.js CHANGED
@@ -1,5 +1,5 @@
1
- import { applyTaskReleaseAction, applyTaskUnblockRetryAction, dispatchTaskAction } from './task-actions.js';
2
- import { isEffectivelyBlocked, unblockAutonomousTasks, unblockTask } from './unblock.js';
1
+ import { applyTaskReleaseAction } from './task-actions.js';
2
+ import { isEffectivelyBlocked } from './unblock.js';
3
3
  import { dispatchBlockerResolution } from './workflow/blocker-resolution-v2.js';
4
4
  import { dispatchTaskResolutionSync, materializeResolution } from './workflow/task-resolution.js';
5
5
  function dependentKey(value) {
@@ -99,47 +99,25 @@ export async function resolveBlockerDependencies(options) {
99
99
  loadError = error instanceof Error ? error.message : String(error);
100
100
  }
101
101
  const targetMatches = !!task && ['DRAFT', 'FAILED', 'BLOCKED', 'QUEUED'].includes(task.state) && task.attempt === relation.blockedAttempt;
102
- let note = 'dependent task requeued';
102
+ let note = 'dependent task released from dependency wait';
103
103
  const decision = await dispatchBlockerResolution({
104
104
  resolutionAllowed: !(mode === 'verified' && options.resolution !== 'verified'),
105
105
  targetMatches,
106
106
  autonomousJob: targetJob?.executionMode === 'autonomous',
107
107
  effectivelyBlocked: !!task && isEffectivelyBlocked(task),
108
108
  alreadyUnblocked: Boolean(task?.unblock && ['DRAFT', 'QUEUED'].includes(task.state)),
109
- isDraft: task?.state === 'DRAFT', isBlocked: task?.state === 'BLOCKED',
109
+ isDraft: task?.state === 'DRAFT', isBlocked: Boolean(task && isEffectivelyBlocked(task)),
110
110
  isFailed: task?.state === 'FAILED', isUnblocked: Boolean(task?.unblock && ['DRAFT', 'QUEUED'].includes(task.state)),
111
111
  }, async (actionId) => {
112
112
  if (!targetJob || !task)
113
113
  throw new Error('Blocker graph requested action without a matching dependent task');
114
- if (actionId === 'blocker.autonomousUnblock') {
115
- await unblockAutonomousTasks({ store, job: targetJob, tasks: [task], trigger: { source: 'blocker_job', reason: 'Blocker Job ' + blocker.id + ' resolved (' + options.resolution + ')', blockerJobId: blocker.id, resolution: options.resolution }, now });
116
- unblocked.push({ repository: relation.repository, prNumber: relation.prNumber, jobId: relation.jobId, taskId: relation.taskId });
117
- note = 'dependent task unblocked for replanning';
118
- }
119
- else if (actionId === 'blocker.acceptExistingUnblock')
120
- note = 'dependent Job already unblocked for replanning';
121
- else if (actionId === 'blocker.releaseDraft') {
114
+ if (actionId === 'blocker.releaseDraft') {
122
115
  await applyTaskReleaseAction(task);
123
116
  task.error = null;
124
117
  task.updatedAt = now;
125
118
  delete task.blockedBy;
126
119
  await store.saveTask(task);
127
120
  }
128
- else if (actionId === 'blocker.unblockTask') {
129
- await unblockTask({ store, job: targetJob, task, trigger: { source: 'blocker_job', reason: 'Blocker Job ' + blocker.id + ' resolved (' + options.resolution + ')', blockerJobId: blocker.id, resolution: options.resolution }, now });
130
- unblocked.push({ repository: relation.repository, prNumber: relation.prNumber, jobId: relation.jobId, taskId: relation.taskId });
131
- }
132
- else if (actionId === 'blocker.retryFailed') {
133
- const result = await dispatchTaskAction(store, { id: 'task.retry', jobId: targetJob.id, taskId: task.id });
134
- delete result.task.blockedBy;
135
- result.task.updatedAt = now;
136
- await store.saveTask(result.task);
137
- }
138
- else if (actionId === 'blocker.retryUnblocked') {
139
- await applyTaskUnblockRetryAction(task);
140
- task.updatedAt = now;
141
- await store.saveTask(task);
142
- }
143
121
  else
144
122
  throw new Error('Unsupported blocker graph action ' + actionId);
145
123
  return {};
@@ -160,6 +138,7 @@ export async function resolveBlockerDependencies(options) {
160
138
  relation.resolvedAt = now;
161
139
  relation.resolution = options.resolution;
162
140
  relation.note = note;
141
+ unblocked.push({ repository: relation.repository, prNumber: relation.prNumber, jobId: relation.jobId, taskId: relation.taskId });
163
142
  resumed += 1;
164
143
  }
165
144
  const remaining = relations.filter(relation => relation.state === 'waiting');
@@ -32,7 +32,7 @@ export async function quarantineUnavailableCodexFallback(transitions, emit, jobI
32
32
  mutate: (_job, task) => { task.error = `Codex fallback route became unavailable: ${error instanceof Error ? error.message : String(error)}`; },
33
33
  });
34
34
  if (transition?.decision.accepted)
35
- await emit(eventFor(jobId, taskId, transition.task.parentTaskId, 'task.blocked', 'blocked', transition.task.error ?? 'Codex fallback route unavailable', 'user', { reason: transition.task.failureReason, phase: 'fallback-routing' }));
35
+ await emit(eventFor(jobId, taskId, transition.task.parentTaskId, 'task.failed', 'failed', transition.task.error ?? 'Codex fallback route unavailable', 'user', { reason: transition.task.failureReason, phase: 'fallback-routing' }));
36
36
  }
37
37
  export function persistChatGptPlatformFailure(task, original, executionId, classification, evidence, safeToReroute) {
38
38
  const route = original?.routing;
package/dist/cli-help.js CHANGED
@@ -93,7 +93,7 @@ Example:
93
93
  'job merge': `Request an idempotent external pull-request merge; return QUEUED until confirmed, when the Graph Engine completes the Job.
94
94
 
95
95
  Required: --repo OWNER/REPO, --pr NUMBER, and --id JOB_ID. The current PR head SHA is pinned into the merge request.`,
96
- 'job unblock': `Authoritatively unblock the effective BLOCKED work Tasks in one Job.\n\nRequired: --repo OWNER/REPO --pr NUMBER --id JOB_ID --reason TEXT. Autonomous Jobs move blocked work to planner-waiting DRAFT with durable unblock facts; fixed Jobs resume directly as QUEUED with a reset retry budget.`,
96
+ 'job unblock': `Authoritatively unblock BLOCKED Troubleshooter Tasks in one Job.\n\nRequired: --repo OWNER/REPO --pr NUMBER --id JOB_ID --reason TEXT. Each unblocked Troubleshooter is queued as the same Task and must rerun, verify the condition, and issue a fresh resolution before retrying an original failed Task.`,
97
97
  'job cancel': `Cancel the durable Job and close its backing pull request.
98
98
 
99
99
  Required: --repo OWNER/REPO, --pr NUMBER, and --id JOB_ID. Nonterminal Tasks are cancelled before the PR is closed.`,
@@ -1,5 +1,5 @@
1
1
  import { arg, publishTaskUnblocked, publishWake, saveLocal, withEventBus } from './cli-support.js';
2
- import { isEffectivelyBlocked, unblockAutonomousTasks, unblockTask } from './unblock.js';
2
+ import { isEffectivelyBlocked, unblockTask } from './unblock.js';
3
3
  import { resumeTroubleshooter } from './task-recovery.js';
4
4
  export async function runTaskUnblockCommand(args, store, job, task, localFile) {
5
5
  const reason = arg(args, '--reason');
@@ -29,24 +29,13 @@ export async function runJobUnblockCommand(args, store, job) {
29
29
  const blocked = job.tasks.filter(isEffectivelyBlocked);
30
30
  if (blocked.length === 0)
31
31
  throw new Error(`Job ${job.id} has no effective BLOCKED work task`);
32
- if (job.executionMode === 'autonomous') {
33
- const planTrigger = await unblockAutonomousTasks({ store, job, tasks: blocked, trigger: { source: 'cli', reason } });
34
- const refreshed = await store.load(job.id);
35
- await withEventBus(args, async (bus) => {
36
- for (const task of blocked)
37
- await publishTaskUnblocked(bus, refreshed, task.id, reason);
38
- await publishWake(bus, refreshed, 'job', `Job ${job.id} unblocked for replanning`);
39
- });
40
- console.log(JSON.stringify({ jobId: job.id, replanning: true, planTrigger }, null, 2));
41
- return;
42
- }
43
32
  for (const task of blocked)
44
33
  await unblockTask({ store, job, task, trigger: { source: 'cli', reason } });
45
34
  const refreshed = await store.load(job.id);
46
35
  await withEventBus(args, async (bus) => {
47
36
  for (const task of blocked)
48
37
  await publishTaskUnblocked(bus, refreshed, task.id, reason);
49
- await publishWake(bus, refreshed, 'job', `Job ${job.id} unblocked`);
38
+ await publishWake(bus, refreshed, 'job', `Troubleshooter work in Job ${job.id} resumed`);
50
39
  });
51
- console.log(JSON.stringify({ jobId: job.id, replanning: false, resumedTasks: blocked.map(task => task.id) }, null, 2));
40
+ console.log(JSON.stringify({ jobId: job.id, resumedTasks: blocked.map(task => task.id) }, null, 2));
52
41
  }
@@ -1,5 +1,5 @@
1
- import { applyJobGraphTarget, applyTaskGraphTarget } from './workflow/runtime.js';
2
- import { jobLifecycleV2, taskLifecycleV2 } from './workflow/definitions-v2.js';
1
+ import { applyJobGraphTarget } from './workflow/runtime.js';
2
+ import { jobLifecycleV2 } from './workflow/definitions-v2.js';
3
3
  import { dispatchWorkflowTargetOverride } from './workflow/dispatcher.js';
4
4
  import { dispatchTaskAction } from './task-actions.js';
5
5
  import { applyJobCancelAction } from './job-actions.js';
@@ -45,22 +45,12 @@ export function dashboardTaskSetState(store) {
45
45
  throw new Error(`Task ${taskId} not found`);
46
46
  if (task.state === state)
47
47
  return job;
48
- if (task.state === 'BLOCKED' && state !== 'CANCELLED')
49
- throw new Error(`Task ${task.id} is BLOCKED; only authoritative unblock may resume it`);
50
- const now = new Date().toISOString();
51
- const from = task.state;
52
- await dispatchWorkflowTargetOverride(taskLifecycleV2, {
53
- currentState: from, targetState: state, kind: 'task',
54
- persistTransition: (_source, target) => { applyTaskGraphTarget(task, target, now); },
55
- });
56
- task.manualStateOverrides = [
57
- ...(task.manualStateOverrides ?? []),
58
- { from, to: state, at: now, actor: 'dashboard-user' },
59
- ].slice(-20);
60
- task.leaseOwner = null;
61
- task.leaseExpiresAt = null;
62
- task.executionId = null;
63
- await store.saveTask(task);
64
- return await store.load(job.id);
48
+ if (state !== 'CANCELLED')
49
+ throw new Error('Task lifecycle state is managed by Relay; use retry, cancel, or the Troubleshooter response flow');
50
+ if (task.state !== 'CANCELLED') {
51
+ await dispatchTaskAction(store, { id: 'task.cancel', jobId, taskId });
52
+ return await store.load(job.id);
53
+ }
54
+ return job;
65
55
  };
66
56
  }
@@ -0,0 +1,45 @@
1
+ import { readFile, readdir } from 'node:fs/promises';
2
+ import { fileURLToPath } from 'node:url';
3
+ import { join } from 'node:path';
4
+ import { eventsBusHistory } from './events-bus-runtime.js';
5
+ const repositoryPattern = /^([A-Za-z0-9](?:[A-Za-z0-9-]{0,36}[A-Za-z0-9])?)\/([A-Za-z0-9._-]{1,100})$/;
6
+ const workflowDirectory = fileURLToPath(new URL('../workflow/', import.meta.url));
7
+ export async function dashboardWorkflowGraphs() {
8
+ const names = (await readdir(workflowDirectory)).filter(name => name.endsWith('.mmd')).sort();
9
+ return Promise.all(names.map(async (name) => {
10
+ const source = await readFile(join(workflowDirectory, name), 'utf8');
11
+ const metadata = /^---\r?\n([\s\S]*?)\r?\n---(?:\r?\n|$)/.exec(source)?.[1] ?? '';
12
+ const field = (key) => metadata.match(new RegExp(`^${key}:\\s*(.+)$`, 'm'))?.[1]?.trim() ?? '';
13
+ return {
14
+ id: name.replace(/\.mmd$/, ''),
15
+ name: field('title') || (name === 'main.mmd' ? 'Main / Happy Path' : name.replace(/\.mmd$/, '').split('-').map(part => part.charAt(0).toUpperCase() + part.slice(1)).join(' ')),
16
+ description: field('description'), path: `workflow/${name}`, source,
17
+ };
18
+ }));
19
+ }
20
+ export function githubPrUrl(repository, prNumber) {
21
+ if (typeof repository !== 'string' || typeof prNumber !== 'number' || !Number.isInteger(prNumber) || prNumber <= 0)
22
+ return null;
23
+ const match = repositoryPattern.exec(repository);
24
+ if (!match)
25
+ return null;
26
+ const [, owner, repo] = match;
27
+ if ([owner, repo].some(segment => segment === '.' || segment === '..'))
28
+ return null;
29
+ return `https://github.com/${owner}/${repo}/pull/${prNumber}`;
30
+ }
31
+ export function effectiveGitHubUsage(snapshot, runtime) {
32
+ const candidates = [snapshot?.resources.core, snapshot?.resources.graphql, snapshot?.resources.search, runtime?.resources.core, runtime?.resources.graphql, runtime?.resources.search]
33
+ .filter((candidate) => candidate !== undefined && candidate !== null)
34
+ .filter(candidate => Number.isFinite(candidate.limit) && Number(candidate.limit) > 0 && Number.isFinite(candidate.remaining) && Number.isFinite(candidate.reset));
35
+ const lowest = candidates.reduce((current, candidate) => !current || Number(candidate.remaining) / Number(candidate.limit) < Number(current.remaining) / Number(current.limit) ? candidate : current, null);
36
+ return lowest ? { limit: Number(lowest.limit), remaining: Number(lowest.remaining), reset: Number(lowest.reset) } : null;
37
+ }
38
+ export async function eventHistory(jobId, taskId, limit) {
39
+ try {
40
+ return await eventsBusHistory(jobId, taskId, limit);
41
+ }
42
+ catch {
43
+ return [];
44
+ }
45
+ }
package/dist/dashboard.js CHANGED
@@ -2,67 +2,17 @@ import { inspectWorkflowSymbol } from './dashboard-workflow-inspection.js';
2
2
  import { createServer } from 'node:http';
3
3
  import { subscribeLocalCommits } from './local-commit-events.js';
4
4
  import { handleTroubleshooterQa } from './troubleshooter-qa-api.js';
5
- import { readFile, readdir } from 'node:fs/promises';
6
- import { join } from 'node:path';
7
- import { fileURLToPath } from 'node:url';
8
5
  import { readUsageSnapshot } from './usage.js';
9
6
  import { deriveJobRuntimeStatus } from './runtime-status.js';
10
7
  import { DeliveryDeduper, parseGitHubWebhook, verifyGitHubWebhook } from './github-webhook.js';
11
8
  import { loadExecutionRouterRules, saveExecutionRouterRules } from './routing-policy.js';
12
9
  import { summarizeRuntime } from './runtime-summary.js';
13
- import { eventsBusHistory } from './events-bus-runtime.js';
14
- const GITHUB_REPOSITORY_PATTERN = /^([A-Za-z0-9](?:[A-Za-z0-9-]{0,36}[A-Za-z0-9])?)\/([A-Za-z0-9._-]{1,100})$/;
10
+ import { dashboardWorkflowGraphs, effectiveGitHubUsage, eventHistory } from './dashboard-data.js';
11
+ export { dashboardWorkflowGraphs, githubPrUrl } from './dashboard-data.js';
15
12
  const SSE_HEARTBEAT_MS = 15000;
16
- const WORKFLOW_DIR = fileURLToPath(new URL('../workflow/', import.meta.url));
17
- export async function dashboardWorkflowGraphs() {
18
- const names = (await readdir(WORKFLOW_DIR)).filter(name => name.endsWith('.mmd')).sort();
19
- return Promise.all(names.map(async (name) => {
20
- const source = await readFile(join(WORKFLOW_DIR, name), 'utf8');
21
- const metadata = /^---\r?\n([\s\S]*?)\r?\n---(?:\r?\n|$)/.exec(source)?.[1] ?? '';
22
- const field = (key) => metadata.match(new RegExp(`^${key}:\\s*(.+)$`, 'm'))?.[1]?.trim() ?? '';
23
- return {
24
- id: name.replace(/\.mmd$/, ''),
25
- name: field('title') || (name === 'main.mmd' ? 'Main / Happy Path' : name.replace(/\.mmd$/, '').split('-').map(part => part.charAt(0).toUpperCase() + part.slice(1)).join(' ')),
26
- description: field('description'),
27
- path: `workflow/${name}`, source,
28
- };
29
- }));
30
- }
31
- function effectiveGitHubUsage(snapshot, runtime) {
32
- const candidates = [snapshot?.resources.core, snapshot?.resources.graphql, snapshot?.resources.search, runtime?.resources.core, runtime?.resources.graphql, runtime?.resources.search]
33
- .filter((candidate) => candidate !== undefined && candidate !== null)
34
- .filter(candidate => Number.isFinite(candidate.limit) && Number(candidate.limit) > 0 && Number.isFinite(candidate.remaining) && Number.isFinite(candidate.reset));
35
- const lowest = candidates.reduce((current, candidate) => !current || Number(candidate.remaining) / Number(candidate.limit) < Number(current.remaining) / Number(current.limit) ? candidate : current, null);
36
- return lowest ? { limit: Number(lowest.limit), remaining: Number(lowest.remaining), reset: Number(lowest.reset) } : null;
37
- }
38
- function isPositiveInteger(candidate) {
39
- return typeof candidate === 'number' && Number.isInteger(candidate) && candidate > 0;
40
- }
41
13
  function isTaskId(candidate) {
42
14
  return candidate.length > 0;
43
15
  }
44
- function isSafeRepositorySegment(segment) {
45
- return segment !== '.' && segment !== '..';
46
- }
47
- export function githubPrUrl(repository, prNumber) {
48
- if (typeof repository !== 'string' || !isPositiveInteger(prNumber))
49
- return null;
50
- const match = GITHUB_REPOSITORY_PATTERN.exec(repository);
51
- if (!match)
52
- return null;
53
- const [, owner, repo] = match;
54
- if (!isSafeRepositorySegment(owner) || !isSafeRepositorySegment(repo))
55
- return null;
56
- return `https://github.com/${owner}/${repo}/pull/${prNumber}`;
57
- }
58
- async function eventHistory(jobId, taskId, limit) {
59
- try {
60
- return await eventsBusHistory(jobId, taskId, limit);
61
- }
62
- catch {
63
- return [];
64
- }
65
- }
66
16
  export { dashboardHtml, dashboardVersion, dashboardSourceRevision } from './dashboard-html.js';
67
17
  import { dashboardHtml, dashboardVersion } from './dashboard-html.js';
68
18
  export function serveDashboard(store, port = 8787, bus, refreshMs = 300000, jobId, usageRegistry, overviewProvider, webhook, statusProvider, githubUsageProvider, exactJobProvider, actions, routerTestProvider, decisionAuditProvider) {
@@ -51,8 +51,8 @@ export function recordHostInvocationDecision(db, delivery, fingerprint, writeSna
51
51
  const job = snapshot?.json ? JSON.parse(snapshot.json) : null;
52
52
  const task = job?.tasks.find(item => item.id === delivery.taskId);
53
53
  const invocation = task?.interactiveInvocation;
54
- if (!job || !task || !invocation || !(['RUNNING', 'BLOCKED'].includes(task.state)) || invocation.executionId !== delivery.executionId ||
55
- (task.executionId !== delivery.executionId && task.state !== 'BLOCKED') ||
54
+ if (!job || !task || !invocation || !(['RUNNING', 'DRAFT'].includes(task.state)) || invocation.executionId !== delivery.executionId ||
55
+ (task.executionId !== delivery.executionId && task.state !== 'DRAFT') ||
56
56
  invocation.status !== 'awaiting_decision' || invocation.invocationId !== delivery.invocationId || invocation.revision !== delivery.revision) {
57
57
  recordHostInvocationEvent(db, delivery, 'stale', fingerprint);
58
58
  db.exec('COMMIT');
@@ -167,7 +167,7 @@ export class HostInteractiveInvocationCoordinator {
167
167
  const job = await this.options.store.loadJob(delivery.jobId);
168
168
  const task = job?.tasks.find((item) => item.id === delivery.taskId);
169
169
  if (!job || !task || task.interactiveInvocation?.executionId !== delivery.executionId ||
170
- (task.executionId !== delivery.executionId && task.state !== 'BLOCKED')) {
170
+ (task.executionId !== delivery.executionId && task.state !== 'DRAFT')) {
171
171
  await this.options.store.recordHostInvocationEvent?.(delivery, 'stale', interactiveDecisionFingerprint(delivery));
172
172
  return { outcome: 'stale', reason: 'execution_not_bound' };
173
173
  }
@@ -8,13 +8,13 @@ export async function resumeInteractiveTask(store, jobId, taskId, invocationId,
8
8
  await store.mutateJob(jobId, job => {
9
9
  const task = job.tasks.find(item => item.id === taskId);
10
10
  const invocation = task?.interactiveInvocation;
11
- if (!task || task.state !== 'BLOCKED' || !invocation ||
11
+ if (!task || task.state !== 'DRAFT' || !invocation ||
12
12
  invocation.invocationId !== invocationId || invocation.revision !== revision ||
13
13
  invocation.status !== decision || !invocation.decidedAt)
14
14
  return job;
15
- // Both state transitions and retry eligibility are owned by task-lifecycle.mmd.
16
- if (!tryTransitionTaskEvent(task, 'task.unblocked', { autonomousJob: false }).accepted) {
17
- throw new Error('Interactive Task unblock rejected by canonical lifecycle Graph');
15
+ // Accepted interactive decisions release the same waiting Task from DRAFT.
16
+ if (!tryTransitionTaskEvent(task, 'release').accepted) {
17
+ throw new Error('Interactive Task release rejected by canonical lifecycle Graph');
18
18
  }
19
19
  applyTaskUnblockRetrySnapshot(task);
20
20
  task.error = null;
@@ -30,7 +30,7 @@ export async function recoverInteractiveDecisions(store, jobId) {
30
30
  const job = await store.load(jobId);
31
31
  let recovered = false;
32
32
  for (const task of job.tasks) {
33
- if (task.state !== 'BLOCKED' || !task.interactiveInvocation?.decidedAt)
33
+ if (task.state !== 'DRAFT' || !task.interactiveInvocation?.decidedAt)
34
34
  continue;
35
35
  const invocation = task.interactiveInvocation;
36
36
  if (invocation.status === 'awaiting_decision' || !await shouldRecoverInteractiveDecision(invocation.status))
@@ -0,0 +1,14 @@
1
+ import { normalizeLegacyTaskLifecycle } from './types.js';
2
+ /** Move pre-contract ordinary BLOCKED snapshots to FAILED without touching the
3
+ * independent append-only event history. Unknown execution evidence is kept. */
4
+ export function migrateLegacyBlockedTasks(db) {
5
+ const rows = db.prepare('SELECT job_id,json FROM job_snapshot').all();
6
+ const update = db.prepare('UPDATE job_snapshot SET json=? WHERE job_id=?');
7
+ for (const row of rows) {
8
+ const job = JSON.parse(row.json);
9
+ const before = JSON.stringify(job.tasks);
10
+ job.tasks = job.tasks.map(task => normalizeLegacyTaskLifecycle(task, job.executionMode));
11
+ if (JSON.stringify(job.tasks) !== before)
12
+ update.run(JSON.stringify(job), row.job_id);
13
+ }
14
+ }
@@ -6,7 +6,7 @@ export async function repairLegacyOrchestrators(jobId, now, ports) {
6
6
  const { store } = ports;
7
7
  const repairOne = async (taskId) => {
8
8
  if (store.mutateJob) {
9
- let outcome = { repaired: false, externallyCompleted: false, blocked: false, ageMs: 0 };
9
+ let outcome = { repaired: false, externallyCompleted: false, failed: false, ageMs: 0 };
10
10
  return await store.mutateJob(jobId, current => {
11
11
  const task = current.tasks.find(item => item.id === taskId);
12
12
  if (!task)
@@ -40,12 +40,12 @@ export async function repairLegacyOrchestrators(jobId, now, ports) {
40
40
  const job = await store.load(jobId);
41
41
  const task = job.tasks.find(item => item.id === stale.id);
42
42
  const type = repair.externallyCompleted ? 'task.external_completion_applied'
43
- : repair.blocked ? 'task.orchestrator_quarantined' : 'task.orchestrator_rescued';
44
- const status = repair.externallyCompleted ? 'succeeded' : repair.blocked ? 'blocked' : 'queued';
43
+ : repair.failed ? 'task.orchestrator_quarantined' : 'task.orchestrator_rescued';
44
+ const status = repair.externallyCompleted ? 'succeeded' : repair.failed ? 'failed' : 'queued';
45
45
  await ports.emit(eventFor(job.id, task.id, task.parentTaskId, type, status, repair.externallyCompleted
46
46
  ? `Applied durable external completion for legacy orchestrator task ${task.id}`
47
- : repair.blocked
48
- ? `Quarantined legacy RUNNING orchestrator task ${task.id}; external outcome must be verified before replacement work`
49
- : `Recovered legacy orchestrator task ${task.id} through Execution Router`, 'orchestrator', { ageMs: repair.ageMs, adapter: repair.externallyCompleted || repair.blocked ? undefined : 'execution-router' }, { agent: 'relay', component: 'agents-relay' }));
47
+ : repair.failed
48
+ ? `Failed legacy RUNNING orchestrator task ${task.id}; worker outcome is unknown and Troubleshooter diagnosis is required`
49
+ : `Recovered legacy orchestrator task ${task.id} through Execution Router`, 'orchestrator', { ageMs: repair.ageMs, adapter: repair.externallyCompleted || repair.failed ? undefined : 'execution-router' }, { agent: 'relay', component: 'agents-relay' }));
50
50
  }
51
51
  }
package/dist/planner.js CHANGED
@@ -23,7 +23,7 @@ export function semanticTaskTree(tasks) {
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', 'UNBLOCKED'].includes(task.state) && task.unblock && !consumed.has(task.id));
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,
@@ -1,4 +1,4 @@
1
- export const SCHEMA_VERSION = 5;
1
+ export const SCHEMA_VERSION = 6;
2
2
  const UNSAFE_QUEUED_KEY_SUFFIX = /:(?:queued|lost):\d+$/;
3
3
  /** Logical operation key recovered from a possibly queued/lost idempotency key. */
4
4
  export function logicalOperationKey(idempotencyKey) {
@@ -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 { codexFallbackCompatible, persistChatGptPlatformFailure } from './chatgpt-platform-fallback.js';
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 = decision.target === 'BLOCKED'
283
- ? (task.platformFailure?.safeToReroute && currentTask?.state === 'QUEUED' ? 'worker_platform_fallback_unavailable' : 'worker_outcome_unknown')
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 transition = await withExecutionBoundary(event.job_id, event.task_id, () => this.taskTransitions.apply(event.job_id, event.task_id, event.type, {
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') && task.interactiveInvocation?.status !== 'awaiting_decision')
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;
@@ -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
  }
@@ -124,11 +124,8 @@ export function createWorkspaceControl(deps) {
124
124
  const blocked = job.tasks.filter(isEffectivelyBlocked);
125
125
  if (!blocked.length)
126
126
  throw new Error(`Job ${job.id} has no effective BLOCKED work task`);
127
- if (job.executionMode === 'autonomous')
128
- await unblockAutonomousTasks({ store, job, tasks: blocked, trigger: { source: 'cli', reason } });
129
- else
130
- for (const task of blocked)
131
- await unblockTask({ store, job, task, trigger: { source: 'cli', reason } });
127
+ for (const task of blocked)
128
+ await unblockTask({ store, job, task, trigger: { source: 'cli', reason } });
132
129
  const refreshed = await store.load(jobId);
133
130
  for (const task of blocked)
134
131
  await publishTaskUnblocked(bus, refreshed, task.id, reason);
@@ -186,8 +186,8 @@ export async function dispatchTaskAction(store, request) {
186
186
  const task = current.tasks.find(item => item.id === request.taskId);
187
187
  if (!task)
188
188
  throw new Error(`Task ${request.taskId} not found`);
189
+ const recovery = request.id === TASK_RECOVER_ACTION && request.recoveryTaskId ? current.tasks.find(item => item.id === request.recoveryTaskId) : undefined;
189
190
  if (request.id === TASK_RECOVER_ACTION) {
190
- const recovery = request.recoveryTaskId ? current.tasks.find(item => item.id === request.recoveryTaskId) : undefined;
191
191
  if (!recovery || recovery.agentName !== 'troubleshooter' || recovery.parentTaskId !== task.id || !['QUEUED', 'RUNNING'].includes(recovery.state))
192
192
  throw new Error(`task recover requires an active Troubleshooter recovery for ${task.id}`);
193
193
  }
@@ -198,7 +198,11 @@ export async function dispatchTaskAction(store, request) {
198
198
  task.workerSelection = undefined;
199
199
  }
200
200
  else if (request.id === TASK_RECOVER_ACTION) {
201
- const proposal = { path: 'exact_original_retry', targetTaskId: task.id, rootCause: task.failureReason ?? task.error ?? 'durable failure', solution: `Recover exact original Task via ${request.recoveryTaskId}` };
201
+ if (!recovery)
202
+ throw new Error(`task recover requires an active Troubleshooter recovery for ${task.id}`);
203
+ if (!recovery.executionId)
204
+ throw new Error(`Troubleshooter ${recovery.id} has no current execution for a fresh resolution`);
205
+ 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
206
  const decision = dispatchTaskResolutionSync(task, proposal, 'troubleshooter', { activateRecovery: () => { applyTaskRetrySnapshot(task, { extendBudget: true }); recordRecoveryRouting(task, request); task.resolution = { ...materializeResolution(task, proposal, 'troubleshooter'), state: 'resolved' }; } });
203
207
  if (!decision.accepted)
204
208
  throw new Error(`Task resolution graph rejected exact-original recovery for ${task.id}`);
@@ -232,7 +236,9 @@ export async function dispatchTaskAction(store, request) {
232
236
  const recovery = request.recoveryTaskId ? job.tasks.find(item => item.id === request.recoveryTaskId) : undefined;
233
237
  if (!recovery || recovery.agentName !== 'troubleshooter' || recovery.parentTaskId !== task.id || !['QUEUED', 'RUNNING'].includes(recovery.state))
234
238
  throw new Error(`task recover requires an active Troubleshooter recovery for ${task.id}`);
235
- const proposal = { path: 'exact_original_retry', targetTaskId: task.id, rootCause: task.failureReason ?? task.error ?? 'durable failure', solution: `Recover exact original Task via ${request.recoveryTaskId}` };
239
+ if (!recovery.executionId)
240
+ throw new Error(`Troubleshooter ${recovery.id} has no current execution for a fresh resolution`);
241
+ 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
242
  const decision = dispatchTaskResolutionSync(task, proposal, 'troubleshooter', { activateRecovery: () => { applyTaskRetrySnapshot(task, { extendBudget: true }); recordRecoveryRouting(task, request); task.resolution = { ...materializeResolution(task, proposal, 'troubleshooter'), state: 'resolved' }; } });
237
243
  if (!decision.accepted)
238
244
  throw new Error(`Task resolution graph rejected exact-original recovery for ${task.id}`);
@@ -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
  });