agents-relay 1.0.177 → 2.0.1

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.
Files changed (66) hide show
  1. package/README.md +4 -2
  2. package/dist/adapters.js +26 -2
  3. package/dist/blockers.js +60 -65
  4. package/dist/cli-support.js +33 -1
  5. package/dist/cli.js +72 -86
  6. package/dist/dashboard-actions.js +13 -35
  7. package/dist/dashboard-html.js +1 -1
  8. package/dist/dashboard.js +39 -0
  9. package/dist/execution-recovery.js +37 -0
  10. package/dist/graph-runtime.js +1 -0
  11. package/dist/job-actions.js +53 -0
  12. package/dist/manual-plan.js +15 -17
  13. package/dist/planner-runtime.js +135 -0
  14. package/dist/planner.js +76 -60
  15. package/dist/pool.js +54 -10
  16. package/dist/projected-store.js +314 -0
  17. package/dist/reconciler-review.js +185 -110
  18. package/dist/reconciler-runtime.js +67 -7
  19. package/dist/reconciler.js +134 -282
  20. package/dist/relay-store.js +380 -0
  21. package/dist/relayd-main.js +6 -0
  22. package/dist/relayd.js +122 -29
  23. package/dist/runtime-status.js +2 -1
  24. package/dist/scheduled-jobs.js +40 -31
  25. package/dist/scheduler.js +19 -18
  26. package/dist/store.js +1 -1
  27. package/dist/task-actions.js +139 -0
  28. package/dist/troubleshooter.js +14 -8
  29. package/dist/types.js +3 -3
  30. package/dist/unblock.js +31 -21
  31. package/dist/workflow/authoritative-unblock-v2.js +20 -0
  32. package/dist/workflow/blocker-resolution-v2.js +33 -0
  33. package/dist/workflow/contracts.js +21 -0
  34. package/dist/workflow/definitions-v2.js +75 -0
  35. package/dist/workflow/dispatcher.js +56 -0
  36. package/dist/workflow/entity-creation.js +32 -0
  37. package/dist/workflow/graph-v2.js +391 -0
  38. package/dist/workflow/job-reconcile-v2.js +19 -0
  39. package/dist/workflow/lifecycle.js +82 -4
  40. package/dist/workflow/planner-orchestration-v2.js +58 -0
  41. package/dist/workflow/planner-trigger-v2.js +24 -0
  42. package/dist/workflow/review-v2.js +42 -0
  43. package/dist/workflow/runtime.js +15 -1
  44. package/dist/workflow/schedule-cycle-v2.js +25 -0
  45. package/dist/workflow/scheduled-run-v2.js +30 -0
  46. package/dist/workflow/task-action-policy.js +19 -0
  47. package/dist/workflow/task-reconcile.js +6 -6
  48. package/dist/workflow/task-related-action-v2.js +42 -0
  49. package/dist/workflow/troubleshooter-v2.js +29 -0
  50. package/dist/workspace.js +6 -1
  51. package/package.json +3 -3
  52. package/skills/agents-relay/agents/planner.agent.md +5 -5
  53. package/skills/agents-relay/contracts/task-job-contract.md +2 -51
  54. package/workflow/authoritative-unblock-v2.mmd +15 -0
  55. package/workflow/blocker-resolution-v2.mmd +73 -0
  56. package/workflow/job-lifecycle-v2.mmd +70 -0
  57. package/workflow/job-lifecycle.mmd +2 -2
  58. package/workflow/planner-orchestration-v2.mmd +65 -0
  59. package/workflow/planner-trigger-v2.mmd +32 -0
  60. package/workflow/review-lifecycle-v2.mmd +124 -0
  61. package/workflow/schedule-cycle-v2.mmd +19 -0
  62. package/workflow/scheduled-run-v2.mmd +55 -0
  63. package/workflow/task-lifecycle-v2.mmd +201 -0
  64. package/workflow/task-lifecycle.mmd +3 -0
  65. package/workflow/task-related-action-v2.mmd +28 -0
  66. package/workflow/troubleshooter-v2.mmd +43 -0
package/dist/pool.js CHANGED
@@ -1,5 +1,7 @@
1
1
  import { GitHubStore, listManagedGitHubJobs, withGitHubRequestContext } from './store.js';
2
2
  import { eventFor } from './events.js';
