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 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 failed or blocked Task
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' || task.state === 'BLOCKED')
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 (!['FAILED', 'BLOCKED'].includes(task.state))
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`;
@@ -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(task => task.state === 'FAILED' || task.state === 'BLOCKED'))
277
+ if (scopedTasks.some(isEffectivelyBlocked))
277
278
  return job;
278
- if (planners.some(task => task.state === 'FAILED' || task.state === 'BLOCKED'))
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
  }
@@ -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' && task.agentName !== 'troubleshooter' && !isSuperseded(task));
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';
@@ -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;
@@ -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
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "agents-relay",
3
- "version": "1.0.160",
3
+ "version": "1.0.162",
4
4
  "description": "Durable async agent jobs coordinated through GitHub pull requests",
5
5
  "type": "module",
6
6
  "bin": {
@@ -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.