agents-relay 1.0.160 → 1.0.162
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 +26 -0
- package/dist/cli-help.js +8 -5
- package/dist/cli-unblock.js +32 -0
- package/dist/cli.js +16 -3
- package/dist/dashboard-actions.js +5 -1
- package/dist/planner.js +3 -0
- package/dist/reconciler-runtime.js +10 -0
- package/dist/reconciler.js +6 -5
- package/dist/runtime-status.js +6 -3
- package/dist/troubleshooter.js +2 -0
- package/dist/unblock.js +74 -0
- package/package.json +1 -1
- package/skills/agents-relay/agents/planner.agent.md +2 -0
- package/skills/agents-relay/contracts/task-job-contract.md +19 -0
package/dist/blockers.js
CHANGED
|
@@ -1,4 +1,5 @@
|
|
|
1
1
|
import { extendRetryBudget, transitionTaskEvent } from './scheduler.js';
|
|
2
|
+
import { isEffectivelyBlocked, unblockAutonomousTasks } from './unblock.js';
|
|
2
3
|
function dependentKey(value) {
|
|
3
4
|
return `${value.repository}#${value.prNumber}:${value.jobId}:${value.taskId}:a${value.blockedAttempt}`;
|
|
4
5
|
}
|
|
@@ -91,6 +92,31 @@ export async function resolveBlockerDependencies(options) {
|
|
|
91
92
|
obsolete += 1;
|
|
92
93
|
continue;
|
|
93
94
|
}
|
|
95
|
+
if (targetJob.executionMode === 'autonomous') {
|
|
96
|
+
if (isEffectivelyBlocked(task)) {
|
|
97
|
+
await unblockAutonomousTasks({
|
|
98
|
+
store,
|
|
99
|
+
job: targetJob,
|
|
100
|
+
tasks: [task],
|
|
101
|
+
trigger: { source: 'blocker_job', reason: `Blocker Job ${blocker.id} resolved (${options.resolution})`, blockerJobId: blocker.id, resolution: options.resolution },
|
|
102
|
+
now,
|
|
103
|
+
});
|
|
104
|
+
relation.state = 'resumed';
|
|
105
|
+
relation.resolvedAt = now;
|
|
106
|
+
relation.resolution = options.resolution;
|
|
107
|
+
relation.note = 'dependent task unblocked for replanning';
|
|
108
|
+
resumed += 1;
|
|
109
|
+
continue;
|
|
110
|
+
}
|
|
111
|
+
if (task.state === 'BLOCKED' && task.unblock) {
|
|
112
|
+
relation.state = 'resumed';
|
|
113
|
+
relation.resolvedAt = now;
|
|
114
|
+
relation.resolution = options.resolution;
|
|
115
|
+
relation.note = 'dependent Job already unblocked for replanning';
|
|
116
|
+
resumed += 1;
|
|
117
|
+
continue;
|
|
118
|
+
}
|
|
119
|
+
}
|
|
94
120
|
if (task.state === 'DRAFT') {
|
|
95
121
|
await transitionTaskEvent(task, 'release');
|
|
96
122
|
}
|
package/dist/cli-help.js
CHANGED
|
@@ -6,8 +6,8 @@ Usage:
|
|
|
6
6
|
npx agents-relay <command> [options]
|
|
7
7
|
|
|
8
8
|
Commands:
|
|
9
|
-
job create|update|show|list|merge|cancel Manage durable GitHub-backed jobs
|
|
10
|
-
task create|update|show|list|complete|retry|cancel Manage durable tasks
|
|
9
|
+
job create|update|show|list|merge|unblock|cancel Manage durable GitHub-backed jobs
|
|
10
|
+
task create|update|show|list|complete|retry|unblock|cancel Manage durable tasks
|
|
11
11
|
plan Invoke the Planner for an existing managed Job
|
|
12
12
|
blocker add|resolve|show Manage cross-Job blocker dependencies
|
|
13
13
|
context resolve Resolve shared project instructions, skills, agents, and runtimes
|
|
@@ -25,7 +25,7 @@ Use 'node dist/cli.js' only for explicit local development/demo mode.`,
|
|
|
25
25
|
job: `Manage GitHub-backed durable jobs.
|
|
26
26
|
|
|
27
27
|
Usage:
|
|
28
|
-
npx agents-relay job <create|update|show|list|merge|cancel> [options]
|
|
28
|
+
npx agents-relay job <create|update|show|list|merge|unblock|cancel> [options]
|
|
29
29
|
|
|
30
30
|
Actions:
|
|
31
31
|
create Create a durable Job, optionally binding an existing unmanaged PR
|
|
@@ -33,6 +33,7 @@ Actions:
|
|
|
33
33
|
show Show one durable Job
|
|
34
34
|
list List managed Jobs in a repository
|
|
35
35
|
merge Merge the backing PR and complete the Job
|
|
36
|
+
unblock Authoritatively invalidate current BLOCKED decisions and trigger replanning/resume
|
|
36
37
|
cancel Close the backing PR and cancel the Job
|
|
37
38
|
|
|
38
39
|
Common options:
|
|
@@ -74,6 +75,7 @@ Example:
|
|
|
74
75
|
'job merge': `Merge the backing pull request and complete the durable Job.
|
|
75
76
|
|
|
76
77
|
Required: --repo OWNER/REPO, --pr NUMBER, and --id JOB_ID. The current PR head SHA is pinned into the merge request.`,
|
|
78
|
+
'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 persist a Change Cycle and wake Planner; fixed Jobs resume the blocked Tasks directly.`,
|
|
77
79
|
'job cancel': `Cancel the durable Job and close its backing pull request.
|
|
78
80
|
|
|
79
81
|
Required: --repo OWNER/REPO, --pr NUMBER, and --id JOB_ID. Nonterminal Tasks are cancelled before the PR is closed.`,
|
|
@@ -86,14 +88,15 @@ Returns the same summary used by the dashboard header plus a running array with
|
|
|
86
88
|
task: `Manage durable tasks.
|
|
87
89
|
|
|
88
90
|
Usage:
|
|
89
|
-
npx agents-relay task <create|update|show|list|retry|cancel> [options]
|
|
91
|
+
npx agents-relay task <create|update|show|list|retry|unblock|cancel> [options]
|
|
90
92
|
|
|
91
93
|
Actions:
|
|
92
94
|
create Create one durable Task under an existing Job
|
|
93
95
|
update Update mutable Task fields
|
|
94
96
|
show Show one Task
|
|
95
97
|
list List Tasks for a Job
|
|
96
|
-
retry Retry a
|
|
98
|
+
retry Retry a FAILED Task
|
|
99
|
+
unblock Authoritatively invalidate a BLOCKED decision
|
|
97
100
|
complete Complete an orchestrator-owned Task
|
|
98
101
|
cancel Cancel a Task`,
|
|
99
102
|
'task create': `Create one durable Task under an existing Job.
|
|
@@ -0,0 +1,32 @@
|
|
|
1
|
+
import { arg, eventBus, publishWake, saveLocal } from './cli-support.js';
|
|
2
|
+
import { isEffectivelyBlocked, unblockAutonomousTasks, unblockTask } from './unblock.js';
|
|
3
|
+
export async function runTaskUnblockCommand(args, store, job, task, localFile) {
|
|
4
|
+
const reason = arg(args, '--reason');
|
|
5
|
+
if (!reason.trim())
|
|
6
|
+
throw new Error('task unblock requires --reason');
|
|
7
|
+
const result = await unblockTask({ store, job, task, trigger: { source: 'cli', reason } });
|
|
8
|
+
const refreshed = await store.load(job.id);
|
|
9
|
+
await saveLocal(localFile, refreshed);
|
|
10
|
+
await publishWake(eventBus(args), refreshed, task.id, `Task ${task.id} unblocked${result.replanning ? ' for replanning' : ''}`);
|
|
11
|
+
console.log(JSON.stringify({ taskId: task.id, ...result, task: refreshed.tasks.find(item => item.id === task.id) }, null, 2));
|
|
12
|
+
}
|
|
13
|
+
export async function runJobUnblockCommand(args, store, job) {
|
|
14
|
+
const reason = arg(args, '--reason');
|
|
15
|
+
if (!reason.trim())
|
|
16
|
+
throw new Error('job unblock requires --reason');
|
|
17
|
+
const blocked = job.tasks.filter(isEffectivelyBlocked);
|
|
18
|
+
if (blocked.length === 0)
|
|
19
|
+
throw new Error(`Job ${job.id} has no effective BLOCKED work task`);
|
|
20
|
+
if (job.executionMode === 'autonomous') {
|
|
21
|
+
const changeCycle = await unblockAutonomousTasks({ store, job, tasks: blocked, trigger: { source: 'cli', reason } });
|
|
22
|
+
const refreshed = await store.load(job.id);
|
|
23
|
+
await publishWake(eventBus(args), refreshed, 'job', `Job ${job.id} unblocked for replanning`);
|
|
24
|
+
console.log(JSON.stringify({ jobId: job.id, replanning: true, changeCycle }, null, 2));
|
|
25
|
+
return;
|
|
26
|
+
}
|
|
27
|
+
for (const task of blocked)
|
|
28
|
+
await unblockTask({ store, job, task, trigger: { source: 'cli', reason } });
|
|
29
|
+
const refreshed = await store.load(job.id);
|
|
30
|
+
await publishWake(eventBus(args), refreshed, 'job', `Job ${job.id} unblocked`);
|
|
31
|
+
console.log(JSON.stringify({ jobId: job.id, replanning: false, resumedTasks: blocked.map(task => task.id) }, null, 2));
|
|
32
|
+
}
|
package/dist/cli.js
CHANGED
|
@@ -24,6 +24,7 @@ import { defaultWorkspaceRoot, resolveManagedProjectPath, resolveRepositoryProje
|
|
|
24
24
|
import { runBlockerCommand } from './cli-blocker.js';
|
|
25
25
|
import { helpText, usage } from './cli-help.js';
|
|
26
26
|
import { parseAgentGraph } from './agent-graph.js';
|
|
27
|
+
import { runJobUnblockCommand, runTaskUnblockCommand } from './cli-unblock.js';
|
|
27
28
|
export { helpText } from './cli-help.js';
|
|
28
29
|
import { SERVICE_DEFAULTS, ServiceEventBus, arg, hasArg, textOption, integerArg, repository, storeFor, saveLocal, githubStore, eventBus, publishWake, priority, storySize, executionMode, chatGptTabClosePolicy, requestedExecutionMode, continuation, scheduleFromArgs, jobTagFromArgs, requestManagedReconciliation, } from './cli-support.js';
|
|
29
30
|
export { SERVICE_DEFAULTS, eventBus, executionMode, chatGptTabClosePolicy } from './cli-support.js';
|
|
@@ -130,7 +131,7 @@ async function workerTaskFromArgs(job, args, now) {
|
|
|
130
131
|
};
|
|
131
132
|
}
|
|
132
133
|
async function runTaskCommand(action, args) {
|
|
133
|
-
if (!['create', 'update', 'show', 'list', 'complete', 'retry', 'cancel'].includes(action))
|
|
134
|
+
if (!['create', 'update', 'show', 'list', 'complete', 'retry', 'unblock', 'cancel'].includes(action))
|
|
134
135
|
throw new Error(usage);
|
|
135
136
|
const { store, job, localFile } = await storeFor(args);
|
|
136
137
|
if (action === 'list') {
|
|
@@ -165,6 +166,10 @@ async function runTaskCommand(action, args) {
|
|
|
165
166
|
console.log(JSON.stringify(task, null, 2));
|
|
166
167
|
return;
|
|
167
168
|
}
|
|
169
|
+
if (action === 'unblock') {
|
|
170
|
+
await runTaskUnblockCommand(args, store, job, task, localFile);
|
|
171
|
+
return;
|
|
172
|
+
}
|
|
168
173
|
if (action === 'complete') {
|
|
169
174
|
if (task.adapter !== 'orchestrator')
|
|
170
175
|
throw new Error('task complete is only valid for adapter=orchestrator self-tasks');
|
|
@@ -189,6 +194,8 @@ async function runTaskCommand(action, args) {
|
|
|
189
194
|
}
|
|
190
195
|
if (action === 'retry' || action === 'cancel') {
|
|
191
196
|
if (action === 'retry') {
|
|
197
|
+
if (task.state === 'BLOCKED')
|
|
198
|
+
throw new Error(`Task ${id} is BLOCKED; use task unblock --reason instead of retry`);
|
|
192
199
|
task.maxAttempts = extendRetryBudget(task);
|
|
193
200
|
if (task.kind === 'planner' && task.error)
|
|
194
201
|
task.plannerRetryFeedback = task.error;
|
|
@@ -255,8 +262,10 @@ async function runTaskCommand(action, args) {
|
|
|
255
262
|
throw new Error('Task dependencies must succeed before queueing');
|
|
256
263
|
if (task.state === 'DRAFT')
|
|
257
264
|
await transitionTaskEvent(task, 'release');
|
|
258
|
-
else if (task.state === 'FAILED'
|
|
265
|
+
else if (task.state === 'FAILED')
|
|
259
266
|
await transitionTaskEvent(task, 'retry');
|
|
267
|
+
else if (task.state === 'BLOCKED')
|
|
268
|
+
throw new Error(`Task ${id} is BLOCKED; use task unblock --reason instead of --queue`);
|
|
260
269
|
}
|
|
261
270
|
if (hasArg(args, '--max-attempts') && task.maxAttempts < task.attempt)
|
|
262
271
|
throw new Error('--max-attempts cannot be less than the current attempt count');
|
|
@@ -306,7 +315,7 @@ async function runContextCommand(action, args) {
|
|
|
306
315
|
console.log(JSON.stringify(context, null, 2));
|
|
307
316
|
}
|
|
308
317
|
async function runJobCommand(action, args) {
|
|
309
|
-
if (!['create', 'update', 'show', 'list', 'merge', 'cancel'].includes(action))
|
|
318
|
+
if (!['create', 'update', 'show', 'list', 'merge', 'unblock', 'cancel'].includes(action))
|
|
310
319
|
throw new Error(usage);
|
|
311
320
|
const repo = repository(args);
|
|
312
321
|
let createObjective;
|
|
@@ -359,6 +368,10 @@ async function runJobCommand(action, args) {
|
|
|
359
368
|
throw new Error(`job ${action} requires --pr`);
|
|
360
369
|
const store = new GitHubStore(auth.client, repo, prNumber, auth.trustedAuthors);
|
|
361
370
|
const job = await store.load(id);
|
|
371
|
+
if (action === 'unblock') {
|
|
372
|
+
await runJobUnblockCommand(args, store, job);
|
|
373
|
+
return;
|
|
374
|
+
}
|
|
362
375
|
if (action === 'show') {
|
|
363
376
|
console.log(JSON.stringify(job, null, 2));
|
|
364
377
|
return;
|
|
@@ -7,7 +7,9 @@ export function dashboardTaskAction(store, reconciler, action) {
|
|
|
7
7
|
if (!task)
|
|
8
8
|
throw new Error(`Task ${taskId} not found`);
|
|
9
9
|
if (action === 'retry') {
|
|
10
|
-
if (
|
|
10
|
+
if (task.state === 'BLOCKED')
|
|
11
|
+
throw new Error(`Task ${task.id} is BLOCKED; use the unblock CLI with a reason`);
|
|
12
|
+
if (task.state !== 'FAILED')
|
|
11
13
|
throw new Error(`Task ${task.id} is not restartable (${task.state})`);
|
|
12
14
|
task.maxAttempts = extendRetryBudget(task);
|
|
13
15
|
await transitionTaskEvent(task, 'retry');
|
|
@@ -52,6 +54,8 @@ export function dashboardTaskSetState(store) {
|
|
|
52
54
|
throw new Error(`Task ${taskId} not found`);
|
|
53
55
|
if (task.state === state)
|
|
54
56
|
return job;
|
|
57
|
+
if (task.state === 'BLOCKED' && state !== 'CANCELLED')
|
|
58
|
+
throw new Error(`Task ${task.id} is BLOCKED; only the unblock CLI may resume or replan it`);
|
|
55
59
|
const now = new Date().toISOString();
|
|
56
60
|
const from = task.state;
|
|
57
61
|
applyTaskWorkflowDecision(task, {
|
package/dist/planner.js
CHANGED
|
@@ -281,6 +281,7 @@ export function plannerInput(context) {
|
|
|
281
281
|
retry_feedback: context.retryFeedback,
|
|
282
282
|
caller_prompt: context.guidance,
|
|
283
283
|
previous_planner_task_id: context.previousPlannerTaskId,
|
|
284
|
+
change_cycle: context.job.changeCycle,
|
|
284
285
|
project: project ? { name: project.name, path: project.path } : undefined,
|
|
285
286
|
visible_agents: visibleAgents ?? [],
|
|
286
287
|
tasks: context.tasks.map(task => ({
|
|
@@ -296,6 +297,8 @@ export function plannerInput(context) {
|
|
|
296
297
|
next_task_ids: task.plannerResult.next_tasks.map(item => item.id),
|
|
297
298
|
} : undefined,
|
|
298
299
|
error: boundedText(task.error, 2000),
|
|
300
|
+
blocked_baseline: task.blockedBaseline,
|
|
301
|
+
unblock: task.unblock,
|
|
299
302
|
})),
|
|
300
303
|
});
|
|
301
304
|
}
|
|
@@ -353,6 +353,16 @@ export class ReconcilerRuntimeBase {
|
|
|
353
353
|
}
|
|
354
354
|
else if (event.type === 'task.blocked') {
|
|
355
355
|
task.error = event.message || `Task ${task.id} blocked`;
|
|
356
|
+
const data = event.data ?? {};
|
|
357
|
+
const head = ['candidate_head', 'candidate_head_sha', 'pr_head', 'head_sha']
|
|
358
|
+
.map(key => data[key])
|
|
359
|
+
.find(value => typeof value === 'string' && value.length > 0);
|
|
360
|
+
task.blockedBaseline = {
|
|
361
|
+
blockedAt: event.timestamp || task.updatedAt,
|
|
362
|
+
headSha: typeof head === 'string' ? head : job.headSha,
|
|
363
|
+
reason: task.error,
|
|
364
|
+
};
|
|
365
|
+
task.unblock = undefined;
|
|
356
366
|
}
|
|
357
367
|
else if (event.type === 'task.cancelled') {
|
|
358
368
|
task.error = event.message || `Task ${task.id} cancelled`;
|
package/dist/reconciler.js
CHANGED
|
@@ -17,6 +17,7 @@ import { pendingTroubleshooterTasks } from './troubleshooter.js';
|
|
|
17
17
|
import { prepareManagedExecutionWorktree } from './workspace.js';
|
|
18
18
|
import { createReviewChairExecutionPlan } from './review-chair.js';
|
|
19
19
|
import { samePlannerChildDefinition } from './planner-child.js';
|
|
20
|
+
import { consumeChangeCycle, isEffectivelyBlocked } from './unblock.js';
|
|
20
21
|
import { taskTagWorkerPrompt } from './task-tag.js';
|
|
21
22
|
const PRIORITY_RANK = { P0: 0, P1: 1, P2: 2, P3: 3 };
|
|
22
23
|
export function compareTaskPriority(a, b, jobPriority = 'P2') { const ar = PRIORITY_RANK[a.priority ?? jobPriority]; const br = PRIORITY_RANK[b.priority ?? jobPriority]; return ar - br || a.createdAt.localeCompare(b.createdAt) || a.id.localeCompare(b.id); }
|
|
@@ -271,13 +272,11 @@ export class Reconciler extends ReconcilerReviewBase {
|
|
|
271
272
|
const slot = job.schedule?.currentRunSlot ? Date.parse(job.schedule.currentRunSlot) : Number.NaN;
|
|
272
273
|
const scopedTasks = Number.isFinite(slot) ? job.tasks.filter(task => Date.parse(task.createdAt) >= slot) : job.tasks;
|
|
273
274
|
const planners = scopedTasks.filter(task => task.kind === 'planner').sort((a, b) => a.createdAt.localeCompare(b.createdAt) || a.id.localeCompare(b.id));
|
|
274
|
-
if (planners.some(task => ['DRAFT', 'QUEUED', 'RUNNING'].includes(task.state)))
|
|
275
|
+
if (planners.some(task => ['DRAFT', 'QUEUED', 'RUNNING', 'FAILED'].includes(task.state)))
|
|
275
276
|
return job;
|
|
276
|
-
if (scopedTasks.some(
|
|
277
|
+
if (scopedTasks.some(isEffectivelyBlocked))
|
|
277
278
|
return job;
|
|
278
|
-
if (
|
|
279
|
-
return job;
|
|
280
|
-
if (scopedTasks.some(task => task.kind !== 'planner' && !['SUCCEEDED', 'CANCELLED'].includes(task.state)))
|
|
279
|
+
if (scopedTasks.some(task => task.kind !== 'planner' && ['DRAFT', 'QUEUED', 'RUNNING'].includes(task.state)))
|
|
281
280
|
return job;
|
|
282
281
|
if (planners.length > 0 && planners.at(-1)?.plannerResult?.objective_status === 'in_progress' && !job.tasks.some(task => task.planningDecisionId === planners.at(-1)?.id))
|
|
283
282
|
return job;
|
|
@@ -286,6 +285,8 @@ export class Reconciler extends ReconcilerReviewBase {
|
|
|
286
285
|
return job;
|
|
287
286
|
const task = createPlannerRoundTask(job, { timeoutMs: this.options.plannerTimeoutMs ?? storySizeTimeoutMs('M') });
|
|
288
287
|
await this.store.appendTask(task);
|
|
288
|
+
if (consumeChangeCycle(job, task.id))
|
|
289
|
+
await this.store.saveJob(job);
|
|
289
290
|
await this.emit(eventFor(job.id, task.id, task.parentTaskId, 'planner.created', 'queued', `Planner task ${task.id} created`, 'orchestrator'));
|
|
290
291
|
return this.store.load(job.id);
|
|
291
292
|
}
|
package/dist/runtime-status.js
CHANGED
|
@@ -2,7 +2,10 @@ function isSuperseded(task) {
|
|
|
2
2
|
return task.state === 'CANCELLED' && typeof task.error === 'string' && task.error.startsWith('Superseded ');
|
|
3
3
|
}
|
|
4
4
|
export function implementationTasks(job) {
|
|
5
|
-
return job.tasks.filter(task => task.agentName !== 'reviewer'
|
|
5
|
+
return job.tasks.filter(task => task.agentName !== 'reviewer'
|
|
6
|
+
&& task.agentName !== 'troubleshooter'
|
|
7
|
+
&& !isSuperseded(task)
|
|
8
|
+
&& !(task.state === 'BLOCKED' && task.unblock));
|
|
6
9
|
}
|
|
7
10
|
export function implementationComplete(job) {
|
|
8
11
|
const tasks = implementationTasks(job);
|
|
@@ -20,7 +23,7 @@ export function deriveJobRuntimeStatus(job, prState = 'OPEN') {
|
|
|
20
23
|
return 'RUNNING';
|
|
21
24
|
if (job.tasks.some(task => task.state === 'QUEUED'))
|
|
22
25
|
return 'QUEUED';
|
|
23
|
-
if (job.tasks.some(task => task.state === 'BLOCKED'))
|
|
26
|
+
if (job.tasks.some(task => task.state === 'BLOCKED' && !task.unblock))
|
|
24
27
|
return 'BLOCKED';
|
|
25
28
|
if (job.tasks.some(task => task.state === 'FAILED'))
|
|
26
29
|
return 'FAILED';
|
|
@@ -40,7 +43,7 @@ export function deriveJobRuntimeStatus(job, prState = 'OPEN') {
|
|
|
40
43
|
return 'RUNNING';
|
|
41
44
|
if (job.tasks.some(task => task.state === 'QUEUED'))
|
|
42
45
|
return 'QUEUED';
|
|
43
|
-
if (job.tasks.some(task => task.state === 'BLOCKED'))
|
|
46
|
+
if (job.tasks.some(task => task.state === 'BLOCKED' && !task.unblock))
|
|
44
47
|
return 'BLOCKED';
|
|
45
48
|
if (job.tasks.some(task => task.state === 'FAILED'))
|
|
46
49
|
return 'FAILED';
|
package/dist/troubleshooter.js
CHANGED
|
@@ -48,6 +48,8 @@ export function pendingTroubleshooterTasks(job) {
|
|
|
48
48
|
for (const task of job.tasks) {
|
|
49
49
|
if (!['FAILED', 'BLOCKED'].includes(task.state) || task.kind === 'planner' || task.agentName === 'troubleshooter' || !belongsToCurrentRun(job, task))
|
|
50
50
|
continue;
|
|
51
|
+
if (task.state === 'BLOCKED' && task.unblock)
|
|
52
|
+
continue;
|
|
51
53
|
const id = troubleshooterTaskId(task);
|
|
52
54
|
if (existing.has(id) || hasRecoveryBarrier(job, task))
|
|
53
55
|
continue;
|
package/dist/unblock.js
ADDED
|
@@ -0,0 +1,74 @@
|
|
|
1
|
+
import { extendRetryBudget, transitionTaskEvent } from './scheduler.js';
|
|
2
|
+
export function isEffectivelyBlocked(task) { return task.kind !== 'planner' && task.state === 'BLOCKED' && !task.unblock; }
|
|
3
|
+
function baselineFor(task) {
|
|
4
|
+
return task.blockedBaseline ?? { blockedAt: task.updatedAt, reason: task.error ?? undefined };
|
|
5
|
+
}
|
|
6
|
+
function cycleFacts(job, tasks, currentHeadSha, reason) {
|
|
7
|
+
const facts = [`unblock: ${reason}`];
|
|
8
|
+
const baselines = tasks.map(task => baselineFor(task));
|
|
9
|
+
const heads = [...new Set(baselines.map(item => item.headSha).filter((item) => Boolean(item)))];
|
|
10
|
+
if (currentHeadSha && heads.some(head => head !== currentHeadSha))
|
|
11
|
+
facts.push(`PR head advanced from ${heads.join(', ')} to ${currentHeadSha}`);
|
|
12
|
+
const times = baselines.map(item => Date.parse(item.blockedAt)).filter(Number.isFinite).sort((a, b) => a - b);
|
|
13
|
+
const earliest = times[0];
|
|
14
|
+
if (earliest !== undefined) {
|
|
15
|
+
for (const task of job.tasks) {
|
|
16
|
+
if (tasks.some(item => item.id === task.id))
|
|
17
|
+
continue;
|
|
18
|
+
const changedAt = Date.parse(task.updatedAt);
|
|
19
|
+
if (!Number.isFinite(changedAt) || changedAt < earliest)
|
|
20
|
+
continue;
|
|
21
|
+
const detail = task.result?.summary ?? task.error ?? task.state;
|
|
22
|
+
facts.push(`task ${task.id} -> ${task.state}: ${detail}`);
|
|
23
|
+
}
|
|
24
|
+
}
|
|
25
|
+
return [...new Set(facts)];
|
|
26
|
+
}
|
|
27
|
+
export async function unblockAutonomousTasks(options) {
|
|
28
|
+
const { store, job, trigger } = options;
|
|
29
|
+
const tasks = options.tasks.filter(isEffectivelyBlocked);
|
|
30
|
+
if (tasks.length === 0)
|
|
31
|
+
throw new Error('No effective BLOCKED work task is available to unblock');
|
|
32
|
+
const now = options.now ?? new Date().toISOString();
|
|
33
|
+
const currentHeadSha = await store.headSha?.() ?? job.headSha ?? null;
|
|
34
|
+
for (const task of tasks) {
|
|
35
|
+
const baseline = baselineFor(task);
|
|
36
|
+
const record = { at: now, source: trigger.source, reason: trigger.reason, baseline, currentHeadSha: currentHeadSha ?? undefined, blockerJobId: trigger.blockerJobId, resolution: trigger.resolution };
|
|
37
|
+
task.blockedBaseline = baseline;
|
|
38
|
+
task.unblock = record;
|
|
39
|
+
task.updatedAt = now;
|
|
40
|
+
delete task.blockedBy;
|
|
41
|
+
await store.saveTask(task);
|
|
42
|
+
}
|
|
43
|
+
const baselineHeadShas = [...new Set(tasks.map(task => baselineFor(task).headSha).filter((item) => Boolean(item)))];
|
|
44
|
+
const cycle = { triggeredAt: now, source: trigger.source, reason: trigger.reason, taskIds: tasks.map(task => task.id), baselineHeadShas, currentHeadSha: currentHeadSha ?? undefined, facts: cycleFacts(job, tasks, currentHeadSha, trigger.reason) };
|
|
45
|
+
job.changeCycle = cycle;
|
|
46
|
+
job.updatedAt = now;
|
|
47
|
+
await store.saveJob(job);
|
|
48
|
+
return cycle;
|
|
49
|
+
}
|
|
50
|
+
export async function unblockTask(options) {
|
|
51
|
+
if (!isEffectivelyBlocked(options.task))
|
|
52
|
+
throw new Error(`Task ${options.task.id} is not an effective BLOCKED work task`);
|
|
53
|
+
if (options.job.executionMode === 'autonomous') {
|
|
54
|
+
const changeCycle = await unblockAutonomousTasks({ store: options.store, job: options.job, tasks: [options.task], trigger: options.trigger, now: options.now });
|
|
55
|
+
return { replanning: true, changeCycle };
|
|
56
|
+
}
|
|
57
|
+
options.task.maxAttempts = extendRetryBudget(options.task);
|
|
58
|
+
await transitionTaskEvent(options.task, 'retry');
|
|
59
|
+
options.task.error = null;
|
|
60
|
+
options.task.leaseOwner = null;
|
|
61
|
+
options.task.leaseExpiresAt = null;
|
|
62
|
+
options.task.executionId = null;
|
|
63
|
+
options.task.updatedAt = options.now ?? new Date().toISOString();
|
|
64
|
+
delete options.task.blockedBy;
|
|
65
|
+
await options.store.saveTask(options.task);
|
|
66
|
+
return { replanning: false };
|
|
67
|
+
}
|
|
68
|
+
export function consumeChangeCycle(job, plannerTaskId, now = new Date().toISOString()) {
|
|
69
|
+
if (!job.changeCycle || job.changeCycle.consumedByPlannerTaskId)
|
|
70
|
+
return false;
|
|
71
|
+
job.changeCycle.consumedByPlannerTaskId = plannerTaskId;
|
|
72
|
+
job.updatedAt = now;
|
|
73
|
+
return true;
|
|
74
|
+
}
|
package/package.json
CHANGED
|
@@ -66,6 +66,8 @@ Do not create separate Tasks merely for "analyze", "format", "call tool", "check
|
|
|
66
66
|
- Otherwise return only the next useful executable batch from the graph-derived Task boundaries.
|
|
67
67
|
- Do not repeat completed work or recreate an existing Task unless replacement work is explicitly needed.
|
|
68
68
|
- Prefer independently verifiable Tasks with stable IDs, but do not split work solely to make Tasks smaller.
|
|
69
|
+
- When a Task depends on mutable repository or product state, distinguish a frozen evidence target from the current execution head. A SHA may freeze that Task's own review/source evidence, but do not copy an upstream SHA into a dependent release, deploy, publication, or other follow-on Task unless the objective explicitly requires that same frozen target. After dependencies complete, downstream Tasks must resolve and verify the current durable Job/PR head before acting.
|
|
70
|
+
- When fresh UI, browser, store, runtime, or visual evidence is material to the Task outcome, say so explicitly in the Task input and require evidence from the real current state. Existing screenshots, prepared assets, or prior observations are not substitutes for fresh evidence unless the objective explicitly allows reuse.
|
|
69
71
|
- When a Job needs multi-perspective review, use the visible `review-chair` agent or an Agent Graph to select specialist roles dynamically. Keep specialist Tasks as independent siblings against the same immutable target/version, and leave worker/model/provider routing to Relay.
|
|
70
72
|
- A Task describes what an agent must accomplish; never emit a shell command as a worker Task.
|
|
71
73
|
- Use dependencies only for Task IDs inside the current Job, and only when execution order actually matters. Never encode cross-Job or cross-repository dependencies in `dependencies`.
|
|
@@ -110,3 +110,22 @@ review-chair -> unified revision plan` is a project pattern, not a hard-coded
|
|
|
110
110
|
Relay reviewer list.
|
|
111
111
|
|
|
112
112
|
A scheduled autonomous job adds cron/timezone/next-run and missed-run/concurrency policy to the trusted job marker, plus the `agents-relay:autonomous` label and one bounded time-of-day `agents-relay:next-run:*` index label. The GitHub heartbeat discovers only indexed due candidates, validates the marker's exact `nextRunAt`, claims the slot idempotently, advances the durable next run, and pulses `agents-relay:schedule-wake`. GitHub webhook delivery then wakes local Relay for planner/worker execution. While the schedule is active, the durable Job remains `ACTIVE`; between runs its derived runtime status is `WAITING`. Scheduled recurring Jobs do not enter one-shot final-review/auto-merge after each satisfied run; older-cycle failures are historical evidence and do not block a later slot.
|
|
113
|
+
|
|
114
|
+
|
|
115
|
+
## Task evidence and head identity
|
|
116
|
+
|
|
117
|
+
Task inputs must make evidence freshness and target identity explicit when those facts affect correctness. A Task may bind its own review, audit, or source evidence to an immutable SHA/version. That frozen target belongs to that Task; it must not be propagated mechanically into later dependent work. A downstream release, deploy, publication, or other Task that runs after dependencies complete must resolve the current durable Job/PR head at execution time unless the Job objective explicitly requires the earlier immutable target.
|
|
118
|
+
|
|
119
|
+
When success depends on the actual current UI, browser state, store listing, runtime state, or another externally observable surface, the Task contract must require fresh evidence from that real current surface. Reusing an existing screenshot, prepared asset, cached observation, or older evidence is valid only when the Job explicitly permits it.
|
|
120
|
+
|
|
121
|
+
This does not weaken immutable review semantics: final review and specialist review Tasks should continue to declare and inspect one exact immutable target/version.
|
|
122
|
+
|
|
123
|
+
## Dependency vs block and authoritative unblock
|
|
124
|
+
|
|
125
|
+
`dependencies` describe normal DAG ordering. Dependency completion does not create an unblock event or a planning change cycle; Relay simply releases dependent work when its prerequisites succeed. `BLOCKED` is different: it records that execution reached a runtime/external condition that invalidates normal progress.
|
|
126
|
+
|
|
127
|
+
A BLOCKED Task cannot be resumed through retry, queue, dashboard state editing, PR push, webhook, ordinary task update, or troubleshooter inference. Except for blocker-Job resolution, the authoritative entry points are `task unblock --reason ...` and `job unblock --reason ...`. For autonomous Jobs, unblock does not retry the old Task. Relay preserves its blocked baseline, records current head/change facts in a durable Job Change Cycle, marks that blocked decision as unblocked-for-planning, and wakes the next Planner round. The Planner then decides whether to retry, replace, split, wait, or otherwise re-plan the objective.
|
|
128
|
+
|
|
129
|
+
A blocker Job owns reverse `unblocks` references. When it resolves, Relay checks the dependent Job. If that autonomous Job has an effective BLOCKED work Task, resolution uses the same Change Cycle/replanning primitive. If it does not have BLOCKED work (for example the registered Task is DRAFT or FAILED), existing release/retry behavior remains unchanged.
|
|
130
|
+
|
|
131
|
+
Automatic Planner creation treats only an effective BLOCKED work Task as a block gate. FAILED history is not itself a Planner gate; active DRAFT/QUEUED/RUNNING work still prevents a competing planning round.
|