agents-relay 2.0.156 → 2.0.158
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/dist/blockers.js +6 -27
- package/dist/chatgpt-platform-fallback.js +1 -1
- package/dist/cli-help.js +5 -6
- package/dist/cli-plan-clean-history.js +16 -0
- package/dist/cli-unblock.js +3 -14
- package/dist/cli-worker-task.js +11 -0
- package/dist/cli.js +27 -16
- package/dist/dashboard-actions.js +9 -19
- package/dist/dashboard-data.js +45 -0
- package/dist/dashboard-html.js +1 -1
- package/dist/dashboard.js +2 -52
- package/dist/host-invocation-store.js +2 -2
- package/dist/interactive-invocation.js +1 -1
- package/dist/interactive-resume.js +5 -5
- package/dist/legacy-blocked-task-migration.js +14 -0
- package/dist/legacy-orchestrator-repair.js +6 -6
- package/dist/manual-plan.js +19 -1
- package/dist/planner-recovery.js +1 -1
- package/dist/planner-runtime.js +9 -1
- package/dist/planner.js +18 -23
- package/dist/projection-outbox.js +1 -1
- package/dist/reconciler-review.js +4 -3
- package/dist/reconciler-runtime.js +15 -19
- package/dist/reconciler.js +3 -3
- package/dist/relay-store.js +2 -0
- package/dist/relayd-control.js +14 -7
- package/dist/single-job-task-control.js +1 -1
- package/dist/task-actions.js +28 -3
- package/dist/task-recovery.js +1 -1
- package/dist/troubleshooter.js +1 -1
- package/dist/types.js +12 -6
- package/dist/unblock.js +5 -44
- package/dist/workflow/authoritative-unblock-v2.js +6 -7
- package/dist/workflow/contracts.js +3 -0
- package/dist/workflow/definitions-v2.js +3 -2
- package/dist/workflow/lifecycle.js +5 -5
- package/dist/workflow/orchestrator-rescue.js +13 -12
- package/dist/workflow/runtime.js +13 -2
- package/dist/workflow/task-resolution.js +52 -15
- package/dist/workflow/troubleshooter-v2.js +5 -3
- package/package.json +3 -3
- package/skills/agents-relay/agents/planner.agent.md +2 -2
- package/skills/agents-relay/agents/troubleshooter.agent.md +5 -3
- package/skills/agents-relay/contracts/task-job-contract.md +18 -6
- package/workflow/authoritative-unblock.mmd +6 -9
- package/workflow/blocker-resolution.mmd +6 -16
- package/workflow/orchestrator-rescue.mmd +3 -3
- package/workflow/task-lifecycle.mmd +45 -59
- package/workflow/task-resolution.mmd +24 -3
- package/workflow/troubleshooter.mmd +20 -1
package/dist/blockers.js
CHANGED
|
@@ -1,5 +1,5 @@
|
|
|
1
|
-
import { applyTaskReleaseAction
|
|
2
|
-
import { isEffectivelyBlocked
|
|
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
|
|
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
|
|
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.
|
|
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.
|
|
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
|
|
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
|
+
}
|
package/dist/cli-unblock.js
CHANGED
|
@@ -1,5 +1,5 @@
|
|
|
1
1
|
import { arg, publishTaskUnblocked, publishWake, saveLocal, withEventBus } from './cli-support.js';
|
|
2
|
-
import { isEffectivelyBlocked,
|
|
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}
|
|
38
|
+
await publishWake(bus, refreshed, 'job', `Troubleshooter work in Job ${job.id} resumed`);
|
|
50
39
|
});
|
|
51
|
-
console.log(JSON.stringify({ jobId: job.id,
|
|
40
|
+
console.log(JSON.stringify({ jobId: job.id, resumedTasks: blocked.map(task => task.id) }, null, 2));
|
|
52
41
|
}
|
package/dist/cli-worker-task.js
CHANGED
|
@@ -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
|
-
|
|
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
|
|
2
|
-
import { jobLifecycleV2
|
|
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 (
|
|
49
|
-
throw new Error(
|
|
50
|
-
|
|
51
|
-
|
|
52
|
-
|
|
53
|
-
|
|
54
|
-
|
|
55
|
-
});
|
|
56
|
-
task.manualStateOverrides = [
|
|
57
|
-
...(task.manualStateOverrides ?? []),
|
|
58
|
-
{ from, to: state, at: now, actor: 'dashboard-user' },
|
|
59
|
-
].slice(-20);
|
|
60
|
-
task.leaseOwner = null;
|
|
61
|
-
task.leaseExpiresAt = null;
|
|
62
|
-
task.executionId = null;
|
|
63
|
-
await store.saveTask(task);
|
|
64
|
-
return await store.load(job.id);
|
|
48
|
+
if (state !== 'CANCELLED')
|
|
49
|
+
throw new Error('Task lifecycle state is managed by Relay; use retry, cancel, or the Troubleshooter response flow');
|
|
50
|
+
if (task.state !== 'CANCELLED') {
|
|
51
|
+
await dispatchTaskAction(store, { id: 'task.cancel', jobId, taskId });
|
|
52
|
+
return await store.load(job.id);
|
|
53
|
+
}
|
|
54
|
+
return job;
|
|
65
55
|
};
|
|
66
56
|
}
|
|
@@ -0,0 +1,45 @@
|
|
|
1
|
+
import { readFile, readdir } from 'node:fs/promises';
|
|
2
|
+
import { fileURLToPath } from 'node:url';
|
|
3
|
+
import { join } from 'node:path';
|
|
4
|
+
import { eventsBusHistory } from './events-bus-runtime.js';
|
|
5
|
+
const repositoryPattern = /^([A-Za-z0-9](?:[A-Za-z0-9-]{0,36}[A-Za-z0-9])?)\/([A-Za-z0-9._-]{1,100})$/;
|
|
6
|
+
const workflowDirectory = fileURLToPath(new URL('../workflow/', import.meta.url));
|
|
7
|
+
export async function dashboardWorkflowGraphs() {
|
|
8
|
+
const names = (await readdir(workflowDirectory)).filter(name => name.endsWith('.mmd')).sort();
|
|
9
|
+
return Promise.all(names.map(async (name) => {
|
|
10
|
+
const source = await readFile(join(workflowDirectory, name), 'utf8');
|
|
11
|
+
const metadata = /^---\r?\n([\s\S]*?)\r?\n---(?:\r?\n|$)/.exec(source)?.[1] ?? '';
|
|
12
|
+
const field = (key) => metadata.match(new RegExp(`^${key}:\\s*(.+)$`, 'm'))?.[1]?.trim() ?? '';
|
|
13
|
+
return {
|
|
14
|
+
id: name.replace(/\.mmd$/, ''),
|
|
15
|
+
name: field('title') || (name === 'main.mmd' ? 'Main / Happy Path' : name.replace(/\.mmd$/, '').split('-').map(part => part.charAt(0).toUpperCase() + part.slice(1)).join(' ')),
|
|
16
|
+
description: field('description'), path: `workflow/${name}`, source,
|
|
17
|
+
};
|
|
18
|
+
}));
|
|
19
|
+
}
|
|
20
|
+
export function githubPrUrl(repository, prNumber) {
|
|
21
|
+
if (typeof repository !== 'string' || typeof prNumber !== 'number' || !Number.isInteger(prNumber) || prNumber <= 0)
|
|
22
|
+
return null;
|
|
23
|
+
const match = repositoryPattern.exec(repository);
|
|
24
|
+
if (!match)
|
|
25
|
+
return null;
|
|
26
|
+
const [, owner, repo] = match;
|
|
27
|
+
if ([owner, repo].some(segment => segment === '.' || segment === '..'))
|
|
28
|
+
return null;
|
|
29
|
+
return `https://github.com/${owner}/${repo}/pull/${prNumber}`;
|
|
30
|
+
}
|
|
31
|
+
export function effectiveGitHubUsage(snapshot, runtime) {
|
|
32
|
+
const candidates = [snapshot?.resources.core, snapshot?.resources.graphql, snapshot?.resources.search, runtime?.resources.core, runtime?.resources.graphql, runtime?.resources.search]
|
|
33
|
+
.filter((candidate) => candidate !== undefined && candidate !== null)
|
|
34
|
+
.filter(candidate => Number.isFinite(candidate.limit) && Number(candidate.limit) > 0 && Number.isFinite(candidate.remaining) && Number.isFinite(candidate.reset));
|
|
35
|
+
const lowest = candidates.reduce((current, candidate) => !current || Number(candidate.remaining) / Number(candidate.limit) < Number(current.remaining) / Number(current.limit) ? candidate : current, null);
|
|
36
|
+
return lowest ? { limit: Number(lowest.limit), remaining: Number(lowest.remaining), reset: Number(lowest.reset) } : null;
|
|
37
|
+
}
|
|
38
|
+
export async function eventHistory(jobId, taskId, limit) {
|
|
39
|
+
try {
|
|
40
|
+
return await eventsBusHistory(jobId, taskId, limit);
|
|
41
|
+
}
|
|
42
|
+
catch {
|
|
43
|
+
return [];
|
|
44
|
+
}
|
|
45
|
+
}
|
package/dist/dashboard-html.js
CHANGED
|
@@ -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 {
|
|
14
|
-
|
|
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', '
|
|
55
|
-
(task.executionId !== delivery.executionId && task.state !== '
|
|
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 !== '
|
|
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 !== '
|
|
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
|
-
//
|
|
16
|
-
if (!tryTransitionTaskEvent(task, '
|
|
17
|
-
throw new Error('Interactive Task
|
|
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 !== '
|
|
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,
|
|
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.
|
|
44
|
-
const status = repair.externallyCompleted ? 'succeeded' : repair.
|
|
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.
|
|
48
|
-
? `
|
|
49
|
-
: `Recovered legacy orchestrator task ${task.id} through Execution Router`, 'orchestrator', { ageMs: repair.ageMs, adapter: repair.externallyCompleted || repair.
|
|
47
|
+
: repair.failed
|
|
48
|
+
? `Failed legacy RUNNING orchestrator task ${task.id}; worker outcome is unknown and Troubleshooter diagnosis is required`
|
|
49
|
+
: `Recovered legacy orchestrator task ${task.id} through Execution Router`, 'orchestrator', { ageMs: repair.ageMs, adapter: repair.externallyCompleted || repair.failed ? undefined : 'execution-router' }, { agent: 'relay', component: 'agents-relay' }));
|
|
50
50
|
}
|
|
51
51
|
}
|
package/dist/manual-plan.js
CHANGED
|
@@ -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
|
-
|
|
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`);
|