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.
Files changed (50) hide show
  1. package/dist/blockers.js +6 -27
  2. package/dist/chatgpt-platform-fallback.js +1 -1
  3. package/dist/cli-help.js +5 -6
  4. package/dist/cli-plan-clean-history.js +16 -0
  5. package/dist/cli-unblock.js +3 -14
  6. package/dist/cli-worker-task.js +11 -0
  7. package/dist/cli.js +27 -16
  8. package/dist/dashboard-actions.js +9 -19
  9. package/dist/dashboard-data.js +45 -0
  10. package/dist/dashboard-html.js +1 -1
  11. package/dist/dashboard.js +2 -52
  12. package/dist/host-invocation-store.js +2 -2
  13. package/dist/interactive-invocation.js +1 -1
  14. package/dist/interactive-resume.js +5 -5
  15. package/dist/legacy-blocked-task-migration.js +14 -0
  16. package/dist/legacy-orchestrator-repair.js +6 -6
  17. package/dist/manual-plan.js +19 -1
  18. package/dist/planner-recovery.js +1 -1
  19. package/dist/planner-runtime.js +9 -1
  20. package/dist/planner.js +18 -23
  21. package/dist/projection-outbox.js +1 -1
  22. package/dist/reconciler-review.js +4 -3
  23. package/dist/reconciler-runtime.js +15 -19
  24. package/dist/reconciler.js +3 -3
  25. package/dist/relay-store.js +2 -0
  26. package/dist/relayd-control.js +14 -7
  27. package/dist/single-job-task-control.js +1 -1
  28. package/dist/task-actions.js +28 -3
  29. package/dist/task-recovery.js +1 -1
  30. package/dist/troubleshooter.js +1 -1
  31. package/dist/types.js +12 -6
  32. package/dist/unblock.js +5 -44
  33. package/dist/workflow/authoritative-unblock-v2.js +6 -7
  34. package/dist/workflow/contracts.js +3 -0
  35. package/dist/workflow/definitions-v2.js +3 -2
  36. package/dist/workflow/lifecycle.js +5 -5
  37. package/dist/workflow/orchestrator-rescue.js +13 -12
  38. package/dist/workflow/runtime.js +13 -2
  39. package/dist/workflow/task-resolution.js +52 -15
  40. package/dist/workflow/troubleshooter-v2.js +5 -3
  41. package/package.json +3 -3
  42. package/skills/agents-relay/agents/planner.agent.md +2 -2
  43. package/skills/agents-relay/agents/troubleshooter.agent.md +5 -3
  44. package/skills/agents-relay/contracts/task-job-contract.md +18 -6
  45. package/workflow/authoritative-unblock.mmd +6 -9
  46. package/workflow/blocker-resolution.mmd +6 -16
  47. package/workflow/orchestrator-rescue.mmd +3 -3
  48. package/workflow/task-lifecycle.mmd +45 -59
  49. package/workflow/task-resolution.mmd +24 -3
  50. package/workflow/troubleshooter.mmd +20 -1