3
+ import { runScheduledBucket } from './scheduled-jobs.js';
4
+ import { scheduleBucket } from './scheduler.js';
3
5
  import { ReconcileCoordinator } from './reconcile-coordinator.js';
4
6
  const RANK = { P0: 0, P1: 1, P2: 2, P3: 3 };
5
7
  export function compareJobPriority(a, b) { return RANK[a.priority ?? 'P2'] - RANK[b.priority ?? 'P2'] || a.createdAt.localeCompare(b.createdAt) || a.id.localeCompare(b.id); }
@@ -25,27 +27,31 @@ export class RepositoryWorkerPool {
25
27
  makeReconciler;
26
28
  isRateLimited;
27
29
  refreshWebhookSnapshot;
30
+ makeStore;
28
31
  running = false;
29
32
  pending = false;
30
33
  reconcileLocks = new Map();
31
34
  coordinator;
32
35
  githubSignalCoordinator;
36
+ scheduleSignalCoordinator;
33
37
  watchedBus;
34
38
  jobTargets = new Map();
35
39
  targetSeeds = new Map();
36
- constructor(client, repository, trustedAuthors, concurrency, makeReconciler, isRateLimited = () => false, refreshWebhookSnapshot) {
40
+ constructor(client, repository, trustedAuthors, concurrency, makeReconciler, isRateLimited = () => false, refreshWebhookSnapshot, makeStore = (repository, prNumber, seed) => new GitHubStore(this.client, repository, prNumber, this.trustedAuthors, seed)) {
37
41
  this.client = client;
38
42
  this.trustedAuthors = trustedAuthors;
39
43
  this.concurrency = concurrency;
40
44
  this.makeReconciler = makeReconciler;
41
45
  this.isRateLimited = isRateLimited;
42
46
  this.refreshWebhookSnapshot = refreshWebhookSnapshot;
47
+ this.makeStore = makeStore;
43
48
  this.repositories = [...new Set(typeof repository === 'string' ? [repository] : repository)];
44
49
  const reportCoordinatorError = (error, signal) => {
45
50
  process.stderr.write(`warning: reconcile coordinator failed for ${signal.jobId} (${signal.reason}): ${error instanceof Error ? error.message : String(error)}\n`);
46
51
  };
47
52
  this.coordinator = new ReconcileCoordinator((jobId, signals) => this.consumeSignals(jobId, signals), reportCoordinatorError);
48
53
  this.githubSignalCoordinator = new ReconcileCoordinator((_key, signals) => this.consumeGitHubSignals(signals), reportCoordinatorError);
54
+ this.scheduleSignalCoordinator = new ReconcileCoordinator((jobId, signals) => this.consumeScheduleSignals(jobId, signals), reportCoordinatorError);
49
55
  }
50
56
  repositories;
51
57
  async reconcileExclusive(jobId, work) {
@@ -71,6 +77,21 @@ export class RepositoryWorkerPool {
71
77
  }
72
78
  return `event.${event.type}`;
73
79
  }
80
+ async consumeScheduleSignals(jobId, signals) {
81
+ if (this.isRateLimited() || signals.length === 0)
82
+ return;
83
+ const signal = [...signals].reverse().find(item => item.event?.type === 'schedule.tick');
84
+ const event = signal?.event;
85
+ const repository = signal?.repository ?? (typeof event?.data?.repository === 'string' ? event.data.repository : undefined);
86
+ const prValue = signal?.prNumber ?? Number(event?.data?.prNumber);
87
+ const prNumber = Number.isInteger(prValue) && Number(prValue) > 0 ? Number(prValue) : undefined;
88
+ const bucket = typeof event?.data?.bucket === 'string' ? event.data.bucket : undefined;
89
+ const tickAt = typeof event?.data?.tickAt === 'string' ? new Date(event.data.tickAt) : new Date(event?.timestamp ?? Date.now());
90
+ if (!repository || !prNumber || !bucket || !this.repositories.includes(repository) || Number.isNaN(tickAt.getTime()))
91
+ return;
92
+ this.jobTargets.set(jobId, { repository, prNumber });
93
+ await runScheduledBucket(this.client, repository, this.trustedAuthors, { prNumber, jobId, bucket }, { reconcile: async () => { await this.reconcileTarget(repository, prNumber, jobId, 'schedule.tick'); }, idle: async () => { } }, tickAt, () => this.makeStore(repository, prNumber));
94
+ }
74
95
  async consumeGitHubSignals(signals) {
75
96
  if (this.isRateLimited() || signals.length === 0)
76
97
  return;
@@ -111,8 +132,6 @@ export class RepositoryWorkerPool {
111
132
  }
112
133
  }
113
134
  async consumeSignals(jobId, signals) {
114
- if (this.isRateLimited())
115
- return;
116
135
  for (const signal of signals) {
117
136
  if (signal.repository && signal.prNumber && this.repositories.includes(signal.repository)) {
118
137
  this.jobTargets.set(jobId, { repository: signal.repository, prNumber: signal.prNumber });
@@ -128,7 +147,7 @@ export class RepositoryWorkerPool {
128
147
  const seed = mayConsumeSeed ? this.targetSeeds.get(seedKey) ?? null : null;
129
148
  if (mayConsumeSeed)
130
149
  this.targetSeeds.delete(seedKey);
131
- const store = new GitHubStore(this.client, target.repository, target.prNumber, this.trustedAuthors, seed);
150
+ const store = this.makeStore(target.repository, target.prNumber, seed);
132
151
  const reconciler = this.makeReconciler(store, Math.max(1, this.concurrency));
133
152
  let shouldReconcile = false;
134
153
  const reasons = [];
@@ -155,7 +174,8 @@ export class RepositoryWorkerPool {
155
174
  // OPEN-only runnable filter can strand durable jobs after GitHub merges/closes them.
156
175
  for (const job of terminalManagedJobs(discovered)) {
157
176
  await this.reconcileExclusive(job.id, async () => {
158
- const store = new GitHubStore(this.client, job.repository, job.prNumber, this.trustedAuthors);
177
+ const store = this.makeStore(job.repository, job.prNumber);
178
+ await store.hydrate?.(job);
159
179
  await withGitHubRequestContext(this.client, 'pool.terminal-reconcile', () => this.makeReconciler(store, 0).reconcile(job.id));
160
180
  });
161
181
  }
@@ -164,7 +184,8 @@ export class RepositoryWorkerPool {
164
184
  for (const job of jobs) {
165
185
  if (capacity <= 0)
166
186
  break;
167
- const store = new GitHubStore(this.client, job.repository, job.prNumber, this.trustedAuthors);
187
+ const store = this.makeStore(job.repository, job.prNumber);
188
+ await store.hydrate?.(job);
168
189
  const ready = job.tasks.filter(task => (task.state === 'QUEUED' && task.executionId === null) || (task.state === 'DRAFT' && task.dependencies.length > 0)).length;
169
190
  const expiredRunning = job.tasks.some(task => ['RUNNING', 'QUEUED'].includes(task.state) && task.executionId !== null && task.leaseExpiresAt !== null && new Date(task.leaseExpiresAt).getTime() <= Date.now());
170
191
  if (!needsLifecycleReconciliation(job))
@@ -177,16 +198,24 @@ export class RepositoryWorkerPool {
177
198
  }
178
199
  }
179
200
  async reconcileKnown(discovered) {
180
- for (const item of discovered.filter(value => value.githubState === 'OPEN' && value.job.state === 'ACTIVE')) {
201
+ const recovered = [];
202
+ for (const item of discovered) {
203
+ if (item.githubState !== 'OPEN' || item.job.state !== 'ACTIVE') {
204
+ recovered.push(item);
205
+ continue;
206
+ }
181
207
  this.jobTargets.set(item.job.id, { repository: item.job.repository, prNumber: item.job.prNumber });
182
- const store = new GitHubStore(this.client, item.job.repository, item.job.prNumber, this.trustedAuthors);
208
+ const store = this.makeStore(item.job.repository, item.job.prNumber);
209
+ await store.hydrate?.(item.job);
183
210
  const reconciler = this.makeReconciler(store, 0);
211
+ let recoveredJob = item.job;
184
212
  if (typeof reconciler.recoverStartupOrphans === 'function')
185
213
  await this.reconcileExclusive(item.job.id, async () => {
186
- await withGitHubRequestContext(this.client, 'pool.startup-orphan-recovery', () => reconciler.recoverStartupOrphans(item.job.id));
214
+ recoveredJob = await withGitHubRequestContext(this.client, 'pool.startup-orphan-recovery', () => reconciler.recoverStartupOrphans(item.job.id));
187
215
  });
216
+ recovered.push({ ...item, job: recoveredJob });
188
217
  }
189
- await this.reconcileDiscovered(discovered);
218
+ await this.reconcileDiscovered(recovered);
190
219
  }
191
220
  async reconcile() {
192
221
  if (this.isRateLimited())
@@ -225,6 +254,10 @@ export class RepositoryWorkerPool {
225
254
  const prNumber = Number.isInteger(prValue) && prValue > 0 ? prValue : undefined;
226
255
  const routedWebhook = event.type === 'github.webhook' && event.data?.coordinatorRouted === true;
227
256
  const ingressWebhook = event.type === 'github.webhook' && event.data?.coordinatorIngress === true && !routedWebhook;
257
+ if (event.type === 'schedule.tick' && repository && prNumber) {
258
+ await this.scheduleSignalCoordinator.acceptTransport({ jobId: event.job_id, reason: this.signalReason(event), event, repository, prNumber });
259
+ return;
260
+ }
228
261
  if (ingressWebhook && repository && prNumber) {
229
262
  await this.githubSignalCoordinator.acceptTransport({
230
263
  jobId: `${repository}#${prNumber}`,
@@ -257,6 +290,17 @@ export class RepositoryWorkerPool {
257
290
  this.watchedBus = undefined;
258
291
  };
259
292
  }
293
+ async scheduleTickSignal(bus, job, now = new Date()) {
294
+ if (!job.schedule)
295
+ return;
296
+ const event = eventFor(job.id, 'scheduler', null, 'schedule.tick', 'queued', `Schedule tick for ${job.schedule.nextRunAt}`, 'orchestrator', { repository: job.repository, prNumber: job.prNumber, bucket: scheduleBucket(new Date(job.schedule.nextRunAt)), tickAt: now.toISOString() }, { agent: 'relay', component: 'scheduler-timer' });
297
+ // Use the same coordinator-owned schedule path whether transport is available or not.
298
+ if (bus) {
299
+ await bus.publish(event);
300
+ return;
301
+ }
302
+ await this.scheduleSignalCoordinator.accept({ jobId: job.id, reason: this.signalReason(event), event, repository: job.repository, prNumber: job.prNumber });
303
+ }
260
304
  async webhookSignal(bus, wake) {
261
305
  if (!wake.repository || !wake.pullRequest)
262
306
  return;
@@ -0,0 +1,314 @@
1
+ import { registerBlockerDependency, resolveBlockerDependencies } from './blockers.js';
2
+ import { sameTaskDefinition } from './store-task-definition.js';
3
+ function taskKey(task) { return `task:${task.jobId ?? ''}:${task.id}`; }
4
+ function planKey(plan) { return `plan:${plan.jobId}:${plan.id}`; }
5
+ function retryHeader(error, name) {
6
+ if (!error || typeof error !== 'object')
7
+ return null;
8
+ const value = error;
9
+ if (name === 'retry-after' && value.retryAfter !== undefined)
10
+ return String(value.retryAfter);
11
+ const headers = value.response?.headers ?? value.headers;
12
+ if (!headers)
13
+ return null;
14
+ if (typeof headers.get === 'function')
15
+ return headers.get(name);
16
+ const record = headers;
17
+ const hit = Object.entries(record).find(([key]) => key.toLowerCase() === name);
18
+ return hit && hit[1] !== undefined ? String(hit[1]) : null;
19
+ }
20
+ export function githubProjectionRetryAt(error, attempt, now = new Date()) {
21
+ const retryAfter = Number(retryHeader(error, 'retry-after'));
22
+ if (Number.isFinite(retryAfter) && retryAfter > 0)
23
+ return new Date(now.getTime() + retryAfter * 1000);
24
+ const reset = Number(retryHeader(error, 'x-ratelimit-reset'));
25
+ if (Number.isFinite(reset) && reset > now.getTime() / 1000)
26
+ return new Date(reset * 1000 + 1000);
27
+ const backoffMs = Math.min(300_000, 1_000 * (2 ** Math.min(8, Math.max(0, attempt))));
28
+ return new Date(now.getTime() + backoffMs);
29
+ }
30
+ export function isTerminalProjectionFailure(item, error) {
31
+ const message = error instanceof Error ? error.message : String(error);
32
+ return (item.operation === 'append_task' || item.operation === 'append_plan')
33
+ && /already exists with a different definition$/.test(message);
34
+ }
35
+ export class GitHubProjectionWriter {
36
+ relay;
37
+ github;
38
+ constructor(relay, github) {
39
+ this.relay = relay;
40
+ this.github = github;
41
+ }
42
+ async flushOnce(limit = 50, now = new Date()) {
43
+ const items = await this.relay.claimDueProjections(limit, now);
44
+ let projected = 0;
45
+ let deferred = 0;
46
+ for (const item of items) {
47
+ try {
48
+ await this.apply(item);
49
+ await this.relay.completeProjection(item.id);
50
+ projected += 1;
51
+ }
52
+ catch (error) {
53
+ const message = error instanceof Error ? error.message : String(error);
54
+ if (isTerminalProjectionFailure(item, error))
55
+ await this.relay.deadLetterProjection(item.id, message);
56
+ else {
57
+ await this.relay.retryProjection(item.id, message, githubProjectionRetryAt(error, item.attempts, now));
58
+ deferred += 1;
59
+ }
60
+ }
61
+ }
62
+ return { projected, deferred };
63
+ }
64
+ async apply(item) {
65
+ const payload = item.payload;
66
+ switch (payload.operation) {
67
+ case 'save_job':
68
+ await this.github.saveJob(payload.value);
69
+ return;
70
+ case 'append_plan':
71
+ await this.github.appendPlan(payload.value);
72
+ return;
73
+ case 'save_plan':
74
+ await this.github.savePlan(payload.value);
75
+ return;
76
+ case 'append_task':
77
+ await this.github.appendTask(payload.value);
78
+ return;
79
+ case 'save_task':
80
+ await this.github.saveTask(payload.value);
81
+ return;
82
+ case 'save_agent':
83
+ await this.github.saveAgent(payload.value);
84
+ return;
85
+ case 'update_pr_body':
86
+ if (!this.github.updatePullRequestBody)
87
+ throw new Error('GitHub projection target cannot update PR body');
88
+ await this.github.updatePullRequestBody(payload.value);
89
+ return;
90
+ case 'merge_pull_request':
91
+ if (!this.github.mergePullRequest)
92
+ throw new Error('GitHub projection target cannot merge pull request');
93
+ await this.github.mergePullRequest(payload.expectedHeadSha);
94
+ return;
95
+ case 'close_pull_request':
96
+ if (!this.github.closePullRequest)
97
+ throw new Error('GitHub projection target cannot close pull request');
98
+ await this.github.closePullRequest();
99
+ return;
100
+ }
101
+ }
102
+ }
103
+ export class ProjectedDurableStore {
104
+ relay;
105
+ github;
106
+ relatedStore;
107
+ projection;
108
+ seededFacts;
109
+ constructor(relay, github, seed = null, relatedStore) {
110
+ this.relay = relay;
111
+ this.github = github;
112
+ this.relatedStore = relatedStore;
113
+ this.projection = new GitHubProjectionWriter(relay, github);
114
+ this.seededFacts = pullRequestFactsFromSeed(seed);
115
+ if (this.seededFacts)
116
+ void this.relay.savePullRequestFacts(this.seededFacts).catch(() => { });
117
+ }
118
+ /** Ingest trusted GitHub records without echoing them back through the projection outbox. */
119
+ async hydrate(job) {
120
+ await this.relay.ingestJobSnapshot(structuredClone(job));
121
+ }
122
+ async load(jobId) {
123
+ const local = await this.relay.loadJob(jobId);
124
+ if (local)
125
+ return structuredClone(local);
126
+ const remote = await this.github.load(jobId);
127
+ await this.relay.saveJob(remote);
128
+ return structuredClone(remote);
129
+ }
130
+ async saveJob(job) {
131
+ const value = structuredClone(job);
132
+ await this.relay.saveJobWithProjection(value, {
133
+ idempotencyKey: `save_job:${job.id}`,
134
+ coalesceKey: `job:${job.id}`,
135
+ operation: 'save_job',
136
+ payload: { operation: 'save_job', value },
137
+ priority: ['COMPLETED', 'CANCELLED'].includes(job.state) ? 100 : 20,
138
+ });
139
+ }
140
+ async appendPlan(plan) {
141
+ const job = await this.load(plan.jobId);
142
+ const existing = (job.plans ?? []).find(item => item.id === plan.id);
143
+ if (existing) {
144
+ if (JSON.stringify({ ...existing, planUrl: undefined }) === JSON.stringify({ ...plan, planUrl: undefined }))
145
+ return;
146
+ throw new Error(`Plan ${plan.id} already exists with a different definition`);
147
+ }
148
+ job.plans = [...(job.plans ?? []), structuredClone(plan)].sort((a, b) => a.round - b.round);
149
+ await this.relay.saveJobWithProjection(job, {
150
+ idempotencyKey: `append_plan:${plan.jobId}:${plan.id}`,
151
+ operation: 'append_plan',
152
+ payload: { operation: 'append_plan', value: plan },
153
+ priority: 30,
154
+ });
155
+ }
156
+ async savePlan(plan) {
157
+ const job = await this.load(plan.jobId);
158
+ const plans = [...(job.plans ?? [])];
159
+ const index = plans.findIndex(item => item.id === plan.id);
160
+ if (index >= 0)
161
+ plans[index] = structuredClone(plan);
162
+ else
163
+ plans.push(structuredClone(plan));
164
+ job.plans = plans.sort((a, b) => a.round - b.round);
165
+ await this.relay.saveJobWithProjection(job, {
166
+ idempotencyKey: `save_plan:${plan.jobId}:${plan.id}`,
167
+ coalesceKey: planKey(plan),
168
+ operation: 'save_plan',
169
+ payload: { operation: 'save_plan', value: plan },
170
+ priority: 30,
171
+ });
172
+ }
173
+ async appendTask(task) {
174
+ if (!task.jobId)
175
+ throw new Error('Projected task requires jobId');
176
+ const job = await this.load(task.jobId);
177
+ const existing = job.tasks.find(item => item.id === task.id);
178
+ if (existing) {
179
+ if (sameTaskDefinition(existing, task))
180
+ return;
181
+ throw new Error(`Task ${task.id} already exists with a different definition`);
182
+ }
183
+ job.tasks = [...job.tasks, structuredClone(task)];
184
+ await this.relay.saveJobWithProjection(job, {
185
+ idempotencyKey: `append_task:${task.jobId}:${task.id}`,
186
+ operation: 'append_task',
187
+ payload: { operation: 'append_task', value: task },
188
+ priority: 40,
189
+ });
190
+ }
191
+ async saveTask(task) {
192
+ if (!task.jobId)
193
+ throw new Error('Projected task requires jobId');
194
+ const job = await this.load(task.jobId);
195
+ const tasks = [...job.tasks];
196
+ const index = tasks.findIndex(item => item.id === task.id);
197
+ if (index >= 0)
198
+ tasks[index] = structuredClone(task);
199
+ else
200
+ tasks.push(structuredClone(task));
201
+ job.tasks = tasks;
202
+ const value = structuredClone(task);
203
+ const terminal = ['SUCCEEDED', 'FAILED', 'BLOCKED', 'UNBLOCKED', 'CANCELLED'].includes(task.state);
204
+ await this.relay.saveJobWithProjection(job, {
205
+ idempotencyKey: `save_task:${task.jobId}:${task.id}`,
206
+ coalesceKey: taskKey(task),
207
+ operation: 'save_task',
208
+ payload: { operation: 'save_task', value },
209
+ priority: terminal ? 100 : 50,
210
+ });
211
+ }
212
+ async listAgents() {
213
+ const local = await this.relay.loadAgents();
214
+ if (local !== null)
215
+ return structuredClone(local);
216
+ const remote = await this.github.listAgents();
217
+ await this.relay.saveAgents(remote);
218
+ return structuredClone(remote);
219
+ }
220
+ async saveAgent(agent) {
221
+ if (await this.relay.loadAgents() === null) {
222
+ await this.relay.saveAgents(await this.github.listAgents());
223
+ }
224
+ const value = structuredClone(agent);
225
+ await this.relay.saveAgentWithProjection(value, {
226
+ idempotencyKey: `save_agent:${agent.id}`,
227
+ coalesceKey: `agent:${agent.id}`,
228
+ operation: 'save_agent',
229
+ payload: { operation: 'save_agent', value },
230
+ priority: 10,
231
+ });
232
+ }
233
+ async comments() { return this.github.comments(); }
234
+ async pullRequestState() {
235
+ return (await this.ensurePullRequestFacts()).state;
236
+ }
237
+ async pullRequestHead() {
238
+ return (await this.ensurePullRequestFacts()).head;
239
+ }
240
+ async headSha() { return (await this.pullRequestHead())?.sha ?? null; }
241
+ async mergePullRequest(expectedHeadSha) {
242
+ await this.relay.enqueueProjection({
243
+ idempotencyKey: `merge_pull_request:${expectedHeadSha ?? 'current'}`,
244
+ operation: 'merge_pull_request',
245
+ payload: { operation: 'merge_pull_request', expectedHeadSha },
246
+ priority: 200,
247
+ });
248
+ }
249
+ async closePullRequest() {
250
+ await this.relay.enqueueProjection({
251
+ idempotencyKey: 'close_pull_request',
252
+ operation: 'close_pull_request',
253
+ payload: { operation: 'close_pull_request' },
254
+ priority: 200,
255
+ });
256
+ }
257
+ async updatePullRequestBody(body) {
258
+ await this.relay.enqueueProjection({
259
+ idempotencyKey: 'update_pr_body',
260
+ coalesceKey: 'pr_body',
261
+ operation: 'update_pr_body',
262
+ payload: { operation: 'update_pr_body', value: body },
263
+ priority: 80,
264
+ });
265
+ }
266
+ async registerBlockerDependent(jobId, dependent) {
267
+ if (!this.relatedStore)
268
+ throw new Error('Projected store requires a related-store factory for blocker dependencies');
269
+ await registerBlockerDependency({
270
+ blockerStore: this,
271
+ blockerJobId: jobId,
272
+ dependentStore: this.relatedStore(dependent.repository, dependent.prNumber),
273
+ dependentJobId: dependent.jobId,
274
+ dependentTaskId: dependent.taskId,
275
+ resolutionMode: dependent.resolutionMode,
276
+ });
277
+ }
278
+ async resolveBlockerDependents(jobId, resolution) {
279
+ if (!this.relatedStore)
280
+ throw new Error('Projected store requires a related-store factory for blocker dependencies');
281
+ return resolveBlockerDependencies({
282
+ blockerStore: this,
283
+ blockerJobId: jobId,
284
+ resolution,
285
+ dependentStore: (repository, prNumber) => this.relatedStore(repository, prNumber),
286
+ });
287
+ }
288
+ async ensurePullRequestFacts() {
289
+ const local = await localPullRequestFacts(this.relay, this.seededFacts);
290
+ if (local)
291
+ return local;
292
+ if (!this.github.pullRequestState || !this.github.pullRequestHead)
293
+ throw new Error('Pull request facts have not been observed locally');
294
+ const [state, head] = await Promise.all([this.github.pullRequestState(), this.github.pullRequestHead()]);
295
+ const facts = { state, head, observedAt: new Date().toISOString() };
296
+ await this.relay.savePullRequestFacts(facts);
297
+ return facts;
298
+ }
299
+ }
300
+ function pullRequestFactsFromSeed(seed) {
301
+ const pr = seed?.prNode;
302
+ if (!pr || Object.keys(pr).length === 0)
303
+ return null;
304
+ const rawState = typeof pr.state === 'string' ? pr.state.toUpperCase() : '';
305
+ const state = pr.merged === true || rawState === 'MERGED' ? 'MERGED' : rawState === 'CLOSED' ? 'CLOSED' : rawState === 'OPEN' ? 'OPEN' : null;
306
+ if (!state)
307
+ return null;
308
+ const ref = typeof pr.headRefName === 'string' ? pr.headRefName : '';
309
+ const sha = typeof pr.headRefOid === 'string' ? pr.headRefOid : '';
310
+ return { state, head: ref && sha ? { ref, sha } : null, observedAt: new Date().toISOString() };
311
+ }
312
+ async function localPullRequestFacts(relay, seededFacts) {
313
+ return await relay.loadPullRequestFacts() ?? seededFacts;
314
+ }