agents-relay 1.0.99 → 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.
- package/dist/blockers.js +113 -0
- package/dist/cli-blocker.js +44 -0
- package/dist/cli-help.js +19 -1
- package/dist/cli.js +7 -0
- package/dist/reconciler.js +2 -0
- package/dist/store-github.js +4 -0
- package/package.json +1 -1
- package/skills/agents-relay/agents/troubleshooter.agent.md +3 -3
- package/skills/agents-relay/contracts/task-job-contract.md +6 -0
package/dist/blockers.js
ADDED
|
@@ -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: [] };
|
package/dist/reconciler.js
CHANGED
|
@@ -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')
|
package/dist/store-github.js
CHANGED
|
@@ -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
|
@@ -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,
|
|
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.
|
|
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
|
|
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.
|