agents-relay 1.0.100 → 1.0.101

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.
@@ -0,0 +1,113 @@
1
+ import { extendRetryBudget, transitionTaskEvent } from './scheduler.js';
2
+ function dependentKey(value) {
3
+ return `${value.repository}#${value.prNumber}:${value.jobId}:${value.taskId}:a${value.blockedAttempt}`;
4
+ }
5
+ function sameBlocker(left, right) {
6
+ return !!left && left.repository === right.repository && left.prNumber === right.prNumber && left.jobId === right.jobId;
7
+ }
8
+ export async function registerBlockerDependency(options) {
9
+ const blocker = await options.blockerStore.load(options.blockerJobId);
10
+ const dependentJob = await options.dependentStore.load(options.dependentJobId);
11
+ const task = dependentJob.tasks.find(item => item.id === options.dependentTaskId);
12
+ if (!task)
13
+ throw new Error(`Dependent task ${options.dependentTaskId} not found`);
14
+ if (!['FAILED', 'BLOCKED'].includes(task.state))
15
+ throw new Error(`Dependent task ${task.id} is not blocked/failed (${task.state})`);
16
+ const now = options.now ?? new Date().toISOString();
17
+ const relation = {
18
+ repository: dependentJob.repository,
19
+ prNumber: dependentJob.prNumber,
20
+ jobId: dependentJob.id,
21
+ taskId: task.id,
22
+ blockedAttempt: task.attempt,
23
+ registeredAt: now,
24
+ state: 'waiting',
25
+ };
26
+ const existing = (blocker.unblocks ?? []).find(item => dependentKey(item) === dependentKey(relation));
27
+ const requestedMode = options.resolutionMode ?? blocker.blockerResolutionMode ?? 'merge';
28
+ const taskBlocker = { repository: blocker.repository, prNumber: blocker.prNumber, jobId: blocker.id, blockedAttempt: task.attempt, registeredAt: existing?.registeredAt ?? now };
29
+ if (existing) {
30
+ if (!task.blockedBy || !sameBlocker(task.blockedBy, taskBlocker) || task.blockedBy.blockedAttempt !== task.attempt) {
31
+ task.blockedBy = taskBlocker;
32
+ task.updatedAt = now;
33
+ await options.dependentStore.saveTask(task);
34
+ }
35
+ return existing;
36
+ }
37
+ if (blocker.blockerResolutionMode && blocker.blockerResolutionMode !== requestedMode)
38
+ throw new Error(`Blocker ${blocker.id} already uses ${blocker.blockerResolutionMode} resolution`);
39
+ blocker.blockerResolutionMode = requestedMode;
40
+ blocker.unblocks = [...(blocker.unblocks ?? []), relation];
41
+ blocker.updatedAt = now;
42
+ await options.blockerStore.saveJob(blocker);
43
+ task.blockedBy = taskBlocker;
44
+ task.updatedAt = now;
45
+ await options.dependentStore.saveTask(task);
46
+ return relation;
47
+ }
48
+ function obsoleteReason(task, relation) {
49
+ if (!task)
50
+ return 'dependent task no longer exists';
51
+ if (!['FAILED', 'BLOCKED'].includes(task.state))
52
+ return `dependent task advanced to ${task.state}`;
53
+ if (task.attempt !== relation.blockedAttempt)
54
+ return `dependent task advanced from attempt ${relation.blockedAttempt} to ${task.attempt}`;
55
+ return 'dependent task no longer matches blocker registration';
56
+ }
57
+ export async function resolveBlockerDependencies(options) {
58
+ const blocker = await options.blockerStore.load(options.blockerJobId);
59
+ const mode = blocker.blockerResolutionMode ?? 'merge';
60
+ if (mode === 'verified' && options.resolution !== 'verified') {
61
+ const waiting = (blocker.unblocks ?? []).filter(item => item.state === 'waiting').length;
62
+ return { resumed: 0, obsolete: 0, waiting };
63
+ }
64
+ const now = options.now ?? new Date().toISOString();
65
+ let resumed = 0;
66
+ let obsolete = 0;
67
+ for (const relation of blocker.unblocks ?? []) {
68
+ if (relation.state !== 'waiting')
69
+ continue;
70
+ const store = options.dependentStore(relation.repository, relation.prNumber);
71
+ let targetJob;
72
+ try {
73
+ targetJob = await store.load(relation.jobId);
74
+ }
75
+ catch (error) {
76
+ relation.state = 'obsolete';
77
+ relation.resolvedAt = now;
78
+ relation.resolution = options.resolution;
79
+ relation.note = error instanceof Error ? error.message : String(error);
80
+ obsolete += 1;
81
+ continue;
82
+ }
83
+ const task = targetJob.tasks.find(item => item.id === relation.taskId);
84
+ const blockerRef = { repository: blocker.repository, prNumber: blocker.prNumber, jobId: blocker.id };
85
+ const matches = !!task && ['FAILED', 'BLOCKED'].includes(task.state) && task.attempt === relation.blockedAttempt && sameBlocker(task.blockedBy, blockerRef);
86
+ if (!matches) {
87
+ relation.state = 'obsolete';
88
+ relation.resolvedAt = now;
89
+ relation.resolution = options.resolution;
90
+ relation.note = obsoleteReason(task, relation);
91
+ obsolete += 1;
92
+ continue;
93
+ }
94
+ task.maxAttempts = extendRetryBudget(task);
95
+ await transitionTaskEvent(task, 'retry');
96
+ task.error = null;
97
+ task.leaseOwner = null;
98
+ task.leaseExpiresAt = null;
99
+ task.executionId = null;
100
+ delete task.blockedBy;
101
+ task.updatedAt = now;
102
+ await store.saveTask(task);
103
+ relation.state = 'resumed';
104
+ relation.resolvedAt = now;
105
+ relation.resolution = options.resolution;
106
+ relation.note = 'dependent task requeued';
107
+ resumed += 1;
108
+ }
109
+ blocker.blockerResolvedAt = now;
110
+ blocker.updatedAt = now;
111
+ await options.blockerStore.saveJob(blocker);
112
+ return { resumed, obsolete, waiting: (blocker.unblocks ?? []).filter(item => item.state === 'waiting').length };
113
+ }
@@ -0,0 +1,44 @@
1
+ import { GitHubStore } from './store.js';
2
+ import { githubAuthContext } from './github-auth.js';
3
+ import { registerBlockerDependency } from './blockers.js';
4
+ import { arg, repository } from './cli-support.js';
5
+ import { usage } from './cli-help.js';
6
+ export async function runBlockerCommand(action, args) {
7
+ if (!['add', 'resolve', 'show'].includes(action))
8
+ throw new Error(usage);
9
+ const repo = repository(args);
10
+ const id = arg(args, '--id');
11
+ const pr = Number(arg(args, '--pr'));
12
+ if (!id || !Number.isInteger(pr) || pr <= 0)
13
+ throw new Error('blocker commands require --repo, --pr, and --id for the blocker Job');
14
+ const auth = await githubAuthContext(args);
15
+ const blockerStore = new GitHubStore(auth.client, repo, pr, auth.trustedAuthors);
16
+ if (action === 'show') {
17
+ console.log(JSON.stringify(await blockerStore.load(id), null, 2));
18
+ return;
19
+ }
20
+ if (action === 'resolve') {
21
+ const summary = await blockerStore.resolveBlockerDependents(id, 'verified');
22
+ console.log(JSON.stringify({ blocker: id, resolution: 'verified', ...summary }, null, 2));
23
+ return;
24
+ }
25
+ const dependentRepo = arg(args, '--dependent-repo');
26
+ const dependentPr = Number(arg(args, '--dependent-pr'));
27
+ const dependentJob = arg(args, '--dependent-job');
28
+ const dependentTask = arg(args, '--dependent-task');
29
+ if (!dependentRepo || !Number.isInteger(dependentPr) || dependentPr <= 0 || !dependentJob || !dependentTask) {
30
+ throw new Error('blocker add requires --dependent-repo, --dependent-pr, --dependent-job, and --dependent-task');
31
+ }
32
+ const modeValue = arg(args, '--resolution', 'merge');
33
+ if (!['merge', 'verified'].includes(modeValue))
34
+ throw new Error('--resolution must be merge or verified');
35
+ const relation = await registerBlockerDependency({
36
+ blockerStore,
37
+ blockerJobId: id,
38
+ dependentStore: new GitHubStore(auth.client, dependentRepo, dependentPr, auth.trustedAuthors),
39
+ dependentJobId: dependentJob,
40
+ dependentTaskId: dependentTask,
41
+ resolutionMode: modeValue,
42
+ });
43
+ console.log(JSON.stringify(relation, null, 2));
44
+ }
package/dist/cli-help.js CHANGED
@@ -1,4 +1,4 @@
1
- export const usage = 'agents-relay <job|task|context|init|record|agent-register|agent-discover|reconcile|tick|serve> [options]';
1
+ export const usage = 'agents-relay <job|task|blocker|context|init|record|agent-register|agent-discover|reconcile|tick|serve> [options]';
2
2
  const help = {
3
3
  root: `Agents Relay coordinates durable asynchronous agent jobs through GitHub pull requests.
4
4
 
@@ -8,6 +8,7 @@ Usage:
8
8
  Commands:
9
9
  job create|update|show|list|merge|cancel Manage durable GitHub-backed jobs
10
10
  task create|update|show|list|complete|retry|cancel Manage durable tasks
11
+ blocker add|resolve|show Manage cross-Job blocker dependencies
11
12
  context resolve Resolve shared project instructions, skills, agents, and runtimes
12
13
  init Create a local demo job or initialize a GitHub job
13
14
  record Backfill a completed task into durable history
@@ -118,6 +119,21 @@ Required: --task-id ID and the normal Job storage options.`,
118
119
  'task cancel': `Cancel one durable Task.
119
120
 
120
121
  Required: --task-id ID and the normal Job storage options.`,
122
+ blocker: `Manage durable cross-Job blocker dependencies.
123
+
124
+ Usage:
125
+ npx agents-relay blocker <add|resolve|show> [options]
126
+
127
+ The blocker Job owns reverse \`unblocks\` references. \`add\` registers a failed/blocked dependent task. \`resolve\` is the explicit verified-resolution path; merge-mode blockers also resolve automatically after the blocker PR is observed merged.`,
128
+ 'blocker add': `Register a dependent failed/blocked Task on a blocker Job.
129
+
130
+ Blocker: --repo OWNER/REPO --pr NUMBER --id JOB_ID.
131
+ Dependent: --dependent-repo OWNER/REPO --dependent-pr NUMBER --dependent-job JOB_ID --dependent-task TASK_ID.
132
+ Common: --resolution merge|verified (default merge).`,
133
+ 'blocker resolve': `Resolve a verified blocker and safely requeue matching dependents.
134
+
135
+ Required: --repo OWNER/REPO --pr NUMBER --id JOB_ID. This explicit path is required for blockers configured with --resolution verified.`,
136
+ 'blocker show': `Show a blocker Job including its durable unblocks relations.`,
121
137
  context: `Resolve the shared execution context used by XChat and Relay.
122
138
 
123
139
  Usage:
@@ -188,5 +204,7 @@ export function helpText(command, action) {
188
204
  return action ? help[`task ${action}`] ?? help.task : help.task;
189
205
  if (command === 'context')
190
206
  return action ? help[`context ${action}`] ?? help.context : help.context;
207
+ if (command === 'blocker')
208
+ return action ? help[`blocker ${action}`] ?? help.blocker : help.blocker;
191
209
  return command ? help[command] ?? help.root : help.root;
192
210
  }
package/dist/cli.js CHANGED
@@ -19,6 +19,7 @@ import { extendRetryBudget, transitionJobEvent, transitionTaskEvent } from './sc
19
19
  import { dueBuckets, processDueScheduledJobs, syncScheduleLabels } from './scheduled-jobs.js';
20
20
  import { resolveExecutionContext } from './execution-context.js';
21
21
  import { defaultWorkspaceRoot, resolveManagedProjectPath } from './workspace.js';
22
+ import { runBlockerCommand } from './cli-blocker.js';
22
23
  import { helpText, usage } from './cli-help.js';
23
24
  export { helpText } from './cli-help.js';
24
25
  import { SERVICE_DEFAULTS, ServiceEventBus, arg, hasArg, textOption, integerArg, repository, storeFor, saveLocal, githubStore, eventBus, publishWake, priority, storySize, executionMode, chatGptTabClosePolicy, requestedExecutionMode, continuation, scheduleFromArgs, } from './cli-support.js';
@@ -352,6 +353,7 @@ async function runJobCommand(action, args) {
352
353
  await store.mergePullRequest(headSha ?? undefined);
353
354
  await transitionJobEvent(job, 'pr.merged');
354
355
  await store.saveJob(job);
356
+ await store.resolveBlockerDependents(id, 'merge');
355
357
  console.log(JSON.stringify(await store.load(id), null, 2));
356
358
  return;
357
359
  }
@@ -568,6 +570,11 @@ async function main() {
568
570
  await runContextCommand(action ?? '', contextArgs);
569
571
  return;
570
572
  }
573
+ if (command === 'blocker') {
574
+ const [action, ...blockerArgs] = args;
575
+ await runBlockerCommand(action ?? '', blockerArgs);
576
+ return;
577
+ }
571
578
  if (command === 'init') {
572
579
  const now = new Date().toISOString();
573
580
  const job = { id: arg(args, '--id', randomUUID()), title: arg(args, '--title', 'Agents Relay job'), objective: arg(args, '--objective') || undefined, priority: priority(args), executionMode: executionMode(args), schedule: scheduleFromArgs(args), autoMerge: !hasArg(args, '--no-auto-merge'), reviewEnabled: !hasArg(args, '--no-review'), prNumber: Number(arg(args, '--pr', '0')), repository: arg(args, '--repo'), state: scheduleFromArgs(args) ? 'ACTIVE' : 'DRAFT', continuation: continuation(args), createdAt: now, updatedAt: now, tasks: [] };
@@ -127,6 +127,8 @@ export class Reconciler extends ReconcilerReviewBase {
127
127
  await this.applyJobEffectOutcomes(job, outcomes);
128
128
  }
129
129
  }
130
+ if (prState === 'MERGED' && this.store.resolveBlockerDependents)
131
+ await this.store.resolveBlockerDependents(job.id, 'merge');
130
132
  return this.store.load(jobId);
131
133
  }
132
134
  if (job.state !== 'ACTIVE')
@@ -1,5 +1,6 @@
1
1
  import { parseAgent, parseJob, parseTask, renderAgentComment, renderJobBody, renderTaskComment, JOB_MARKER } from './markers.js';
2
2
  import { isTaskForJob, sameTaskDefinition } from './store-task-definition.js';
3
+ import { resolveBlockerDependencies } from './blockers.js';
3
4
  export class GitHubStore {
4
5
  client;
5
6
  repository;
@@ -93,6 +94,9 @@ export class GitHubStore {
93
94
  return ref && sha ? { ref, sha } : null;
94
95
  }
95
96
  async headSha() { return (await this.pullRequestHead())?.sha ?? null; }
97
+ async resolveBlockerDependents(jobId, resolution) {
98
+ return await resolveBlockerDependencies({ blockerStore: this, blockerJobId: jobId, resolution, dependentStore: (repository, prNumber) => new GitHubStore(this.client, repository, prNumber, this.trustedAuthors) });
99
+ }
96
100
  async load(jobId) {
97
101
  const comments = (await this.list()).filter(comment => this.isTrusted(comment));
98
102
  const bodyJob = parseJob(await this.pullRequestBody());
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "agents-relay",
3
- "version": "1.0.100",
3
+ "version": "1.0.101",
4
4
  "description": "Durable async agent jobs coordinated through GitHub pull requests",
5
5
  "type": "module",
6
6
  "bin": {
@@ -28,9 +28,9 @@ Read and follow the shared Task / Job contract and orchestrator P0/self-job work
28
28
  5. Repair:
29
29
  - For a transient failure, verify the transient condition is gone.
30
30
  - For task-local failure, make the smallest durable repair in the original Job/PR when that is within scope.
31
- - For a shared platform/runtime defect, become the active orchestrator for a P0/self-job in the repository that owns the broken component. Create the durable P0 Job and its orchestrator-owned Task, fix it directly, validate, merge, and complete any required publish/deploy reconciliation.
31
+ - For a shared platform/runtime defect, first create or reuse the owning blocker P0/self-job. If it can be completed inside this recovery execution, fix/verify it and retry the original Task. If it cannot be completed now, register the original Task with `agents-relay blocker add` on that blocker Job and finish this Troubleshooter task; do not stay running while waiting for another Job.
32
32
  - For a genuine human/external blocker, do not fake progress. Publish task.blocked with the exact action required.
33
- 6. Only after the blocker is removed, run `agents-relay task retry` for the original Task and reconcile its Job.
33
+ 6. For an immediate repair, only after the blocker is removed run `agents-relay task retry` for the original Task and reconcile its Job. For deferred repair, rely on the durable blocker relation: merge-mode blockers wake matching dependents after merge; release/deploy/runtime blockers use `--resolution verified` and `agents-relay blocker resolve` only after verification.
34
34
  7. Verify durable evidence that the original Task moved: at minimum QUEUED after retry, preferably a new execution / task.started / observable progress.
35
35
  8. Publish this Troubleshooter Task's terminal event only after the recovery outcome is durable.
36
36
 
@@ -43,7 +43,7 @@ Read and follow the shared Task / Job contract and orchestrator P0/self-job work
43
43
  - Repository PR creation/adoption/repair still goes through Agents Relay.
44
44
  - Keep fixes narrowly scoped; do not use a failed application task as permission for broad platform refactoring.
45
45
  - If a P0 fix publishes a package or changes a managed service, verify the released artifact and dependent service health before retrying the original Task.
46
- - Do not mark yourself successful merely because a repair PR was opened. Recovery succeeds when the original Task is actually retryable and has been retried.
46
+ - Do not mark an immediate recovery successful merely because a repair PR was opened. A deferred recovery may complete after it has durably registered the exact original Task/attempt on the blocker Job; Relay then owns the later generation-safe wake-up.
47
47
 
48
48
  ## Example
49
49
 
@@ -98,6 +98,12 @@ A Task with `adapter: orchestrator` is synchronous self-work owned by the exact
98
98
  - Successful self-work is terminalized on the same durable Task with `task complete --task-id ... --summary ...`; do not use `record` as a substitute for a Task that was created before execution. `record` remains a backfill mechanism for work that genuinely happened outside Relay.
99
99
  - If work is intended to survive the current turn or be picked up asynchronously, use a real worker adapter rather than `orchestrator`.
100
100
 
101
+ ## Cross-Job blocker dependencies
102
+
103
+ Task `dependencies` remain local to one Job and must contain only Task IDs from that Job. Shared runtime/tool/platform blockers use a separate durable cross-Job relation. The blocker Job owns reverse `unblocks` records identifying dependent repository, PR, Job, Task, and blocked attempt. The dependent Task may carry a lightweight `blockedBy` reference for observability and deduplication.
104
+
105
+ Blocker resolution is generation-safe and idempotent: Relay retries a dependent only when it is still FAILED/BLOCKED on the recorded attempt and still points at that blocker. Otherwise the relation becomes obsolete. Merge-mode blockers resolve when the blocker PR is observed merged. Blockers requiring package publication, deployment, runtime health, human/external verification, or another post-merge fact use verified mode and resolve only through the explicit verified-resolution path. Existing failure evidence is preserved; retry uses the normal FAILED/BLOCKED -> QUEUED lifecycle transition.
106
+
101
107
  ## Worker lifecycle
102
108
 
103
109
  Relay owns process lifecycle. Workers own semantic task lifecycle. A worker must publish canonical `task.started` as its first execution action, then exactly one of `task.completed`, `task.failed`, `task.blocked`, or `task.cancelled`. Process launch/exit events do not substitute for worker semantic events.