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
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
@@ -9,7 +9,7 @@ Usage:
9
9
 
10
10
  Commands:
11
11
  job create|update|show|list|merge|unblock|cancel|pause|resume Manage SQLite-authoritative jobs with optional GitHub PRs
12
- task create|update|show|list|complete|retry|unblock|cancel Manage durable tasks
12
+ task create|update|show|list|classify|complete|retry|unblock|cancel Manage durable tasks
13
13
  review request Request graph-owned review (optional thread override)
14
14
  plan Invoke the Planner for an existing managed Job
15
15
  plan apply Submit a validated Task Tree within one active Plan
@@ -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.`,
@@ -166,15 +166,14 @@ Optional: --failure-kind adapter_failure|model_failure|provider_failure|task_fai
166
166
  'task cancel': `Cancel one durable Task.
167
167
 
168
168
  Required: --task-id ID and the normal Job storage options.`,
169
- 'task supersede': `Mark an already-CANCELLED durable Task as superseded by later corrected work through the Task Resolution Graph.
170
-
171
- Required: --task-id ID, --reason TEXT, and the normal managed Job storage options. This does not change Task state and cannot supersede FAILED/BLOCKED/runnable work.`,
169
+ 'task supersede': `Mark an already-CANCELLED durable Task as superseded by later corrected work through the Task Resolution Graph.\n\nRequired: --task-id ID, --reason TEXT, and the normal managed Job storage options. This does not change Task state and cannot supersede FAILED/BLOCKED/runnable work.`,
170
+ 'task classify': `Classify a FAILED work Task as superseded or invalid without changing its Task lifecycle.\n\nRequired: --task-id ID --classification superseded|invalid --target-task-id ID --evidence TEXT --reason TEXT and the normal Job storage options. Unknown or active execution outcomes are rejected.`,
172
171
  plan: `Invoke the Planner against the current durable Job state.
173
172
 
174
173
  Agent submit: agents-relay plan apply --repo OWNER/REPO --pr PR_NUMBER --id JOB_ID --plan-id PLAN_ID --execution-id EXECUTION_ID --file plan.json. Validation errors reject without partial task writes and can be corrected inside the same Planner execution.
175
174
 
176
175
  Required: --repo OWNER/REPO, --pr NUMBER, and --id JOB_ID.
177
- Optional: --prompt TEXT or --prompt-file PATH|- (mutually exclusive). The prompt is additional guidance for this planning round and never replaces the durable Job objective.
176
+ Optional: --prompt TEXT or --prompt-file PATH|- (mutually exclusive). The prompt is additional guidance for this planning round and never replaces the durable Job objective. --force-clean-history --clean-history-file PATH requires explicit {taskId,kind,targetTaskId,evidence,reason} records for every unclassified FAILED work Task; it applies graph-authorized classifications and creates the new Plan in one SQLite mutation. Unknown execution outcomes fail closed. Planner proposals must be committed with plan apply; final text is never applied as a Plan.
178
177
 
179
178
  The command persists one real plan-N round, materializes validated next_tasks with planner provenance, and wakes normal reconciliation. It works for zero-task DRAFT Jobs. Historical planner-N rounds remain readable, but new rounds use plan-N.
180
179
 
@@ -0,0 +1,16 @@
1
+ import { readFile } from 'node:fs/promises';
2
+ import { arg, hasArg } from './cli-support.js';
3
+ export async function cleanHistoryOption(args) {
4
+ const force = hasArg([...args], '--force-clean-history');
5
+ const path = arg([...args], '--clean-history-file');
6
+ if (!force && path)
7
+ throw new Error('--clean-history-file requires --force-clean-history');
8
+ if (force && !path)
9
+ throw new Error('--force-clean-history requires --clean-history-file with one classification per FAILED Task');
10
+ if (!path)
11
+ return undefined;
12
+ const value = JSON.parse(await readFile(path, 'utf8'));
13
+ if (!Array.isArray(value) || value.some(item => !item || typeof item !== 'object' || Array.isArray(item)))
14
+ throw new Error('--clean-history-file must contain a JSON array of classification objects');
15
+ return value;
16
+ }
@@ -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
  }
@@ -2,6 +2,17 @@ import { taskOverallTimeoutMs } from './types.js';
2
2
  import { arg, hasArg, textOption, integerArg, chatGptTabClosePolicy, storySize, priority, continuation } from './cli-support.js';
3
3
  import { parseAgentGraph } from './agent-graph.js';