@@ -12,6 +12,7 @@ export const WORKFLOW_EVENT_NAMES = [
12
12
  'hold',
13
13
  'lease.expired',
14
14
  'implementation.completed',
15
+ 'interactive.waiting',
15
16
  'local.completed',
16
17
  'local.merge.requested',
17
18
  'effect.deliverLocalMerge.failed',
@@ -38,6 +39,7 @@ export const WORKFLOW_EVENT_NAMES = [
38
39
  'task.progress',
39
40
  'task.started',
40
41
  'task.unblocked',
42
+ 'interactive.waiting',
41
43
  'watchdog.failed',
42
44
  'worker.delivery_failed',
43
45
  'worker.start_ack_failed',
@@ -70,6 +72,7 @@ export const WORKFLOW_MUTATION_NAMES = [
70
72
  'mutation.clearExecution',
71
73
  'mutation.markLeaseExpired',
72
74
  'mutation.markStartupOrphanedExhausted',
75
+ 'mutation.markUnknownOutcome',
73
76
  'mutation.markPlannerUnavailable',
74
77
  'mutation.preparePlatformFallback',
75
78
  'mutation.quarantinePlatformFailure',
@@ -5,7 +5,7 @@ import { WORKFLOW_EFFECT_NAMES, WORKFLOW_EVENT_NAMES, WORKFLOW_MUTATION_NAMES, }
5
5
  const TASK_STATES = ['DRAFT', 'QUEUED', 'RUNNING', 'BLOCKED', 'SUCCEEDED', 'FAILED', 'CANCELLED'];
6
6
  const JOB_STATES = ['DRAFT', 'ACTIVE', 'COMPLETED', 'CANCELLED'];
7
7
  const conditionRegistry = new ConditionRegistry([
8
- ...new Set(['dependenciesSatisfied', 'dependenciesUnsatisfied', 'runnable', 'dispatchable', 'recoverableExecution', 'troubleshooterRecovery', 'retryAvailable', 'autonomousJob', 'platformFallbackSafe', 'notMerged', 'autoMerge', 'prOpen', 'reviewDisabled', 'implementationComplete', 'headAvailable', 'mergeCapable', 'localCompletionAllowed', 'localMergeReady'])
8
+ ...new Set(['dependenciesSatisfied', 'dependenciesUnsatisfied', 'runnable', 'dispatchable', 'recoverableExecution', 'troubleshooterRecovery', 'troubleshooterTask', 'awaitingInteractiveDecision', 'retryAvailable', 'autonomousJob', 'notMerged', 'autoMerge', 'prOpen', 'reviewDisabled', 'implementationComplete', 'headAvailable', 'mergeCapable', 'localCompletionAllowed', 'localMergeReady'])
9
9
  ].map(booleanCondition));
10
10
  const boolGuard = (name) => conditionRegistry.guard(name);
11
11
  export const lifecycleConditions = conditionRegistry;
@@ -31,9 +31,10 @@ export const taskLifecycleV2 = load('task-lifecycle', TASK_STATES, {
31
31
  dispatchable: boolGuard('dispatchable'),
32
32
  recoverableExecution: boolGuard('recoverableExecution'),
33
33
  troubleshooterRecovery: boolGuard('troubleshooterRecovery'),
34
+ troubleshooterTask: boolGuard('troubleshooterTask'),
35
+ awaitingInteractiveDecision: facts => facts.durableFacts.awaitingInteractiveDecision === true,
34
36
  retryAvailable: boolGuard('retryAvailable'),
35
37
  autonomousJob: boolGuard('autonomousJob'),
36
- platformFallbackSafe: boolGuard('platformFallbackSafe'),
37
38
  });
38
39
  export const jobLifecycleV2 = load('job-lifecycle', JOB_STATES, {
39
40
  notMerged: boolGuard('notMerged'),
@@ -26,7 +26,7 @@ function v2WorkflowDecision(runtime, from, event, facts = {}) {
26
26
  };
27
27
  }
28
28
  export function decideTaskLifecycle(task, event, facts = {}) {
29
- return v2WorkflowDecision(taskLifecycleV2, task.state, event, facts);
29
+ return v2WorkflowDecision(taskLifecycleV2, task.state, event, { ...facts, troubleshooterTask: task.agentName === 'troubleshooter' });
30
30
  }
31
31
  export function decideJobLifecycle(job, event, facts = {}) {
32
32
  return v2WorkflowDecision(jobLifecycleV2, job.state, event, facts);
@@ -36,9 +36,9 @@ export function dispatchTaskLifecycleSync(task, event, facts = {}) {
36
36
  const result = dispatchWorkflowGraphSync(taskLifecycleV2, {
37
37
  initialState: task.state,
38
38
  input: { transport: 'event', id: event, payload: {} },
39
- durableFacts: facts,
39
+ durableFacts: { ...facts, troubleshooterTask: task.agentName === 'troubleshooter' },
40
40
  graphName: 'task-lifecycle',
41
- persistTransition: (_source, target) => applyTaskGraphTarget(task, target),
41
+ persistTransition: (_source, target) => applyTaskGraphTarget(task, target, new Date().toISOString(), event === 'task.unblocked' && task.agentName === 'troubleshooter'),
42
42
  executeAction: actionId => { actions.push(actionId); if (actionId.startsWith('mutation.'))
43
43
  applyTaskMutation(task, actionId); return {}; },
44
44
  });
@@ -49,9 +49,9 @@ export function dispatchTaskLifecycleSync(task, event, facts = {}) {
49
49
  export async function dispatchTaskLifecycle(task, event, facts = {}, executeEffect, persist) {
50
50
  const actions = [];
51
51
  const result = await dispatchWorkflowGraph(taskLifecycleV2, {
52
- initialState: task.state, input: { transport: 'event', id: event, payload: {} }, durableFacts: facts,
52
+ initialState: task.state, input: { transport: 'event', id: event, payload: {} }, durableFacts: { ...facts, troubleshooterTask: task.agentName === 'troubleshooter' },
53
53
  graphName: 'task-lifecycle',
54
- persistTransition: async (_source, target) => { applyTaskGraphTarget(task, target); await persist?.(); },
54
+ persistTransition: async (_source, target) => { applyTaskGraphTarget(task, target, new Date().toISOString(), event === 'task.unblocked' && task.agentName === 'troubleshooter'); await persist?.(); },
55
55
  executeAction: async (actionId) => {
56
56
  actions.push(actionId);
57
57
  if (actionId.startsWith('mutation.'))
@@ -7,7 +7,7 @@ export const STALE_ORCHESTRATOR_TASK_MS = 30 * 60 * 1000;
7
7
  const source = readFileSync(new URL('../../workflow/orchestrator-rescue.mmd', import.meta.url), 'utf8');
8
8
  const boolGuard = (name) => (facts) => facts.durableFacts[name] === true;
9
9
  export const orchestratorRescueV2 = new GraphRuntime(source, {
10
- states: new Set(['idle', 'check', 'external_completed', 'blocked', 'skip', 'rescued']),
10
+ states: new Set(['idle', 'check', 'external_completed', 'failed', 'skip', 'rescued']),
11
11
  guards: {
12
12
  legacyActive: boolGuard('legacyActive'),
13
13
  verifiedExternalCompletion: boolGuard('verifiedExternalCompletion'),
@@ -16,7 +16,7 @@ export const orchestratorRescueV2 = new GraphRuntime(source, {
16
16
  },
17
17
  actions: {
18
18
  'task.applyExternalCompletion': { id: 'task.applyExternalCompletion' },
19
- 'task.blockLegacyOrchestrator': { id: 'task.blockLegacyOrchestrator' },
19
+ 'task.failLegacyOrchestrator': { id: 'task.failLegacyOrchestrator' },
20
20
  'task.rescueOrchestrator': { id: 'task.rescueOrchestrator' },
21
21
  },
22
22
  events: { 'orchestrator.rescue.reconcile': { id: 'orchestrator.rescue.reconcile' } },
@@ -46,9 +46,9 @@ export function prepareOrchestratorRepair(task, now) {
46
46
  const decision = decideOrchestratorRescue(task, now);
47
47
  const externallyCompleted = decision.accepted
48
48
  && decision.actions.some(action => action.id === 'task.applyExternalCompletion');
49
- const blocked = decision.accepted
50
- && decision.actions.some(action => action.id === 'task.blockLegacyOrchestrator');
51
- const rescued = externallyCompleted || blocked
49
+ const failed = decision.accepted
50
+ && decision.actions.some(action => action.id === 'task.failLegacyOrchestrator');
51
+ const rescued = externallyCompleted || failed
52
52
  || (decision.accepted && decision.actions.some(action => action.id === 'task.rescueOrchestrator'));
53
53
  const ageMs = now - Date.parse(task.createdAt);
54
54
  if (decision.accepted && externallyCompleted) {
@@ -58,19 +58,20 @@ export function prepareOrchestratorRepair(task, now) {
58
58
  task.executionId = null;
59
59
  task.threadId = null;
60
60
  task.updatedAt = new Date(now).toISOString();
61
- return { repaired: true, externallyCompleted: true, blocked: false, ageMs };
61
+ return { repaired: true, externallyCompleted: true, failed: false, ageMs };
62
62
  }
63
63
  if (!rescued)
64
- return { repaired: false, externallyCompleted: false, blocked: false, ageMs };
65
- if (blocked) {
66
- tryTransitionTaskEvent(task, 'task.blocked');
67
- task.error = 'Legacy orchestrator execution outcome is unknown; automatic replay is unsafe. Verify external effects and create replacement validation/recovery work before unblocking.';
64
+ return { repaired: false, externallyCompleted: false, failed: false, ageMs };
65
+ if (failed) {
66
+ tryTransitionTaskEvent(task, 'task.failed');
67
+ task.error = 'Legacy orchestrator execution outcome is unknown; Troubleshooter diagnosis is required before retry.';
68
+ task.failureReason = 'worker_outcome_unknown';
68
69
  task.leaseOwner = null;
69
70
  task.leaseExpiresAt = null;
70
71
  task.executionId = null;
71
72
  task.threadId = null;
72
73
  task.updatedAt = new Date(now).toISOString();
73
- return { repaired: true, externallyCompleted: false, blocked: true, ageMs };
74
+ return { repaired: true, externallyCompleted: false, failed: true, ageMs };
74
75
  }
75
76
  task.adapter = 'codex';
76
77
  task.workerRequirements = { capabilities: [...task.capabilities] };
@@ -82,5 +83,5 @@ export function prepareOrchestratorRepair(task, now) {
82
83
  task.threadId = null;
83
84
  task.timeoutMs = taskOverallTimeoutMs(task.storySize ?? 'M');
84
85
  task.updatedAt = new Date(now).toISOString();
85
- return { repaired: true, externallyCompleted: false, blocked: false, ageMs };
86
+ return { repaired: true, externallyCompleted: false, failed: false, ageMs };
86
87
  }
@@ -32,6 +32,10 @@ export function applyTaskMutation(task, mutation) {
32
32
  task.error = 'Lease expired after maximum attempts';
33
33
  if (mutation === 'mutation.markStartupOrphanedExhausted')
34
34
  task.error = 'Orphaned after relayd restart after maximum attempts';
35
+ if (mutation === 'mutation.markUnknownOutcome') {
36
+ task.error = 'Worker execution outcome is unknown; Troubleshooter diagnosis is required before retry.';
37
+ task.failureReason = 'worker_outcome_unknown';
38
+ }
35
39
  if (mutation === 'mutation.markPlannerUnavailable')
36
40
  task.error = 'No objective planner configured';
37
41
  if (mutation === 'mutation.preparePlatformFallback') {
@@ -49,10 +53,17 @@ export function applyTaskMutation(task, mutation) {
49
53
  task.leaseOwner = null;
50
54
  task.leaseExpiresAt = null;
51
55
  task.executionId = null;
52
- task.failureReason = task.platformFailure?.safeToReroute ? 'worker_platform_fallback_unavailable' : 'worker_outcome_unknown';
56
+ task.failureReason = ['outcome_unknown', 'start_ack_timeout'].includes(task.platformFailure?.classification ?? '')
57
+ ? 'worker_outcome_unknown' : 'worker_platform_failure';
53
58
  }
54
59
  }
55
- export function applyTaskGraphTarget(task, target, timestamp = new Date().toISOString()) {
60
+ export function applyTaskGraphTarget(task, target, timestamp = new Date().toISOString(), authoritativeUnblock = false) {
61
+ if (target === 'BLOCKED' && task.agentName !== 'troubleshooter')
62
+ throw new Error(`Only a Troubleshooter Task may become BLOCKED (${task.id})`);
63
+ if (task.state === 'BLOCKED' && target === 'QUEUED' && (!authoritativeUnblock || task.agentName !== 'troubleshooter'))
64
+ throw new Error(`Blocked Task ${task.id} requires an authoritative Troubleshooter unblock before transition`);
65
+ if (task.state === 'BLOCKED' && target !== 'QUEUED' && target !== 'CANCELLED' && target !== 'BLOCKED')
66
+ throw new Error(`Blocked Task ${task.id} requires an authoritative Troubleshooter unblock before transition`);
56
67
  const previous = Date.parse(task.updatedAt), candidate = Date.parse(timestamp);
57
68
  task.state = target;
58
69
  task.updatedAt = Number.isFinite(previous)
@@ -7,23 +7,53 @@ export const TASK_RESOLUTION_PATHS = [
7
7
  const source = readFileSync(new URL('../../workflow/task-resolution.mmd', import.meta.url), 'utf8');
8
8
  const boolGuard = (name) => (facts) => facts.durableFacts[name] === true;
9
9
  export const taskResolutionV2 = new GraphRuntime(source, {
10
- states: new Set(['unresolved', 'check_owner', 'check_path', 'resolved']),
10
+ states: new Set(['unresolved', 'check_owner', 'check_path', 'check_fresh_execution', 'check_classification', 'classificationKind', 'resolved', 'rejected']),
11
11
  guards: {
12
12
  recoveryOwnerAvailable: boolGuard('recoveryOwnerAvailable'), plannerOwnerAvailable: boolGuard('plannerOwnerAvailable'),
13
13
  exactOriginalRetry: boolGuard('exactOriginalRetry'), sameJobDependency: boolGuard('sameJobDependency'),
14
14
  blockerJobDependency: boolGuard('blockerJobDependency'), escalationGate: boolGuard('escalationGate'), genuinelyObsolete: boolGuard('genuinelyObsolete'),
15
+ freshRecoveryExecution: boolGuard('freshRecoveryExecution'),
16
+ classifiableFailure: boolGuard('classifiableFailure'),
17
+ classificationSuperseded: boolGuard('classificationSuperseded'),
18
+ supersededEvidenceComplete: boolGuard('supersededEvidenceComplete'),
19
+ invalidEvidenceComplete: boolGuard('invalidEvidenceComplete'),
15
20
  },
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' } },
21
+ actions: Object.fromEntries(['resolution.activateRecovery', 'resolution.attachSameJobDependency', 'resolution.registerBlockerDependency', 'resolution.createEscalationGate', 'resolution.cancelSupersede', 'resolution.classifySuperseded', 'resolution.classifyInvalid'].map(id => [id, { id }])),
22
+ events: { 'resolution.propose': { id: 'resolution.propose' }, 'resolution.classify': { id: 'resolution.classify' } },
18
23
  });
24
+ export function classifyTaskResolution(task, classification, now = new Date().toISOString()) {
25
+ const complete = Boolean(classification.targetTaskId.trim() && classification.evidence.trim() && classification.reason.trim());
26
+ let applied;
27
+ const decision = dispatchWorkflowGraphSync(taskResolutionV2, {
28
+ initialState: 'unresolved', input: { transport: 'event', id: 'resolution.classify', payload: classification },
29
+ durableFacts: {
30
+ classifiableFailure: task.kind !== 'planner' && task.state === 'FAILED' && task.failureReason !== 'worker_outcome_unknown' && !task.executionId && !task.leaseOwner && !task.leaseExpiresAt && !task.resolutionClassification && !['active', 'waiting'].includes(task.resolution?.state ?? ''),
31
+ classificationSuperseded: classification.kind === 'superseded',
32
+ supersededEvidenceComplete: classification.kind === 'superseded' && complete,
33
+ invalidEvidenceComplete: classification.kind === 'invalid' && complete,
34
+ }, graphName: 'task-resolution', isTransientState: state => state === 'check_classification' || state === 'classificationKind', maxSteps: 6,
35
+ executeAction: actionId => {
36
+ const expectedAction = classification.kind === 'superseded' ? 'resolution.classifySuperseded' : 'resolution.classifyInvalid';
37
+ if (actionId !== expectedAction)
38
+ throw new Error(`Unexpected Task Resolution action ${actionId}`);
39
+ applied = { ...classification, updatedAt: now };
40
+ task.resolutionClassification = applied;
41
+ task.updatedAt = now;
42
+ return {};
43
+ },
44
+ });
45
+ if (!decision.accepted || decision.target !== 'resolved' || !applied)
46
+ throw new Error(`Task resolution graph rejected ${classification.kind} classification for ${task.id}`);
47
+ return applied;
48
+ }
19
49
  export function unresolvedTask(task) { return task.kind !== 'planner' && !['SUCCEEDED', 'CANCELLED'].includes(task.state); }
20
50
  export function validateResolutionProposal(job, original, proposal) {
21
51
  if (!TASK_RESOLUTION_PATHS.includes(proposal.path))
22
52
  throw new Error(`Unknown task resolution path ${proposal.path}`);
23
53
  if (proposal.path === 'exact_original_retry' && original.state !== 'FAILED')
24
54
  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');
55
+ if (proposal.path === 'same_job_dependency' && (!proposal.targetTaskId || proposal.targetTaskId === original.id || !job.tasks.some(task => task.id === proposal.targetTaskId && task.jobId === job.id && !['SUCCEEDED', 'CANCELLED'].includes(task.state))))
56
+ throw new Error('Same-Job dependency resolution requires an existing unresolved target Task in the same Job');
27
57
  if (proposal.path === 'blocker_job_dependency' && (!proposal.targetJobId || !proposal.mode))
28
58
  throw new Error('Blocker-Job dependency resolution requires targetJobId and merge or verified mode');
29
59
  if (proposal.path === 'escalation_gate' && (!proposal.rootCause || !proposal.solution))
@@ -32,10 +62,15 @@ export function validateResolutionProposal(job, original, proposal) {
32
62
  throw new Error('Cancellation/supersession requires an explicit obsolescence rootCause');
33
63
  }
34
64
  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) };
65
+ const freshRecoveryExecution = owner !== 'troubleshooter' || proposal.path !== 'exact_original_retry' || (Boolean(proposal.recoveryExecutionId)
66
+ && proposal.recoveryExecutionId !== task.resolution?.recoveryExecutionId);
67
+ return { recoveryOwnerAvailable: owner === 'troubleshooter', plannerOwnerAvailable: owner !== null && owner !== 'troubleshooter', exactOriginalRetry: proposal.path === 'exact_original_retry' && task.state === 'FAILED', sameJobDependency: proposal.path === 'same_job_dependency' && owner !== null && proposal.targetTaskId !== task.id, blockerJobDependency: proposal.path === 'blocker_job_dependency' && owner !== null && Boolean(proposal.targetJobId && proposal.mode), escalationGate: proposal.path === 'escalation_gate' && owner === 'troubleshooter' && Boolean(proposal.rootCause && proposal.solution), genuinelyObsolete: proposal.path === 'cancel_supersede' && Boolean(proposal.rootCause), freshRecoveryExecution, unresolved: unresolvedTask(task) };
36
68
  }
37
69
  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 });
70
+ return checkedResolutionDecision(dispatchWorkflowGraphSync(taskResolutionV2, { initialState: 'unresolved', input: { transport: 'event', id: 'resolution.propose', payload: proposal }, durableFacts: resolutionFacts(task, proposal, owner), graphName: 'task-resolution', isTransientState: state => ['check_owner', 'check_path', 'check_fresh_execution'].includes(state), maxSteps: 8 }));
71
+ }
72
+ function checkedResolutionDecision(decision) {
73
+ return decision.accepted && decision.target === 'rejected' ? { ...decision, accepted: false, reason: 'no_transition' } : decision;
39
74
  }
40
75
  export function dispatchTaskResolutionSync(task, proposal, owner, effects) {
41
76
  const handlers = {
@@ -45,13 +80,13 @@ export function dispatchTaskResolutionSync(task, proposal, owner, effects) {
45
80
  'resolution.createEscalationGate': effects.createEscalationGate,
46
81
  'resolution.cancelSupersede': effects.cancelSupersede,
47
82
  };
48
- return dispatchWorkflowGraphSync(taskResolutionV2, {
83
+ return checkedResolutionDecision(dispatchWorkflowGraphSync(taskResolutionV2, {
49
84
  initialState: 'unresolved', input: { transport: 'event', id: 'resolution.propose', payload: proposal },
50
85
  durableFacts: resolutionFacts(task, proposal, owner), graphName: 'task-resolution',
51
- isTransientState: state => state === 'check_owner' || state === 'check_path', maxSteps: 6,
86
+ isTransientState: state => ['check_owner', 'check_path', 'check_fresh_execution'].includes(state), maxSteps: 8,
52
87
  executeAction: actionId => { const handler = handlers[actionId]; if (!handler)
53
88
  throw new Error(`Task resolution effect ${actionId} is unavailable`); handler(); return {}; },
54
- });
89
+ }));
55
90
  }
56
91
  export async function dispatchTaskResolution(task, proposal, owner, effects) {
57
92
  const handlers = {
@@ -61,13 +96,13 @@ export async function dispatchTaskResolution(task, proposal, owner, effects) {
61
96
  'resolution.createEscalationGate': effects.createEscalationGate,
62
97
  'resolution.cancelSupersede': effects.cancelSupersede,
63
98
  };
64
- return dispatchWorkflowGraph(taskResolutionV2, {
99
+ return checkedResolutionDecision(await dispatchWorkflowGraph(taskResolutionV2, {
65
100
  initialState: 'unresolved',
66
101
  input: { transport: 'event', id: 'resolution.propose', payload: proposal },
67
102
  durableFacts: resolutionFacts(task, proposal, owner),
68
103
  graphName: 'task-resolution',
69
- isTransientState: state => state === 'check_owner' || state === 'check_path',
70
- maxSteps: 6,
104
+ isTransientState: state => ['check_owner', 'check_path', 'check_fresh_execution'].includes(state),
105
+ maxSteps: 8,
71
106
  executeAction: async (actionId) => {
72
107
  const handler = handlers[actionId];
73
108
  if (!handler)
@@ -75,7 +110,7 @@ export async function dispatchTaskResolution(task, proposal, owner, effects) {
75
110
  await handler();
76
111
  return {};
77
112
  },
78
- });
113
+ }));
79
114
  }
80
115
  export function materializeResolution(task, proposal, owner, now = new Date().toISOString()) {
81
116
  return { ...proposal, state: proposal.path === 'escalation_gate' || proposal.path === 'blocker_job_dependency' ? 'waiting' : 'active', owner, updatedAt: now };
@@ -100,5 +135,7 @@ export function attachResolutionOwnership(job, child, proposal, owner) {
100
135
  return original;
101
136
  }
102
137
  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');
138
+ if (!recovery.executionId)
139
+ throw new Error(`Troubleshooter ${recovery.id} has no current execution for a fresh resolution`);
140
+ original.resolution = materializeResolution(original, { path: 'exact_original_retry', targetTaskId: original.id, recoveryExecutionId: recovery.executionId, rootCause: original.failureReason ?? original.error ?? 'durable failure', solution: `Troubleshooter ${recovery.id} diagnosis and exact-original recovery` }, 'troubleshooter');
104
141
  }
@@ -4,7 +4,7 @@ import { dispatchWorkflowGraphSync } from './dispatcher.js';
4
4
  const source = readFileSync(new URL('../../workflow/troubleshooter.mmd', import.meta.url), 'utf8');
5
5
  const boolGuard = (name) => (facts) => facts.durableFacts[name] === true;
6
6
  export const troubleshooterV2 = new GraphRuntime(source, {
7
- states: new Set(['idle', 'check_ordinary', 'check_run', 'check_needs', 'check_id', 'check_barrier', 'skip', 'create']),
7
+ states: new Set(['idle', 'check_ordinary', 'check_run', 'check_needs', 'check_id', 'check_barrier', 'skip', 'create', 'resumed_after_authoritative_unblock', 'fresh_resolution_proposed', 'remain_failed_without_original_mutation']),
8
8
  guards: {
9
9
  failedWork: boolGuard('failedWork'),
10
10
  ordinaryWork: boolGuard('ordinaryWork'),
@@ -12,9 +12,11 @@ export const troubleshooterV2 = new GraphRuntime(source, {
12
12
  needsRecovery: boolGuard('needsRecovery'),
13
13
  idUnused: boolGuard('idUnused'),
14
14
  noRecoveryBarrier: boolGuard('noRecoveryBarrier'),
15
+ freshConditionVerification: boolGuard('freshConditionVerification'),
16
+ resolutionForCurrentExecution: boolGuard('resolutionForCurrentExecution'),
15
17
  },
16
- actions: { 'troubleshooter.create': { id: 'troubleshooter.create' } },
17
- events: { 'troubleshooter.reconcile': { id: 'troubleshooter.reconcile' } },
18
+ actions: { 'troubleshooter.create': { id: 'troubleshooter.create' }, 'troubleshooter.proposeFreshResolution': { id: 'troubleshooter.proposeFreshResolution' }, 'troubleshooter.applyAuthorizedResolution': { id: 'troubleshooter.applyAuthorizedResolution' } },
19
+ events: { 'troubleshooter.reconcile': { id: 'troubleshooter.reconcile' }, 'troubleshooter.resumed': { id: 'troubleshooter.resumed' }, 'task.started': { id: 'task.started' }, 'resolution.propose': { id: 'resolution.propose' } },
18
20
  });
19
21
  export function dispatchTroubleshooter(facts, onCreate) {
20
22
  return dispatchWorkflowGraphSync(troubleshooterV2, {
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "agents-relay",
3
- "version": "2.0.156",
3
+ "version": "2.0.158",
4
4
  "description": "Durable async agent jobs coordinated through GitHub pull requests",
5
5
  "repository": {
6
6
  "type": "git",
@@ -19,8 +19,8 @@
19
19
  "test:publish": "npm run build && node --import ./test/test-store-env.mjs --test --test-concurrency=1 --test-force-exit test/*.test.js",
20
20
  "test:fast": "npm run build && node --import ./test/test-store-env.mjs --test --test-concurrency=2 test/workflow-lifecycle.test.js test/workflow-v2-lifecycle-parity.test.js test/workflow-effects.test.js test/relay-store.test.js test/local-authority.test.js test/local-jobid-routing.test.js test/local-sqlite-jobid.test.js test/no-github.test.js test/scheduled-jobs.test.js test/reconcile-coordinator.test.js test/service-publication.test.js test/github-webhook.test.js",
21
21
  "test:full": "npm run build && node --import ./test/test-store-env.mjs --test --test-concurrency=1 test/*.test.js && npm run test:verification:l3",
22
- "test:verification:l1": "npm run build && node --test test/verification/harness.test.mjs test/verification/contract.test.mjs test/verification/graph-inventory.test.mjs test/verification/dashboard-drilldown.test.mjs test/verification/evidence-matrix.test.mjs",
23
- "test:verification:l3": "npm run build && node --test --test-concurrency=1 test/verification/harness.test.mjs test/verification/l3-sqlite.test.mjs test/verification/l3-ingress.test.mjs test/verification/l3-replay.test.mjs test/verification/objective-authority.test.mjs test/verification/l3-convergence.test.mjs test/verification/l3-review-convergence.test.mjs test/verification/l3-human-escalation.test.mjs test/verification/l3-escalation-notifier.test.mjs test/verification/l3-unknown-effect.test.mjs test/verification/l3-cli-actions.test.mjs test/verification/l3-planner-round.test.mjs test/verification/full-recovery-gates.test.mjs test/verification/production-wiring.test.mjs test/verification/l3-full-path.test.mjs test/verification/l3-projection-recovery.test.mjs",
22
+ "test:verification:l1": "npm run build && node --test test/verification/harness.test.mjs test/verification/contract.test.mjs test/verification/graph-inventory.test.mjs test/verification/dashboard-drilldown.test.mjs test/verification/evidence-matrix.test.mjs test/verification/task-resolution-classification.test.mjs",
23
+ "test:verification:l3": "npm run build && node --test --test-concurrency=1 test/verification/harness.test.mjs test/verification/l3-sqlite.test.mjs test/verification/l3-ingress.test.mjs test/verification/l3-replay.test.mjs test/verification/objective-authority.test.mjs test/verification/l3-convergence.test.mjs test/verification/l3-review-convergence.test.mjs test/verification/l3-human-escalation.test.mjs test/verification/l3-escalation-notifier.test.mjs test/verification/l3-unknown-effect.test.mjs test/verification/l3-cli-actions.test.mjs test/verification/l3-planner-round.test.mjs test/verification/l3-task-resolution.test.mjs test/verification/full-recovery-gates.test.mjs test/verification/production-wiring.test.mjs test/verification/l3-full-path.test.mjs test/verification/l3-projection-recovery.test.mjs",
24
24
  "test:verification": "npm run test:verification:l1 && npm run test:verification:l2 && npm run test:verification:l3 && npm run test:verification:cross-a && node --test test/workflow-production-outbox.test.js && npm run test:verification:matrix",
25
25
  "e2e": "node e2e/runner/run.mjs",
26
26
  "test:verification:cross-a": "npm run build && RELAY_A_DIST=$PWD/dist node --test test/verification/action-ledger-cross-branch.test.mjs test/verification/outbox-cross-a.test.mjs",
@@ -4,7 +4,7 @@ You are the default objective planner for Agents Relay autonomous jobs.
4
4
 
5
5
  Before producing tasks, read and follow `../contracts/task-job-contract.md`.
6
6
 
7
- You are a planning agent. Relay invokes you with the durable Job, existing Tasks, previous planner rounds, and resolved project context. Your sole responsibility is to decompose the objective and create a valid Plan/Task Tree. When the invocation provides a live Plan ID and execution ID, write the proposed JSON to a temporary file and submit it with `agents-relay plan apply --repo OWNER/REPO --pr PR_NUMBER --id JOB_ID --plan-id PLAN_ID --execution-id EXECUTION_ID --file PATH`. If the CLI rejects it, correct the reported error and re-submit within the SAME Plan, up to three corrections. Do not create new Jobs, run Tasks, change statuses, or perform scheduling/recovery. On successful apply, return the same JSON as your final answer for compatibility; Relay detects the committed round and does not create duplicate Tasks. If no live execution identity is provided, return JSON and let Relay materialize it as before.
7
+ You are a planning agent. Relay invokes you with the durable Job, existing Tasks, previous planner rounds, and resolved project context. Your sole responsibility is to decompose the objective and create a valid Plan/Task Tree. Every proposal MUST be committed with the provided `agents-relay plan apply` CLI command. Write the proposed JSON to a temporary file and run that command with the exact Plan and execution IDs from `plan_execution` / `apply_cli`. If the CLI rejects it, correct the reported error and resubmit within the SAME Plan, up to three corrections. Do not edit SQLite or other durable storage directly. Do not treat a JSON/text response as a plan submission and do not return the proposal as your final answer. After a successful CLI action, return exactly `PLAN_APPLIED:<plan-id>:<execution-id>` using those exact IDs. Never invent that receipt. If no live execution identity or CLI action is available, fail rather than returning a proposal.
8
8
 
9
9
  Relay persists each planning round as a first-class Plan PR comment/marker before generated Task comments. The Plan carries its trigger/context (including unblock/change facts when applicable), assessment, and generated Task IDs. Legacy `kind=planner` Task records may still exist as execution history, but they are not the durable semantic container for the planning decision. Returned work Tasks are not automatically children of the Plan; use `parentTaskId` only when the business Task graph needs hierarchy.
10
10
 
@@ -96,7 +96,7 @@ Do not create separate Tasks merely for "analyze", "format", "call tool", "check
96
96
  - If satisfied, return `objective_status: "satisfied"` and `next_tasks: []`.
97
97
  - Otherwise return only the next useful executable batch from the graph-derived Task boundaries.
98
98
  - Do not repeat completed work or recreate an existing Task unless replacement work is explicitly needed.
99
- - **Resolution is mandatory for replacement work:** before returning any `next_tasks`, inspect every existing `FAILED` or `BLOCKED` work Task. If it lacks an active/waiting `resolution` and the proposed batch creates related/sibling work, explicitly cover it with a valid `resolution` on the new recovery Task. For a same-Job recovery child, use `"resolution":{"path":"same_job_dependency","targetTaskId":"<existing failed/blocked task ID>","rootCause":"<confirmed cause or stated uncertainty>","solution":"<how the new task resolves the original>"}`. `targetTaskId` identifies the **existing unresolved Task**, not the new Task. Keep the recovered work semantically under the original artifact's subtree. Do not invent a resolution or silently cancel unrelated failed work.
99
+ - **Resolution is mandatory for replacement work:** before returning any `next_tasks`, inspect every existing `FAILED` or `BLOCKED` work Task. If it lacks an active/waiting `resolution` or durable `resolutionClassification` and the proposed batch creates related/sibling work, explicitly cover it with a valid `resolution` on the new recovery Task. A superseded/invalid classification covers only planning validation; preserve the FAILED state and never infer success from the classification. For a same-Job recovery child, use `"resolution":{"path":"same_job_dependency","targetTaskId":"<existing failed/blocked task ID>","rootCause":"<confirmed cause or stated uncertainty>","solution":"<how the new task resolves the original>"}`. `targetTaskId` identifies the **existing unresolved Task**, not the new Task. Keep the recovered work semantically under the original artifact's subtree. Do not invent a resolution or silently cancel unrelated failed work.
100
100
  - **On `retry_feedback`:** treat the feedback as Relay's authoritative validation error for the previous output in this **same Plan**. Correct the JSON proposal and all uncovered resolution targets; do not replay the same sibling Tasks unchanged. Preserve the intent and evidence already found, and return one fully corrected result. If no safe resolution can be justified, do not invent a successful outcome.
101
101
 
102
102
  - Prefer independently verifiable Tasks with stable IDs, but do not split work solely to make Tasks smaller.
@@ -1,6 +1,6 @@
1
1
  ---
2
2
  name: troubleshooter
3
- description: Recover FAILED Agents Relay tasks by diagnosing runtime/execution faults and repairing recoverable causes. BLOCKED tasks require authoritative unblock and are not resumed by Troubleshooter.
3
+ description: Recover failed Agents Relay tasks by diagnosing execution faults and repairing recoverable causes. Only a Troubleshooter can wait in BLOCKED; after authoritative unblock, the same Troubleshooter reruns and re-verifies before issuing a fresh resolution.
4
4
  capabilities: [troubleshoot, recovery, orchestration, local_repo, iterative_tools]
5
5
  runtime: codex
6
6
  delegation: [p0-self-job, relay-task-retry]
@@ -9,7 +9,7 @@ restrictions: [preserve-failure-evidence, no-blind-retry, no-human-blocker-bypas
9
9
 
10
10
  # Troubleshooter Agent
11
11
 
12
- You are the recovery orchestrator for a FAILED Agents Relay task. Do not authoritatively clear BLOCKED work; that requires `task unblock`, `job unblock`, or blocker-Job resolution.
12
+ You are the recovery orchestrator for a FAILED Agents Relay task. Ordinary Tasks never enter BLOCKED or UNBLOCKED. Only a Troubleshooter can become BLOCKED, and only an authoritative unblock can resume that same Troubleshooter.
13
13
 
14
14
  Your goal is not to explain the failure. Your goal is to make the original task move again when it is safe and technically possible.
15
15
 
@@ -33,7 +33,9 @@ Read `skills/agents-relay/troubleshooter-qa.md` for user-editable best practices
33
33
  - For a transient failure, verify the transient condition is gone.
34
34
  - For task-local failure, make the smallest durable repair in the original Job/PR when that is within scope.
35
35
  - For a shared platform/runtime defect, first create or reuse the owning blocker P0/self-job. If it can be completed inside this recovery execution, fix/verify it and retry the original Task. If it cannot be completed now, register the original Task with `agents-relay blocker add` on that blocker Job and finish this Troubleshooter task; do not stay running while waiting for another Job.
36
- - For a suspected genuine human/external blocker, do not publish `task.blocked` and do not branch on Neo's result yourself. Invoke `agents-relay escalation` with the original intent, blocker, and relevant context. The canonical graph owns the `resolved`, `failed`, and `needs_user` outcomes. When it returns `needs_user`, call Relay's `task escalate` operation with the concrete required action, resolution condition, and diagnostic context. Relay records the escalation and blocks this Troubleshooter; no worker remains running while waiting.
36
+ - For a suspected genuine human/external blocker, invoke `agents-relay escalation` with the original intent, blocker, and relevant context. The canonical graph owns the `resolved`, `failed`, and `needs_user` outcomes. When it returns `needs_user`, call Relay's `task escalate` operation with the concrete required action, resolution condition, and diagnostic context. Relay records the escalation and blocks this Troubleshooter; no worker remains running while waiting.
37
+ - After unblock, this same Troubleshooter must run with a new execution ID, re-check the blocking condition, and produce a fresh resolution bound to that execution. A previously resolved resolution record never authorizes retrying a new attempt.
38
+ - Unknown worker execution outcomes fail closed. Do not automatically reroute or retry the original task, and do not block the original task; diagnose it through the Troubleshooter.
37
39
  7. For an immediate repair, only after the blocker is removed request `agents-relay task recover` with this Troubleshooter's recovery Task ID, then reconcile the original Job. Verify the SQLite postcondition: original Task state QUEUED, expected resolution and recovery correlation. An authorized human response is a Task Comment, not a rewrite of Task input. Retain historical conditions as provenance, independently verify the original failing execution context, and do not submit an identical escalation with no new evidence. Deferred repair uses the durable blocker relation: merge-mode blockers wake dependents after merge; release/deploy/runtime blockers require `--resolution verified` and `agents-relay blocker resolve` only after verification.
38
40
  8. Verify durable evidence the original Task moved: at minimum QUEUED after retry, preferably a new execution / task.started / observable progress.
39
41
  9. Publish this Troubleshooter Task's terminal event only after the recovery outcome is durable.
@@ -70,7 +70,7 @@ Keep these concepts distinct: observation is not root cause; hypothesis is not r
70
70
 
71
71
  ### Job-defined Task tag
72
72
 
73
- `Task.state` is reserved for Relay lifecycle only: `DRAFT`, `QUEUED`, `RUNNING`, `BLOCKED`, `SUCCEEDED`, `FAILED`, and `CANCELLED`. A Job may additionally define one optional `tag` contract for a Job-specific result or classification that should be visible on each Task without changing its lifecycle state.
73
+ `Task.state` is reserved for Relay lifecycle only: `DRAFT`, `QUEUED`, `RUNNING`, `BLOCKED`, `SUCCEEDED`, `FAILED`, and `CANCELLED`. `BLOCKED` is exclusive to Tasks whose `agentName` is exactly `troubleshooter`; ordinary Tasks never enter `BLOCKED` or an `UNBLOCKED` lifecycle state. A Job may additionally define one optional `tag` contract for a Job-specific result or classification that should be visible on each Task without changing its lifecycle state.
74
74
 
75
75
  The Job owns the meaning of the tag. Its trusted Job marker declares a human-readable `description` explaining what the tag means and when workers should set it, plus the allowed durable values and their dashboard display strings:
76
76
 
@@ -114,6 +114,18 @@ A tag must describe something specific to the Job contract, not Relay lifecycle.
114
114
  For the ChatGPT Browser Worker guardian, for example, the Job defines `pass -> ✅` and `fail -> ❌`; each hourly E2E child Task reports `data.tag` after running the fixed deterministic E2E, while the Task lifecycle independently records whether Relay execution itself completed, failed, or became blocked.
115
115
 
116
116
 
117
+ ### Task resolution classifications
118
+
119
+ `Task.resolutionClassification` is durable diagnostic metadata, separate from
120
+ `Task.state` and the recovery `Task.resolution` path. The canonical Task
121
+ Resolution Graph and `task classify` CLI action may classify a known FAILED
122
+ work Task as `superseded` or `invalid`; each record requires a different
123
+ durable target Task, evidence, and reason. Classification never changes the
124
+ FAILED lifecycle state, execution history, or result and never proves success.
125
+ Unknown outcomes, active executions, and Tasks with active recovery ownership
126
+ cannot be classified. Planners must preserve these fields in their assessment
127
+ and may treat classified history as covered when validating a fresh Plan.
128
+
117
129
  ### Optional Review Chair Tasks
118
130
 
119
131
  Projects may use the shared `review-chair` agent as a normal Task or Agent
@@ -164,14 +176,14 @@ forbids it.
164
176
 
165
177
  ## Dependency vs block and authoritative unblock
166
178
 
167
- `dependencies` describe normal DAG ordering. Dependency completion does not create an unblock lifecycle event or Planner unblock trigger; Relay simply releases dependent work when its prerequisites succeed. `BLOCKED` is different: it records that execution reached a runtime/external condition that invalidates normal progress.
179
+ `dependencies` describe normal DAG ordering. An unmet dependency keeps ordinary work in `DRAFT`; dispatchable work is `QUEUED`. Dependency completion does not create an unblock lifecycle event or Planner unblock trigger; Relay releases dependent work when its prerequisites succeed. Worker, platform, semantic, and unknown-outcome failures are `FAILED`. Unknown worker outcome fails closed and never justifies rerouting. Only a Troubleshooter waiting for an authoritative human response may become `BLOCKED`.
168
180
 
169
- A BLOCKED Task cannot be resumed through retry, queue, dashboard state editing, PR push, webhook, ordinary task update, or troubleshooter inference. Except for blocker-Job resolution, the authoritative entry points are `task unblock --reason ...` and `job unblock --reason ...`. Authoritative unblock records the blocked baseline and current evidence, then transitions `BLOCKED -> UNBLOCKED` through the durable `task.unblocked` lifecycle event. `BLOCKED` therefore always means the blocker is still effective; `UNBLOCKED` means the old blocked attempt is historical and its blocker was explicitly cleared.
181
+ A BLOCKED Troubleshooter cannot be resumed through retry, queue, dashboard state editing, PR push, webhook, ordinary task update, or Troubleshooter inference. The authoritative entry point records the response and transitions that same Troubleshooter `BLOCKED -> QUEUED` through `task.unblocked`. The queued Troubleshooter must execute again, verify the condition from fresh evidence, and issue a fresh resolution before Relay may retry or replan the original FAILED Task. Prior `resolved` metadata never authorizes another attempt. Historical ordinary BLOCKED snapshots migrate to FAILED while their append-only event history remains unchanged; they are never silently converted to success.
170
182
 
171
- For autonomous Jobs, an UNBLOCKED Task is never silently retried. Its unblock metadata becomes the trigger/change context for the next Planner round, which decides whether to replace, split, wait, or otherwise re-plan the objective. For fixed Jobs, authoritative unblock records the UNBLOCKED transition first and may then requeue the same Task according to fixed-job retry semantics.
183
+ The old `UNBLOCKED` state is compatibility-only input. It normalizes to QUEUED only for Troubleshooter Tasks and to FAILED for ordinary Tasks, pending diagnosis.
172
184
 
173
185
  Each Planner round is also a first-class durable Plan entity stored in its own `agents-relay:plan:v1` PR comment. Relay creates the Plan comment before the internal planner execution record and before any generated Task comments, then updates that same Plan comment with the final assessment and generated Task IDs. Unblock/change facts belong to the Plan trigger/context, not to new Job-level Change Cycle state. Historical `kind=planner` Task records and legacy Job `changeCycle` fields remain readable for compatibility only.
174
186
 
175
- A blocker Job owns reverse `unblocks` references. When it resolves, Relay checks the dependent Job. If that autonomous Job has an effective BLOCKED work Task, resolution uses the same authoritative `BLOCKED -> UNBLOCKED -> Planner` path. If it does not have BLOCKED work (for example the registered Task is DRAFT or FAILED), existing release/retry behavior remains unchanged.
187
+ A blocker Job owns reverse `unblocks` references. When it resolves, Relay releases matching dependency-waiting Tasks through their ordinary DRAFT/QUEUED path. It cannot unblock ordinary work or resume a Troubleshooter without the authoritative response flow.
176
188
 
177
- Automatic Planner creation treats only a Task whose lifecycle state is exactly `BLOCKED` as a block gate. `UNBLOCKED` and FAILED history do not themselves gate a new Planner round; active DRAFT/QUEUED/RUNNING work still prevents a competing planning round.
189
+ Automatic Planner creation treats a currently BLOCKED Troubleshooter as a recovery gate. FAILED history alone does not authorize resolution or success; active DRAFT/QUEUED/RUNNING work still prevents a competing planning round.
@@ -5,15 +5,12 @@ description: Validates authoritative unblock requests and safely resumes blocked
5
5
  flowchart LR
6
6
  %% relay: initial blocked
7
7
  blocked["state: blocked"]
8
- autonomous["state: autonomous_replan"]
9
- fixed["state: fixed_retry"]
8
+ queued["state: troubleshooter_queued"]
10
9
 
11
- autonomousJob["guard: autonomousJob"]
12
- replan["action: unblock.replan"]
10
+ eligible["guard: blockedTroubleshooter"]
13
11
  retry["action: unblock.retry"]
14
12
 
15
- blocked -->|on: task.authoritative_unblock| autonomousJob
16
- autonomousJob -->|true| replan
17
- replan --> autonomous
18
- autonomousJob -->|false| retry
19
- retry --> fixed
13
+ blocked -->|on: task.authoritative_unblock| eligible
14
+ eligible -->|true| retry
15
+ eligible -->|false| blocked
16
+ retry --> queued
@@ -1,6 +1,6 @@
1
1
  ---
2
2
  title: Blocker Resolution
3
- description: Resolves blockers through verified recovery evidence and graph-authorized decisions.
3
+ description: Releases dependency-waiting work; failed work and blocked Troubleshooters require their own fresh recovery flow.
4
4
  ---
5
5
  flowchart LR
6
6
  %% relay: initial waiting
@@ -27,12 +27,7 @@ isBlocked["guard: isBlocked"]
27
27
  isFailed["guard: isFailed"]
28
28
  isUnblocked["guard: isUnblocked"]
29
29
 
30
- autonomousUnblock["action: blocker.autonomousUnblock"]
31
- acceptExisting["action: blocker.acceptExistingUnblock"]
32
30
  releaseDraft["action: blocker.releaseDraft"]
33
- unblockTask["action: blocker.unblockTask"]
34
- retryFailed["action: blocker.retryFailed"]
35
- retryUnblocked["action: blocker.retryUnblocked"]
36
31
 
37
32
  waiting -->|on: blocker.resolved| resolutionAllowed
38
33
  resolutionAllowed -->|false| wait
@@ -47,13 +42,11 @@ autonomousJob -->|true| checkEffective
47
42
  autonomousJob -->|false| checkDraft
48
43
 
49
44
  checkEffective -->|on: blocker.resolved| effectivelyBlocked
50
- effectivelyBlocked -->|true| autonomousUnblock
51
- autonomousUnblock --> resumed
45
+ effectivelyBlocked -->|true| wait
52
46
  effectivelyBlocked -->|false| checkAlreadyUnblocked
53
47
 
54
48
  checkAlreadyUnblocked -->|on: blocker.resolved| alreadyUnblocked
55
- alreadyUnblocked -->|true| acceptExisting
56
- acceptExisting --> resumed
49
+ alreadyUnblocked -->|true| resumed
57
50
  alreadyUnblocked -->|false| checkDraft
58
51
 
59
52
  checkDraft -->|on: blocker.resolved| isDraft
@@ -62,16 +55,13 @@ releaseDraft --> resumed
62
55
  isDraft -->|false| checkBlocked
63
56
 
64
57
  checkBlocked -->|on: blocker.resolved| isBlocked
65
- isBlocked -->|true| unblockTask
66
- unblockTask --> resumed
58
+ isBlocked -->|true| wait
67
59
  isBlocked -->|false| checkFailed
68
60
 
69
61
  checkFailed -->|on: blocker.resolved| isFailed
70
- isFailed -->|true| retryFailed
71
- retryFailed --> resumed
62
+ isFailed -->|true| wait
72
63
  isFailed -->|false| checkUnblocked
73
64
 
74
65
  checkUnblocked -->|on: blocker.resolved| isUnblocked
75
- isUnblocked -->|true| retryUnblocked
76
- retryUnblocked --> resumed
66
+ isUnblocked -->|true| wait
77
67
  isUnblocked -->|false| obsolete
@@ -7,7 +7,7 @@ flowchart LR
7
7
  idle["state: idle"]
8
8
  check["state: check"]
9
9
  external["state: external_completed"]
10
- blocked["state: blocked"]
10
+ failed["state: failed"]
11
11
  skip["state: skip"]
12
12
  rescued["state: rescued"]
13
13
  legacyActive["guard: legacyActive"]
@@ -16,7 +16,7 @@ unknownStartedOutcome["guard: unknownStartedOutcome"]
16
16
  rescuable["guard: rescuable"]
17
17
  rescue["action: task.rescueOrchestrator"]
18
18
  externalCompletion["action: task.applyExternalCompletion"]
19
- quarantine["action: task.blockLegacyOrchestrator"]
19
+ quarantine["action: task.failLegacyOrchestrator"]
20
20
  idle -->|on: orchestrator.rescue.reconcile| check
21
21
  check -->|on: orchestrator.rescue.reconcile| legacyActive
22
22
  legacyActive -->|false| skip
@@ -25,7 +25,7 @@ verifiedExternalCompletion -->|true| externalCompletion
25
25
  externalCompletion -->|to: external_completed| external
26
26
  verifiedExternalCompletion -->|false| unknownStartedOutcome
27
27
  unknownStartedOutcome -->|true| quarantine
28
- quarantine -->|to: blocked| blocked
28
+ quarantine -->|to: failed| failed
29
29
  unknownStartedOutcome -->|false| rescuable
30
30
  rescuable -->|false| skip
31
31
  rescuable -->|true| rescue