4
4
  import { createTaskEntity } from './workflow/entity-creation.js';
5
+ export function recoveryDiagnosisFromArgs(args) {
6
+ const failureKind = arg(args, '--failure-kind');
7
+ if (!failureKind)
8
+ return undefined;
9
+ const reason = arg(args, '--reason');
10
+ if (!reason.trim())
11
+ throw new Error('--failure-kind requires --reason');
12
+ if (!['adapter_failure', 'model_failure', 'provider_failure', 'task_failure', 'unknown'].includes(failureKind))
13
+ throw new Error('Invalid --failure-kind');
14
+ return { failureKind, reason, evidence: arg(args, '--evidence') || undefined, reroute: hasArg(args, '--reroute'), excludeCurrentRoute: hasArg(args, '--exclude-current-route') };
15
+ }
5
16
  export async function workerTaskFromArgs(job, args, now) {
6
17
  const model = arg(args, '--model');
7
18
  const adapterValue = arg(args, '--adapter');
package/dist/cli.js CHANGED
@@ -23,6 +23,8 @@ import { discoverAgents } from './registry.js';
23
23
  import { AgentObjectivePlanner, CommandObjectivePlanner } from './planner.js';
24
24
  import { applyTaskHoldAction, applyTaskQueueAction, dispatchTaskAction } from './task-actions.js';
25
25
  import { applyJobActivateAction, applyJobCancelAction } from './job-actions.js';
26
+ import { classifyFailedTask } from './task-actions.js';
27
+ import { cleanHistoryOption } from './cli-plan-clean-history.js';
26
28
  import { resolveExecutionContext } from './execution-context.js';
27
29
  import { startServiceExternalActionFlusher } from './service-external-actions.js';
28
30
  import { handleServicePublication } from './service-publication.js';
@@ -42,7 +44,7 @@ import { normalizeThreadId, runtimeReviewer } from './reviewer-thread.js';
42
44
  export { runtimeReviewer } from './reviewer-thread.js';
43
45
  import { SERVICE_DEFAULTS, ServiceEventBus, arg, hasArg, textOption, integerArg, repository, storeFor, saveLocal, eventBus, publishWake, priority, storySize, executionMode, chatGptTabClosePolicy, requestedExecutionMode, continuation, scheduleFromArgs, jobTagFromArgs, requestManagedReconciliation, relayControlJob, relayControlCommand, relayJobList, relayJobStateCommand, relayTaskCommand, withEventBus } from './cli-support.js';
44
46
  export { SERVICE_DEFAULTS, eventBus, executionMode, chatGptTabClosePolicy } from './cli-support.js';
45
- import { workerTaskFromArgs } from './cli-worker-task.js';
47
+ import { recoveryDiagnosisFromArgs, workerTaskFromArgs } from './cli-worker-task.js';
46
48
  import { taskExecutionContext } from './task-context.js';
47
49
  import { requestTroubleshooterEscalation, resumeTroubleshooter } from './task-recovery.js';
48
50
  import { pauseJob, resumeJob } from './job-pause.js';
@@ -72,20 +74,8 @@ async function runManagedTaskCommand(action, args) {
72
74
  throw new Error(`Task ${id} missing after ${action}`);
73
75
  console.log(JSON.stringify(task, null, 2));
74
76
  }
75
- function recoveryDiagnosisFromArgs(args) {
76
- const failureKind = arg(args, '--failure-kind');
77
- if (!failureKind)
78
- return undefined;
79
- const reason = arg(args, '--reason');
80
- if (!reason.trim())
81
- throw new Error('--failure-kind requires --reason');
82
- if (!['adapter_failure', 'model_failure', 'provider_failure', 'task_failure', 'unknown'].includes(failureKind))
83
- throw new Error('Invalid --failure-kind');
84
- const reroute = args.includes('--reroute');
85
- return { failureKind, reason, evidence: arg(args, '--evidence') || undefined, reroute, excludeCurrentRoute: args.includes('--exclude-current-route') };
86
- }
87
77
  async function runTaskCommand(action, args) {
88
- if (!['create', 'update', 'show', 'context', 'comment', 'comments', 'list', 'retry', 'recover', 'unblock', 'escalate', 'resume-troubleshooter', 'cancel', 'supersede'].includes(action))
78
+ if (!['create', 'update', 'show', 'context', 'comment', 'comments', 'list', 'retry', 'recover', 'unblock', 'escalate', 'resume-troubleshooter', 'cancel', 'supersede', 'classify'].includes(action))
89
79
  throw new Error(usage);
90
80
  const managed = !arg(args, '--file') && Boolean(arg(args, '--repo') && arg(args, '--id'));
91
81
  if (managed) {
@@ -130,6 +120,16 @@ async function runTaskCommand(action, args) {
130
120
  console.log(JSON.stringify(task, null, 2));
131
121
  return;
132
122
  }
123
+ if (action === 'classify') {
124
+ if (!taskId)
125
+ throw new Error('task classify requires --task-id');
126
+ const kind = arg(args, '--classification');
127
+ if (kind !== 'superseded' && kind !== 'invalid')
128
+ throw new Error('--classification must be superseded or invalid');
129
+ const result = await relayControlCommand(args, { command: 'task.classify', jobId, taskId, classification: { kind, targetTaskId: arg(args, '--target-task-id'), evidence: await textOption(args, '--evidence', '--evidence-file'), reason: await textOption(args, '--reason', '--reason-file') } });
130
+ console.log(JSON.stringify(result.tasks.find(item => item.id === taskId), null, 2));
131
+ return;
132
+ }
133
133
  if (['create', 'retry', 'cancel'].includes(action)) {
134
134
  await runManagedTaskCommand(action, args);
135
135
  return;
@@ -262,6 +262,15 @@ async function runTaskCommand(action, args) {
262
262
  console.log(JSON.stringify(task, null, 2));
263
263
  return;
264
264
  }
265
+ if (action === 'classify') {
266
+ const kind = arg(args, '--classification');
267
+ if (kind !== 'superseded' && kind !== 'invalid')
268
+ throw new Error('--classification must be superseded or invalid');
269
+ const result = await classifyFailedTask(store, job.id, id, { kind, targetTaskId: arg(args, '--target-task-id'), evidence: await textOption(args, '--evidence', '--evidence-file'), reason: await textOption(args, '--reason', '--reason-file') });
270
+ await saveLocal(localFile, result.job);
271
+ console.log(JSON.stringify(result.task, null, 2));
272
+ return;
273
+ }
265
274
  if (action === 'unblock') {
266
275
  await runTaskUnblockCommand(args, store, job, task, localFile);
267
276
  return;
@@ -798,7 +807,8 @@ async function main() {
798
807
  throw new Error(`${command} requires --repo and --id`);
799
808
  if (command === 'plan') {
800
809
  const guidance = hasArg(args, '--prompt') || hasArg(args, '--prompt-file') ? await textOption(args, '--prompt', '--prompt-file') : undefined;
801
- console.log(JSON.stringify(await relayControlCommand(args, { command: 'job.plan', jobId, ...(guidance?.trim() ? { guidance: guidance.trim() } : {}) }), null, 2));
810
+ const cleanHistory = await cleanHistoryOption(args);
811
+ console.log(JSON.stringify(await relayControlCommand(args, { command: 'job.plan', jobId, ...(guidance?.trim() ? { guidance: guidance.trim() } : {}), ...(cleanHistory ? { cleanHistory } : {}) }), null, 2));
802
812
  return;
803
813
  }
804
814
  if (command === 'reconcile') {
@@ -824,10 +834,11 @@ async function main() {
824
834
  const { store, job, localFile } = loaded;
825
835
  if (command === 'plan') {
826
836
  const guidance = hasArg(args, '--prompt') || hasArg(args, '--prompt-file') ? await textOption(args, '--prompt', '--prompt-file') : undefined;
837
+ const cleanHistory = await cleanHistoryOption(args);
827
838
  await withEventBus(args, async (bus) => {
828
839
  const reconciler = runtime(store, args, bus);
829
840
  try {
830
- const result = await reconciler.planJob(job.id, guidance?.trim() || undefined);
841
+ const result = await reconciler.planJob(job.id, guidance?.trim() || undefined, cleanHistory);
831
842
  await saveLocal(localFile, result.job);
832
843
  await publishWake(bus, result.job, result.round.id, `Planning round ${result.round.id} completed`);
833
844
  console.log(JSON.stringify(result.round, null, 2));
@@ -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
+ }
@@ -263,7 +263,7 @@ const TASK_STATES=['DRAFT','QUEUED','RUNNING','BLOCKED','SUCCEEDED','FAILED','CA
263
263
  async function setTaskState(task,state,select){const job=selected();if(!job||state===task.state)return;select.disabled=true;try{const params=new URLSearchParams({id:job.id,task:task.id,state,repo:job.repository,pr:String(job.prNumber)});const response=await fetch('/api/task-state?'+params.toString(),{method:'POST'});if(!response.ok)throw new Error(response.status+' '+response.statusText);const updated=await response.json();const index=jobs.findIndex(candidate=>jobSelectionKey(candidate)===jobSelectionKey(job));if(index>=0)jobs[index]=updated;else jobs.push(updated);renderAll();void loadEventHistory();}catch(error){select.title='Set task state: '+error.message;select.value=task.state;}finally{select.disabled=false;}}
264
264
  function taskStateField(task){const box=element('div','detail-group');box.append(element('p','detail-label',text('State')));const select=document.createElement('select');select.className='job-filter task-state-select';select.setAttribute('aria-label','Task state');for(const state of TASK_STATES)select.append(new Option(state,state));select.value=task.state;select.addEventListener('change',()=>void setTaskState(task,select.value,select));box.append(select);return box;}
265
265
  function manualOverrideField(task){const rows=Array.isArray(task.manualStateOverrides)?task.manualStateOverrides:[];const last=rows.at(-1);return field('Manual override',last?last.from+' → '+last.to+' · '+relativeTime(last.at):'—');}
266
- function showDetailsDialog(title,sections){const dialog=element('dialog','details-dialog');dialog.setAttribute('aria-label',title);const head=element('div','details-dialog-head');const name=element('strong','',text(title));const close=element('button','refresh-button',text('Close'));close.type='button';close.addEventListener('click',()=>dialog.close());head.append(name,close);dialog.append(head);for(const [label,value] of sections){if(value===undefined||value===null||value==='')continue;dialog.append(element('h3','',text(label)));const content=element('pre');content.textContent=typeof value==='string'?value:JSON.stringify(value,null,2);dialog.append(content);}dialog.addEventListener('close',()=>dialog.remove());document.body.append(dialog);dialog.showModal();}function openJobDetails(){const job=selected();if(!job)return;showDetailsDialog('Job '+job.id+' · '+value(job.title),[['Objective',job.objective],['Description',job.description],['State',job.state],['Plans',job.plans?.map(p=>({id:p.id,status:p.status,assessment:p.assessment,error:p.error,generatedTaskIds:p.generatedTaskIds}))],['Review',job.review],['Outcome',job.result],['Publication',job.publication],['Failure',job.error]]);}function openTaskDetails(task){const result=task.result?.data??task.result;showDetailsDialog('Task '+task.id+' · '+value(task.title),[['Input / task objective',task.input],['State',task.state],['Conclusion',result],['Result summary',task.result?.summary],['Planner assessment',task.plannerResult?.assessment],['Resolution',task.resolution],['Failure',task.failureReason||task.error],['Dependencies',task.dependencies]]);}document.querySelector('#job-content-open').addEventListener('click',openJobDetails);function renderTaskDetail(task){taskDetailRoot.replaceChildren();if(!task){taskDetailRoot.append(element('p','empty',text('Select a task to inspect execution details.')));return;}const routing=task.routing||{};const groups=element('div','detail-groups');groups.append(field('Title',task.title||'—'),field('Task ID',task.id),field('Kind',task.kind||'work'),taskStateField(task),manualOverrideField(task),field('Planning decision',task.planningDecisionId||'—'),field('Planning source',task.planningSource||'—'),field('Planning round',task.planningRound===undefined?'—':String(task.planningRound)),field('Project / agent',value(task.projectName)+' / '+value(task.agentName)),field('Parent task',task.parentTaskId||'root'),field('Adapter',task.adapter),field('Provider / model',value(routing.provider)+' / '+value(routing.model)),field('Reasoning / profile',value(routing.reasoning||routing.profile)),field('Attempt',attemptBudget(task)),field('Dependencies',Array.isArray(task.dependencies)&&task.dependencies.length?task.dependencies.join(', '):'none'),taskCommentField(task),chatField(task));const job=selected();if(task.reviewHeadSha&&job)groups.append(commitField('HEAD commit',job,task.reviewHeadSha));const graph=agentGraphField(task);if(graph)groups.append(graph);if(task.plannerResult)groups.append(field('Objective status',task.plannerResult.objective_status),markdownField('Assessment',task.plannerResult.assessment));if(task.error)groups.append(field('Error',task.error));const result=task.result?.data??task.result;if(result){groups.append(field('Conclusion',result.verdict||result.outcome?.verdict||task.result?.summary||'Available'));}taskDetailRoot.append(groups);const details=element('button','refresh-button',text('Task details / conclusion'));details.type='button';details.addEventListener('click',()=>openTaskDetails(task));taskDetailRoot.append(details);}
266
+ function showDetailsDialog(title,sections){const dialog=element('dialog','details-dialog');dialog.setAttribute('aria-label',title);const head=element('div','details-dialog-head');const name=element('strong','',text(title));const close=element('button','refresh-button',text('Close'));close.type='button';close.addEventListener('click',()=>dialog.close());head.append(name,close);dialog.append(head);for(const [label,value] of sections){if(value===undefined||value===null||value==='')continue;dialog.append(element('h3','',text(label)));const content=element('pre');content.textContent=typeof value==='string'?value:JSON.stringify(value,null,2);dialog.append(content);}dialog.addEventListener('close',()=>dialog.remove());document.body.append(dialog);dialog.showModal();}function openJobDetails(){const job=selected();if(!job)return;showDetailsDialog('Job '+job.id+' · '+value(job.title),[['Objective',job.objective],['Description',job.description],['State',job.state],['Plans',job.plans?.map(p=>({id:p.id,status:p.status,assessment:p.assessment,error:p.error,generatedTaskIds:p.generatedTaskIds}))],['Review',job.review],['Outcome',job.result],['Publication',job.publication],['Failure',job.error]]);}function openTaskDetails(task){const result=task.result?.data??task.result;showDetailsDialog('Task '+task.id+' · '+value(task.title),[['Input / task objective',task.input],['State',task.state],['Conclusion',result],['Result summary',task.result?.summary],['Planner assessment',task.plannerResult?.assessment],['Resolution',task.resolution],['Resolution classification',task.resolutionClassification],['Failure',task.failureReason||task.error],['Dependencies',task.dependencies]]);}document.querySelector('#job-content-open').addEventListener('click',openJobDetails);function renderTaskDetail(task){taskDetailRoot.replaceChildren();if(!task){taskDetailRoot.append(element('p','empty',text('Select a task to inspect execution details.')));return;}const routing=task.routing||{};const groups=element('div','detail-groups');groups.append(field('Title',task.title||'—'),field('Task ID',task.id),field('Kind',task.kind||'work'),taskStateField(task),manualOverrideField(task),field('Planning decision',task.planningDecisionId||'—'),field('Planning source',task.planningSource||'—'),field('Planning round',task.planningRound===undefined?'—':String(task.planningRound)),field('Project / agent',value(task.projectName)+' / '+value(task.agentName)),field('Parent task',task.parentTaskId||'root'),...(task.resolutionClassification?[field('Resolution classification',task.resolutionClassification.kind),field('Resolution target',task.resolutionClassification.targetTaskId),field('Resolution evidence',task.resolutionClassification.evidence),field('Resolution reason',task.resolutionClassification.reason)]:[]),field('Adapter',task.adapter),field('Provider / model',value(routing.provider)+' / '+value(routing.model)),field('Reasoning / profile',value(routing.reasoning||routing.profile)),field('Attempt',attemptBudget(task)),field('Dependencies',Array.isArray(task.dependencies)&&task.dependencies.length?task.dependencies.join(', '):'none'),taskCommentField(task),chatField(task));const job=selected();if(task.reviewHeadSha&&job)groups.append(commitField('HEAD commit',job,task.reviewHeadSha));const graph=agentGraphField(task);if(graph)groups.append(graph);if(task.plannerResult)groups.append(field('Objective status',task.plannerResult.objective_status),markdownField('Assessment',task.plannerResult.assessment));if(task.error)groups.append(field('Error',task.error));const result=task.result?.data??task.result;if(result){groups.append(field('Conclusion',result.verdict||result.outcome?.verdict||task.result?.summary||'Available'));}taskDetailRoot.append(groups);const details=element('button','refresh-button',text('Task details / conclusion'));details.type='button';details.addEventListener('click',()=>openTaskDetails(task));taskDetailRoot.append(details);}
267
267
  function reviewStatus(job){const review=job?.review;if(!review)return 'Not started';if(review.outcome==='approved'&&review.headSha===job.headSha)return 'MERGE READY · approved';const stale=Array.isArray(job?.tasks)&&job.tasks.some(task=>task.agentName==='reviewer'&&task.reviewHeadSha&&job.headSha&&task.reviewHeadSha!==job.headSha);if(stale)return 'STALE · awaiting review';return String(review.outcome||'pending').replace('_',' ').toUpperCase();}function renderSelectedJob(){jobDetail.replaceChildren();const job=selected();if(!job){selectedJobTitle.textContent='';jobDetail.append(element('div','empty',text('No durable jobs discovered.')));buildTree([]);renderTaskDetail(null);return;}selectedJobTitle.textContent=value(job.title||job.id);if(jobSelectionKey(job)!==selectedJob){selectedJob=jobSelectionKey(job);selectedTaskId=null;eventTaskId=null;clearEvents();rememberSelection();}const summary=element('div','job-summary');summary.append(field('PR state',job.githubState||'UNKNOWN'),jobStateField(job),field('Runtime status',job.runtimeStatus||'IDLE'),field('Execution mode',job.executionMode||'fixed'),field('Cadence',cadence(job)),field('Review',reviewStatus(job)),field('Creator thread',job.creatorThreadId||'—'),threadField('Implementation thread',job.implementationThreadId),threadField('Reviewer thread',job.reviewerThreadId),commitField('Review head SHA',job,job.review?.headSha||job.headSha||''),prField(job),field('Repository',job.repository||'—'),field('Last activity',relativeTime(job.updatedAt)),field('Task states',compactCounts(job.tasks)),chatShortcutField(job));jobDetail.append(summary);const selectedTask=(job.tasks||[]).find(task=>task.id===selectedTaskId)||null;if(selectedTaskId&&!selectedTask){selectedTaskId=null;eventTaskId=null;clearEvents();rememberSelection();}buildTree(job.tasks||[]);renderTaskDetail(selectedTask);renderEventScope();}function renderAll(){jobs.sort(compareJobs);syncFilters();renderJobList();renderSelectedJob();syncMobileJobsView();}
268
268
  function isCompleteUnmerged(job){return job.runtimeStatus==='MERGE_PENDING';}
269
269
  function isTerminalJob(job){return ['COMPLETED','CANCELLED'].includes(job.state)||['MERGED','CLOSED'].includes(job.githubState);}
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
  }
@@ -1,7 +1,8 @@
1
1
  import { dispatchJobLifecycleSync } from './workflow/lifecycle.js';
2
2
  import { createPlanRound, pendingPlan } from './planner.js';
3
3
  import { applyJobActivateAction } from './job-actions.js';
4
- export async function executeManualPlan(store, jobId, guidance, timeoutMs, launchPlanner) {
4
+ import { classifyTaskResolution } from './workflow/task-resolution.js';
5
+ export async function executeManualPlan(store, jobId, guidance, timeoutMs, launchPlanner, cleanHistory) {
5
6
  let job = await store.load(jobId);
6
7
  if (['COMPLETED', 'CANCELLED'].includes(job.state))
7
8
  throw new Error(`Cannot plan terminal job ${job.id} (${job.state})`);
@@ -18,6 +19,23 @@ export async function executeManualPlan(store, jobId, guidance, timeoutMs, launc
18
19
  if (current.pause)
19
20
  throw new Error(`Cannot plan paused Job ${current.id}; resume it first`);
20
21
  const concurrent = pendingPlan(current);
22
+ if (cleanHistory) {
23
+ if (concurrent)
24
+ throw new Error(`--force-clean-history requires no pending Plan (${concurrent.id})`);
25
+ const failed = current.tasks.filter(task => task.kind !== 'planner' && task.state === 'FAILED' && !task.resolutionClassification);
26
+ const provided = new Map(cleanHistory.map(item => [item.taskId, item]));
27
+ if (provided.size !== cleanHistory.length || failed.length !== provided.size || failed.some(task => !provided.has(task.id)))
28
+ throw new Error('--force-clean-history requires exactly one explicit classification for every FAILED work Task');
29
+ for (const task of failed) {
30
+ const item = provided.get(task.id);
31
+ if (item.targetTaskId === task.id || !current.tasks.some(target => target.id === item.targetTaskId))
32
+ throw new Error(`Classification for ${task.id} must target a different durable Task in this Job`);
33
+ classifyTaskResolution(task, item);
34
+ }
35
+ const unknown = current.tasks.filter(task => task.kind !== 'planner' && task.state === 'FAILED' && task.failureReason === 'worker_outcome_unknown');
36
+ if (unknown.length)
37
+ throw new Error(`--force-clean-history cannot proceed while FAILED Task execution outcomes are unknown: ${unknown.map(task => task.id).join(', ')}`);
38
+ }
21
39
  if (concurrent) {
22
40
  if (concurrent.executionId)
23
41
  throw new Error(`Planning round ${concurrent.id} is already PLANNING`);