agents-relay 2.0.67 → 2.0.69

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 (72) hide show
  1. package/README.md +56 -20
  2. package/dist/adapters.js +39 -3
  3. package/dist/blockers.js +17 -4
  4. package/dist/cli-blocker.js +12 -30
  5. package/dist/cli-help.js +22 -17
  6. package/dist/cli-job-create.js +77 -0
  7. package/dist/cli-local-merge-config.js +11 -0
  8. package/dist/cli-projection.js +23 -55
  9. package/dist/cli-review.js +7 -24
  10. package/dist/cli-support.js +81 -8
  11. package/dist/cli-worker-task.js +63 -0
  12. package/dist/cli.js +223 -185
  13. package/dist/dashboard-html.js +15 -12
  14. package/dist/dashboard.js +27 -2
  15. package/dist/e2e-injection.js +76 -0
  16. package/dist/github-auth.js +4 -3
  17. package/dist/github-binding-order.js +15 -0
  18. package/dist/github-client.js +7 -2
  19. package/dist/github-pr-archive.js +22 -0
  20. package/dist/github-webhook.js +43 -10
  21. package/dist/job-actions.js +29 -0
  22. package/dist/local-authority-store.js +288 -0
  23. package/dist/local-commit-events.js +2 -0
  24. package/dist/local-git-merge.js +124 -0
  25. package/dist/local-job-path.js +7 -0
  26. package/dist/local-job-registry.js +29 -0
  27. package/dist/local-managed-store.js +21 -0
  28. package/dist/local-merge-delivery.js +53 -0
  29. package/dist/local-merge-lock.js +103 -0
  30. package/dist/local-merge-projection.js +60 -0
  31. package/dist/managed-worker-input.js +13 -6
  32. package/dist/manual-plan.js +29 -7
  33. package/dist/no-github.js +11 -0
  34. package/dist/planned-review-head.js +7 -8
  35. package/dist/planner-runtime.js +182 -41
  36. package/dist/planner.js +35 -2
  37. package/dist/pool.js +89 -136
  38. package/dist/projected-store.js +20 -3
  39. package/dist/projection-outbox.js +3 -1
  40. package/dist/publication-store.js +37 -0
  41. package/dist/reconciler-cancellation.js +21 -0
  42. package/dist/reconciler-review.js +99 -29
  43. package/dist/reconciler-runtime.js +72 -35
  44. package/dist/reconciler.js +124 -85
  45. package/dist/relay-store.js +108 -6
  46. package/dist/relayd-control.js +184 -0
  47. package/dist/relayd-dashboard-actions.js +108 -0
  48. package/dist/relayd-utilities.js +107 -0
  49. package/dist/relayd.js +304 -299
  50. package/dist/review-generation-fence.js +12 -0
  51. package/dist/runtime-status-events.js +21 -0
  52. package/dist/runtime-status.js +21 -7
  53. package/dist/scheduled-jobs.js +43 -36
  54. package/dist/service-external-actions.js +27 -0
  55. package/dist/service-publication.js +29 -0
  56. package/dist/store-github.js +17 -6
  57. package/dist/task-actions.js +59 -1
  58. package/dist/task-update.js +1 -1
  59. package/dist/troubleshooter.js +21 -2
  60. package/dist/unblock.js +47 -15
  61. package/dist/worker-rejection.js +11 -0
  62. package/dist/workflow/contracts.js +10 -1
  63. package/dist/workflow/definitions-v2.js +2 -0
  64. package/dist/workflow/job-reconcile-v2.js +9 -60
  65. package/dist/workflow/runtime.js +18 -3
  66. package/dist/workflow-effect-failures.js +30 -0
  67. package/dist/workspace.js +95 -6
  68. package/package.json +6 -3
  69. package/skills/agents-relay/agents/planner.agent.md +3 -0
  70. package/skills/agents-relay/contracts/orchestrator-workflows.md +4 -7
  71. package/workflow/job-lifecycle.mmd +19 -60
  72. package/workflow/review-lifecycle.mmd +5 -8
@@ -0,0 +1,288 @@
1
+ import { GitHubProjectionWriter } from './projected-store.js';
2
+ import { registerBlockerDependency, resolveBlockerDependencies } from './blockers.js';
3
+ import { samePlanDefinition, sameTaskDefinition, DurableDefinitionConflictError, planDefinitionDivergence, taskDefinitionDivergence } from './store-task-definition.js';
4
+ import { mergeInboundJobSnapshot } from './relay-store.js';
5
+ import { publishLocalCommit } from './local-commit-events.js';
6
+ import { decideScheduledRun } from './workflow/scheduled-run-v2.js';
7
+ import { nextScheduleRun, scheduleBucket } from './scheduler.js';
8
+ /**
9
+ * V2 authoritative lifecycle boundary.
10
+ *
11
+ * Job, Plan, Task and Agent reads/writes are SQLite-only. No GitHub marker,
12
+ * remote snapshot, or projection result can create, recover, or mutate them.
13
+ * Inherited PR merge/close/head interfaces are external collaboration adapters,
14
+ * not lifecycle storage. An explicit one-time import must use RelayStore directly.
15
+ */
16
+ const TASK_STATE_ORDER = {
17
+ DRAFT: 0, QUEUED: 1, UNBLOCKED: 2, RUNNING: 3, BLOCKED: 4,
18
+ SUCCEEDED: 5, FAILED: 5, CANCELLED: 5,
19
+ };
20
+ export class LocalAuthorityDurableStore {
21
+ authority;
22
+ relatedStore;
23
+ noGitHub;
24
+ projection;
25
+ constructor(authority, github, relatedStore, noGitHub = false) {
26
+ this.authority = authority;
27
+ this.relatedStore = relatedStore;
28
+ this.noGitHub = noGitHub;
29
+ this.projection = new GitHubProjectionWriter(authority, github, undefined, true);
30
+ }
31
+ /** Queue one retryable PR merge; do not mutate Job lifecycle until external confirmation. */
32
+ async mergePullRequest(expectedHeadSha) {
33
+ if (this.noGitHub)
34
+ throw new Error('Remote merge unavailable with --no-github; local Graph completion is separate');
35
+ if (!expectedHeadSha)
36
+ throw new Error('External merge requires immutable expected head SHA');
37
+ const facts = await this.authority.loadPullRequestFacts();
38
+ if (facts?.state === 'MERGED')
39
+ return;
40
+ if (facts?.state === 'CLOSED')
41
+ throw new Error('Cannot merge a PR already known closed');
42
+ if (!this.authority.enqueueExternalActionOnce)
43
+ throw new Error('Atomic external action queue is required');
44
+ await this.authority.enqueueExternalActionOnce({
45
+ idempotencyKey: `merge_pull_request:${expectedHeadSha}`,
46
+ operation: 'merge_pull_request',
47
+ payload: { operation: 'merge_pull_request', expectedHeadSha },
48
+ priority: 200,
49
+ });
50
+ }
51
+ async closePullRequest() {
52
+ if (this.noGitHub)
53
+ return; // Local cancel is persisted by Graph; there is no remote PR.
54
+ const facts = await this.authority.loadPullRequestFacts();
55
+ if (facts?.state === 'MERGED')
56
+ throw new Error('Cannot close a PR already merged');
57
+ if (facts?.state === 'CLOSED')
58
+ return;
59
+ if (!this.authority.enqueueExternalActionOnce)
60
+ throw new Error('Atomic external action queue is required');
61
+ await this.authority.enqueueExternalActionOnce({
62
+ idempotencyKey: 'close_pull_request',
63
+ operation: 'close_pull_request',
64
+ payload: { operation: 'close_pull_request' },
65
+ priority: 200,
66
+ });
67
+ }
68
+ async recordExecutionProcess(record) {
69
+ await this.authority.recordExecutionProcess(record);
70
+ }
71
+ async loadExecutionProcess(jobId, taskId, executionId) {
72
+ return this.authority.loadExecutionProcess(jobId, taskId, executionId);
73
+ }
74
+ async finishExecutionProcess(jobId, taskId, executionId) {
75
+ await this.authority.finishExecutionProcess(jobId, taskId, executionId);
76
+ }
77
+ async applyPullRequestFacts(facts) {
78
+ await this.authority.savePullRequestFacts(structuredClone(facts));
79
+ }
80
+ async headSha() {
81
+ return (await this.pullRequestHead())?.sha ?? null;
82
+ }
83
+ async registerBlockerDependent(jobId, dependent) {
84
+ if (!this.relatedStore)
85
+ throw new Error('Related local store factory is required for blocker dependencies');
86
+ await registerBlockerDependency({
87
+ blockerStore: this,
88
+ blockerJobId: jobId,
89
+ dependentStore: this.relatedStore(dependent.repository, dependent.prNumber),
90
+ dependentJobId: dependent.jobId,
91
+ dependentTaskId: dependent.taskId,
92
+ resolutionMode: dependent.resolutionMode,
93
+ });
94
+ }
95
+ async resolveBlockerDependents(jobId, resolution) {
96
+ if (!this.relatedStore)
97
+ throw new Error('Related local store factory is required for blocker dependencies');
98
+ return resolveBlockerDependencies({
99
+ blockerStore: this,
100
+ blockerJobId: jobId,
101
+ resolution,
102
+ dependentStore: (repository, prNumber) => this.relatedStore(repository, prNumber),
103
+ });
104
+ }
105
+ async comments() { return []; }
106
+ async pullRequestState() {
107
+ return this.noGitHub ? 'UNKNOWN' : (await this.authority.loadPullRequestFacts())?.state ?? 'UNKNOWN';
108
+ }
109
+ async pullRequestHead() {
110
+ return this.noGitHub ? null : (await this.authority.loadPullRequestFacts())?.head ?? null;
111
+ }
112
+ async hydrate(_job) {
113
+ // GitHub snapshots cannot overwrite or create authoritative lifecycle state.
114
+ }
115
+ async load(jobId) {
116
+ const job = await this.authority.loadJob(jobId);
117
+ if (!job)
118
+ throw new Error(`Local authoritative Job ${jobId} not found`);
119
+ return structuredClone(job);
120
+ }
121
+ async saveJob(job) {
122
+ const incoming = structuredClone(job);
123
+ const existing = await this.authority.loadJob(job.id);
124
+ if (!existing) {
125
+ await this.authority.saveJob(incoming);
126
+ Object.assign(job, structuredClone(incoming));
127
+ publishLocalCommit(incoming);
128
+ return;
129
+ }
130
+ if (!this.authority.mutateJob)
131
+ throw new Error('Atomic SQLite mutation is required for local lifecycle authority');
132
+ const updated = await this.authority.mutateJob(job.id, current => {
133
+ const children = mergeInboundJobSnapshot(current, incoming);
134
+ const preserveTerminal = ['COMPLETED', 'CANCELLED'].includes(current.state)
135
+ && !['COMPLETED', 'CANCELLED'].includes(incoming.state);
136
+ const review = current.review && incoming.review
137
+ && current.review.updatedAt > incoming.review.updatedAt
138
+ ? current.review : incoming.review;
139
+ // Job-level scalar fields obey snapshot freshness. Graph transitions
140
+ // may commit review evidence after a preceding effect has already saved
141
+ // the Job; review itself is merged independently by review.updatedAt.
142
+ const scalarSource = incoming.updatedAt > current.updatedAt ? incoming : current;
143
+ return {
144
+ ...current, ...scalarSource,
145
+ state: preserveTerminal ? current.state : scalarSource.state,
146
+ updatedAt: current.updatedAt > incoming.updatedAt ? current.updatedAt : incoming.updatedAt,
147
+ tasks: children.tasks,
148
+ ...(children.plans ? { plans: children.plans } : {}),
149
+ ...(review !== undefined ? { review } : {}),
150
+ };
151
+ });
152
+ Object.assign(job, structuredClone(updated));
153
+ publishLocalCommit(updated);
154
+ }
155
+ /** Claim a schedule slot under one SQLite IMMEDIATE transaction, across all processes. */
156
+ async claimScheduledSlot(jobId, bucket, now) {
157
+ if (!this.authority.mutateJob)
158
+ throw new Error('Atomic SQLite schedule claim is required');
159
+ let status = 'skipped-idempotent';
160
+ const result = await this.authority.mutateJob(jobId, current => {
161
+ const schedule = current.schedule;
162
+ if (!schedule)
163
+ throw new Error('Scheduled Job ' + jobId + ' has no schedule metadata');
164
+ const slot = new Date(schedule.nextRunAt);
165
+ const decision = decideScheduledRun({
166
+ slotDue: scheduleBucket(slot) === bucket && slot.getTime() <= now.getTime(),
167
+ slotUnclaimed: !schedule.lastScheduledSlot || new Date(schedule.lastScheduledSlot).getTime() < slot.getTime(),
168
+ jobActive: current.state === 'ACTIVE',
169
+ hasActiveTasks: current.tasks.some(task => ['QUEUED', 'RUNNING'].includes(task.state)),
170
+ concurrencySkip: schedule.concurrencyPolicy === 'skip',
171
+ concurrencyQueue: schedule.concurrencyPolicy === 'queue',
172
+ runMissed: schedule.missedRunPolicy === 'run',
173
+ });
174
+ const action = decision.accepted ? decision.actions[0]?.id : undefined;
175
+ if (action === 'schedule.skipActiveAdvance') {
176
+ schedule.lastScheduledSlot = slot.toISOString();
177
+ schedule.nextRunAt = nextScheduleRun(schedule, now).toISOString();
178
+ status = 'skipped-active';
179
+ }
180
+ else if (action === 'schedule.claimFromSlot' || action === 'schedule.claimFromNow') {
181
+ schedule.lastScheduledSlot = slot.toISOString();
182
+ schedule.currentRunSlot = slot.toISOString();
183
+ schedule.nextRunAt = nextScheduleRun(schedule, action === 'schedule.claimFromSlot' ? slot : now).toISOString();
184
+ status = 'completed';
185
+ }
186
+ if (status !== 'skipped-idempotent')
187
+ current.updatedAt = now.toISOString();
188
+ return current;
189
+ });
190
+ if (status !== 'skipped-idempotent')
191
+ publishLocalCommit(result);
192
+ return { job: result, status };
193
+ }
194
+ /** Persist completion only if this is still the authoritative claimed slot. */
195
+ async finishScheduledSlot(jobId, slot, now) {
196
+ if (!this.authority.mutateJob)
197
+ throw new Error('Atomic SQLite schedule completion is required');
198
+ const result = await this.authority.mutateJob(jobId, current => {
199
+ if (current.schedule?.currentRunSlot !== slot)
200
+ return current;
201
+ if (!current.schedule.lastRunAt || Date.parse(current.schedule.lastRunAt) < now.getTime()) {
202
+ current.schedule.lastRunAt = now.toISOString();
203
+ current.updatedAt = now.toISOString();
204
+ }
205
+ return current;
206
+ });
207
+ publishLocalCommit(result);
208
+ return result;
209
+ }
210
+ async appendPlan(plan) {
211
+ await this.mutate(plan.jobId, job => {
212
+ const existing = (job.plans ?? []).find(item => item.id === plan.id);
213
+ if (existing) {
214
+ if (samePlanDefinition(existing, plan))
215
+ return job;
216
+ throw new DurableDefinitionConflictError('plan', 'append_plan', plan.jobId, plan.id, plan, existing, planDefinitionDivergence(existing, plan));
217
+ }
218
+ job.plans = [...(job.plans ?? []), structuredClone(plan)].sort((a, b) => a.round - b.round);
219
+ return job;
220
+ });
221
+ }
222
+ async savePlan(plan) {
223
+ await this.mutate(plan.jobId, job => {
224
+ const plans = [...(job.plans ?? [])];
225
+ const index = plans.findIndex(item => item.id === plan.id);
226
+ if (index < 0)
227
+ plans.push(structuredClone(plan));
228
+ else if (plans[index].updatedAt <= plan.updatedAt)
229
+ plans[index] = structuredClone(plan);
230
+ job.plans = plans.sort((a, b) => a.round - b.round);
231
+ return job;
232
+ });
233
+ }
234
+ async appendTask(task) {
235
+ if (!task.jobId)
236
+ throw new Error('Local authoritative task requires jobId');
237
+ await this.mutate(task.jobId, job => {
238
+ const existing = job.tasks.find(item => item.id === task.id);
239
+ if (existing) {
240
+ if (sameTaskDefinition(existing, task))
241
+ return job;
242
+ throw new DurableDefinitionConflictError('task', 'append_task', task.jobId, task.id, task, existing, taskDefinitionDivergence(existing, task));
243
+ }
244
+ job.tasks.push(structuredClone(task));
245
+ return job;
246
+ });
247
+ }
248
+ async saveTask(task) {
249
+ if (!task.jobId)
250
+ throw new Error('Local authoritative task requires jobId');
251
+ await this.mutate(task.jobId, job => {
252
+ const index = job.tasks.findIndex(item => item.id === task.id);
253
+ if (index < 0)
254
+ throw new Error(`Local authoritative Task ${task.id} not found`);
255
+ const current = job.tasks[index];
256
+ if (current.attempt > task.attempt || current.updatedAt > task.updatedAt)
257
+ return job;
258
+ if (current.attempt === task.attempt && current.updatedAt === task.updatedAt
259
+ && TASK_STATE_ORDER[current.state] >= TASK_STATE_ORDER[task.state])
260
+ return job;
261
+ job.tasks[index] = structuredClone(task);
262
+ return job;
263
+ });
264
+ }
265
+ /**
266
+ * Apply all related lifecycle changes to one freshly loaded Job snapshot.
267
+ * RelayStore owns the SQLite transaction; callers must not call saveJob,
268
+ * savePlan, or saveTask from inside this callback.
269
+ */
270
+ async mutateJob(jobId, mutation) {
271
+ if (!this.authority.mutateJob)
272
+ throw new Error('Atomic SQLite mutation is required for local lifecycle authority');
273
+ const changed = await this.authority.mutateJob(jobId, mutation);
274
+ publishLocalCommit(changed);
275
+ return structuredClone(changed);
276
+ }
277
+ async mutate(jobId, mutation) {
278
+ await this.mutateJob(jobId, mutation);
279
+ }
280
+ async listAgents() {
281
+ return structuredClone(await this.authority.loadAgents() ?? []);
282
+ }
283
+ async saveAgent(agent) {
284
+ if (!this.authority.upsertAgent)
285
+ throw new Error('Atomic SQLite agent upsert is required');
286
+ await this.authority.upsertAgent(structuredClone(agent));
287
+ }
288
+ }
@@ -1,6 +1,8 @@
1
1
  const listeners = new Set();
2
2
  /** Notify dashboard listeners only after authoritative SQLite state is committed. */
3
3
  export function publishLocalCommit(job) {
4
+ // Notify after commit. A listener failure may be surfaced to the caller,
5
+ // but it can never roll back the already-committed SQLite snapshot.
4
6
  for (const listener of listeners)
5
7
  listener(structuredClone(job));
6
8
  }
@@ -0,0 +1,124 @@
1
+ import { execFileSync } from 'node:child_process';
2
+ import { withLocalMergeLock } from './local-merge-lock.js';
3
+ import { resolve } from 'node:path';
4
+ import { existsSync, readFileSync } from 'node:fs';
5
+ import { implementationComplete } from './runtime-status.js';
6
+ import { completionGateSatisfied } from './scheduler.js';
7
+ function git(cwd, ...args) {
8
+ return execFileSync('git', ['-C', cwd, ...args], {
9
+ encoding: 'utf8', timeout: 30000, maxBuffer: 1024 * 1024,
10
+ env: { ...process.env, GIT_TERMINAL_PROMPT: '0' },
11
+ }).trim();
12
+ }
13
+ function ref(name) {
14
+ if (!/^refs\/heads\/[A-Za-z0-9][A-Za-z0-9._/-]*$/.test(name) || name.includes('..') || name.endsWith('.lock'))
15
+ throw new Error('Local merge requires explicit refs/heads/ branch references');
16
+ return name;
17
+ }
18
+ export function localWorkingHead(repoPath) {
19
+ return git(repoPath, 'rev-parse', '--verify', 'HEAD');
20
+ }
21
+ export function localSourceHead(repoPath, sourceRef) {
22
+ return git(repoPath, 'rev-parse', '--verify', ref(sourceRef));
23
+ }
24
+ export function requiresLocalGitMerge(job) {
25
+ return Boolean(job.localMerge) || job.tasks.some(task => task.output?.kind === 'task_pr');
26
+ }
27
+ export function localMergeEligible(job) {
28
+ return job.state === 'ACTIVE' && job.autoMerge !== false && !job.schedule &&
29
+ implementationComplete(job) && completionGateSatisfied(job) &&
30
+ (job.reviewEnabled === false || job.review?.outcome === 'approved');
31
+ }
32
+ export function localMergeCommitMessage(request, sourceSha) {
33
+ const lines = ['Agents Relay local merge', '', `Agents-Relay-Source-SHA: ${sourceSha}`];
34
+ if (request.correlationJobId)
35
+ lines.push(`Agents-Relay-Job: ${request.correlationJobId}`);
36
+ if (Number.isInteger(request.displayPrNumber) && Number(request.displayPrNumber) > 0)
37
+ lines.push(`Agents-Relay-Display-PR: ${request.displayPrNumber}`);
38
+ return lines.join('\n');
39
+ }
40
+ function checkedOutTarget(repoPath, targetRef) {
41
+ const records = git(repoPath, 'worktree', 'list', '--porcelain').split(/\n\n/);
42
+ const paths = records.filter(record => record.split('\n').includes('branch ' + targetRef))
43
+ .map(record => record.split('\n').find(line => line.startsWith('worktree '))?.slice(9))
44
+ .filter((path) => Boolean(path));
45
+ if (paths.length > 1)
46
+ throw new Error('Local merge target has multiple checked-out worktrees');
47
+ return paths[0] ?? null;
48
+ }
49
+ /** Merge a checked-out branch using Git porcelain so its worktree and index stay coherent. */
50
+ function mergeCheckedOutTarget(repoPath, targetRef, sourceSha, targetSha, message) {
51
+ const worktree = checkedOutTarget(repoPath, targetRef);
52
+ if (!worktree)
53
+ throw new Error('Target worktree disappeared before local merge');
54
+ if (git(worktree, 'symbolic-ref', 'HEAD') !== targetRef
55
+ || git(worktree, 'rev-parse', 'HEAD') !== targetSha) {
56
+ throw new Error('Local merge target moved before delivery');
57
+ }
58
+ if (git(worktree, 'status', '--porcelain', '--untracked-files=no')) {
59
+ throw new Error('Local merge target worktree has tracked or index changes');
60
+ }
61
+ if (existsSync(resolve(worktree, git(worktree, 'rev-parse', '--git-path', 'MERGE_HEAD')))) {
62
+ throw new Error('Existing MERGE_HEAD is not owned by Relay; manual resolution required');
63
+ }
64
+ // No checkout, reset or force update. Git controls index and working tree,
65
+ // reports conflicts and leaves a recoverable merge state when interrupted.
66
+ try {
67
+ git(worktree, 'merge', '--no-ff', '--no-edit', '-m', message, sourceSha);
68
+ }
69
+ catch (error) {
70
+ // Only a MERGE_HEAD matching our exact source SHA can be attributed to
71
+ // this delivery. Never abort a concurrent user's unrelated merge.
72
+ const mergeHead = resolve(worktree, git(worktree, 'rev-parse', '--git-path', 'MERGE_HEAD'));
73
+ if (existsSync(mergeHead)) {
74
+ try {
75
+ const mergeMessage = resolve(worktree, git(worktree, 'rev-parse', '--git-path', 'MERGE_MSG'));
76
+ if (readFileSync(mergeHead, 'utf8').trim() === sourceSha
77
+ && existsSync(mergeMessage)
78
+ && readFileSync(mergeMessage, 'utf8').startsWith('Agents Relay local merge')
79
+ && git(worktree, 'rev-parse', 'HEAD') === targetSha) {
80
+ git(worktree, 'merge', '--abort');
81
+ }
82
+ }
83
+ catch { /* Leave unidentified merge state for manual resolution. */ }
84
+ }
85
+ throw error;
86
+ }
87
+ const result = git(worktree, 'rev-parse', 'HEAD');
88
+ const parents = git(worktree, 'rev-list', '--parents', '-n', '1', result).split(' ');
89
+ if (parents[1] !== targetSha || parents[2] !== sourceSha) {
90
+ throw new Error('Unexpected local merge commit parentage; manual verification required');
91
+ }
92
+ return result;
93
+ }
94
+ /** Deliver to a Git ref without ever updating a checked-out ref behind Git's back. */
95
+ export function deliverLocalMerge(repoPath, request) {
96
+ const sourceRef = ref(request.sourceRef), targetRef = ref(request.targetRef);
97
+ if (targetRef !== 'refs/heads/main')
98
+ throw new Error('Local Git merge target must always be refs/heads/main');
99
+ if (sourceRef === targetRef)
100
+ throw new Error('Local merge source and target must differ');
101
+ const commonDir = resolve(repoPath, git(repoPath, 'rev-parse', '--git-common-dir'));
102
+ return withLocalMergeLock(commonDir, targetRef, () => {
103
+ const sourceSha = localSourceHead(repoPath, sourceRef);
104
+ if (request.sourceSha && sourceSha !== request.sourceSha)
105
+ throw new Error('Local merge source SHA changed after review');
106
+ const targetSha = git(repoPath, 'rev-parse', '--verify', targetRef);
107
+ try {
108
+ git(repoPath, 'merge-base', '--is-ancestor', sourceSha, targetSha);
109
+ return { sourceSha, mergedCommitSha: targetSha }; // Idempotent after acknowledged or crashed delivery.
110
+ }
111
+ catch { /* merge required */ }
112
+ const message = localMergeCommitMessage(request, sourceSha);
113
+ if (checkedOutTarget(repoPath, targetRef)) {
114
+ return { sourceSha, mergedCommitSha: mergeCheckedOutTarget(repoPath, targetRef, sourceSha, targetSha, message) };
115
+ }
116
+ // Unchecked-out refs can be updated atomically without touching any worktree.
117
+ const tree = git(repoPath, 'merge-tree', '--write-tree', targetSha, sourceSha).split('\n')[0];
118
+ if (!/^[0-9a-f]{40,64}$/.test(tree))
119
+ throw new Error('Local merge-tree returned no valid tree');
120
+ const mergedCommitSha = git(repoPath, 'commit-tree', tree, '-p', targetSha, '-p', sourceSha, '-m', message);
121
+ git(repoPath, 'update-ref', targetRef, mergedCommitSha, targetSha);
122
+ return { sourceSha, mergedCommitSha };
123
+ });
124
+ }
@@ -0,0 +1,7 @@
1
+ import { createHash } from 'node:crypto';
2
+ /** Primary local store key is immutable Job ID, independent of a PR number. */
3
+ export function relayJobStorePath(directory, jobId) {
4
+ if (!jobId.trim() || jobId.length > 1024)
5
+ throw new Error('Invalid immutable Job ID');
6
+ return directory + '/job-' + createHash('sha256').update(jobId).digest('hex') + '.db';
7
+ }
@@ -0,0 +1,29 @@
1
+ import { GitHubStore, InMemoryStore } from './store.js';
2
+ import { LocalAuthorityDurableStore } from './local-authority-store.js';
3
+ import { discoverLocalRelayStoreSnapshots, SQLiteRelayStore } from './relay-store.js';
4
+ /** Resolve exactly one SQLite-backed Job from its immutable ID, independent of PR binding. */
5
+ export function createJobIdRegistryResolver(directory, openStores, options = {}) {
6
+ return async (jobId) => {
7
+ const snapshots = await discoverLocalRelayStoreSnapshots(directory);
8
+ const matches = snapshots.flatMap(snapshot => snapshot.jobs.filter(job => job.id === jobId).map(job => ({ snapshot, job })));
9
+ // Retain historical terminal snapshots without shadowing a uniquely live Job.
10
+ const live = matches.filter(item => !['COMPLETED', 'CANCELLED'].includes(item.job.state));
11
+ const selected = live.length === 1 ? live[0] : live.length === 0 && matches.length === 1 ? matches[0] : null;
12
+ if (!selected)
13
+ return null;
14
+ const { snapshot, job } = selected;
15
+ let relay = openStores.get(snapshot.path);
16
+ if (!relay) {
17
+ relay = new SQLiteRelayStore(snapshot.path);
18
+ openStores.set(snapshot.path, relay);
19
+ }
20
+ // SQLite remains lifecycle authority. A positive PR binding only enables
21
+ // the external collaboration adapter for projection/merge side effects.
22
+ const externalPrNumber = job.externalPrNumber ?? (job.prNumber > 0 ? job.prNumber : 0);
23
+ const githubEnabled = !options.noGitHub && externalPrNumber > 0 && options.client;
24
+ const github = githubEnabled
25
+ ? new GitHubStore(options.client, job.repository, externalPrNumber, options.trustedAuthors ?? new Set())
26
+ : new InMemoryStore();
27
+ return { job, relay, store: new LocalAuthorityDurableStore(relay, github, undefined, !githubEnabled) };
28
+ };
29
+ }
@@ -0,0 +1,21 @@
1
+ import { homedir } from 'node:os';
2
+ import { join } from 'node:path';
3
+ import { GhApiClient } from './github-client.js';
4
+ import { DisabledGitHubClient, noGitHubMode } from './no-github.js';
5
+ import { GitHubStore } from './store.js';
6
+ import { LocalAuthorityDurableStore } from './local-authority-store.js';
7
+ import { relayStorePath, relayJobStorePath, SQLiteRelayStore } from './relay-store.js';
8
+ /** Open the canonical V2 SQLite authority without making any GitHub request. */
9
+ export function openLocalManagedStore(repository, prNumber, options = {}) {
10
+ if (!/^[A-Za-z0-9_.-]+\/[A-Za-z0-9_.-]+$/.test(repository) || repository.includes('..') || !Number.isInteger(prNumber) || prNumber < 0 || (prNumber === 0 && !options.jobId)) {
11
+ throw new Error('Local managed store requires a repository and either a positive PR number or immutable jobId');
12
+ }
13
+ const directory = options.directory ?? join(homedir(), '.agents-relay', 'stores');
14
+ const relay = new SQLiteRelayStore(prNumber === 0 ? relayJobStorePath(directory, options.jobId) : relayStorePath(directory, repository, prNumber));
15
+ const client = prNumber === 0 || options.noGitHub || noGitHubMode() ? new DisabledGitHubClient() : options.client ?? new GhApiClient();
16
+ const github = new GitHubStore(client, repository, prNumber, options.trustedAuthors ?? new Set());
17
+ return {
18
+ relay,
19
+ store: new LocalAuthorityDurableStore(relay, github, (relatedRepository, relatedPr) => openLocalManagedStore(relatedRepository, relatedPr, options).store, options.noGitHub || noGitHubMode()),
20
+ };
21
+ }
@@ -0,0 +1,53 @@
1
+ import { transitionJobEvent } from './scheduler.js';
2
+ import { deliverLocalMerge, localMergeEligible, requiresLocalGitMerge } from './local-git-merge.js';
3
+ import { errorMessage } from './observability.js';
4
+ /** Graph-selected durable local merge and completion; no checked-out worktree is ever changed. */
5
+ export async function reconcileLocalGitCompletion(store, finalState, headSha, noGitHub, projectPathForJob) {
6
+ if (localMergeEligible(finalState) && !requiresLocalGitMerge(finalState)) {
7
+ await transitionJobEvent(finalState, 'local.completed', { localCompletionAllowed: true });
8
+ await store.saveJob(finalState);
9
+ return true;
10
+ }
11
+ if (localMergeEligible(finalState) &&
12
+ finalState.localMerge && (!finalState.reviewEnabled || finalState.review?.headSha === headSha)) {
13
+ const receipt = finalState.localMerge;
14
+ let activeReceipt = receipt;
15
+ if (receipt.status !== 'succeeded' &&
16
+ (!receipt.nextAttemptAt || Date.parse(receipt.nextAttemptAt) <= Date.now())) {
17
+ await transitionJobEvent(finalState, 'local.merge.requested', { localMergeReady: true }, async (actionId) => {
18
+ if (actionId !== 'effect.deliverLocalMerge')
19
+ throw new Error('Unsupported local merge Graph effect ' + actionId);
20
+ try {
21
+ if (receipt.sourceSha !== headSha && headSha) {
22
+ activeReceipt = { ...receipt, sourceSha: headSha, status: 'pending' };
23
+ finalState.localMerge = activeReceipt;
24
+ finalState.updatedAt = new Date().toISOString();
25
+ await store.saveJob(finalState);
26
+ }
27
+ if (!projectPathForJob)
28
+ throw new Error('Local Git merge requires an explicit project path');
29
+ const repoPath = await projectPathForJob(finalState);
30
+ const delivered = deliverLocalMerge(repoPath, { ...activeReceipt, sourceSha: activeReceipt.sourceSha ?? headSha ?? undefined,
31
+ correlationJobId: finalState.id, displayPrNumber: finalState.externalPrNumber ?? (finalState.prNumber > 0 ? finalState.prNumber : undefined) });
32
+ finalState.localMerge = { ...activeReceipt, ...delivered, status: 'succeeded', projectionStrategy: 'target_ref_push',
33
+ error: undefined, nextAttemptAt: undefined, updatedAt: new Date().toISOString() };
34
+ }
35
+ catch (error) {
36
+ const attempts = (activeReceipt.attempts ?? 0) + 1;
37
+ finalState.localMerge = { ...activeReceipt, status: 'failed', attempts,
38
+ error: errorMessage(error),
39
+ nextAttemptAt: new Date(Date.now() + Math.min(300_000, 1000 * (2 ** Math.min(attempts, 8)))).toISOString(),
40
+ updatedAt: new Date().toISOString() };
41
+ }
42
+ finalState.updatedAt = new Date().toISOString();
43
+ await store.saveJob(finalState);
44
+ });
45
+ }
46
+ if (finalState.localMerge?.status === 'succeeded') {
47
+ await transitionJobEvent(finalState, 'local.completed', { localCompletionAllowed: true });
48
+ await store.saveJob(finalState);
49
+ return true;
50
+ }
51
+ }
52
+ return false;
53
+ }
@@ -0,0 +1,103 @@
1
+ import { mkdirSync, readFileSync, rmSync, writeFileSync, statSync, renameSync } from 'node:fs';
2
+ import { join } from 'node:path';
3
+ import { createHash } from 'node:crypto';
4
+ import { hostname } from 'node:os';
5
+ const sleep = (ms) => {
6
+ // Synchronous delivery API; Atomics.wait only blocks this worker's thread.
7
+ const wait = new Int32Array(new SharedArrayBuffer(4));
8
+ Atomics.wait(wait, 0, 0, ms);
9
+ };
10
+ function processAlive(pid) {
11
+ try {
12
+ process.kill(pid, 0);
13
+ return true;
14
+ }
15
+ catch (error) {
16
+ return error.code !== 'ESRCH';
17
+ }
18
+ }
19
+ /** Cross-process atomic directory lock in the shared Git common directory. */
20
+ export function withLocalMergeLock(commonDir, targetRef, action, timeoutMs = 15000) {
21
+ const digest = createHash('sha256').update(targetRef).digest('hex').slice(0, 24);
22
+ const path = join(commonDir, 'agents-relay-merge-' + digest + '.lockdir');
23
+ const ownerPath = join(path, 'owner.json');
24
+ const recoveryGuard = path + '.recovery';
25
+ const guardOwnerPath = join(recoveryGuard, 'owner.json');
26
+ const incompleteGraceMs = 3000;
27
+ const deadline = Date.now() + timeoutMs;
28
+ const owner = { pid: process.pid, host: hostname(), startedAt: new Date().toISOString(), targetRef };
29
+ while (true) {
30
+ try {
31
+ mkdirSync(path);
32
+ writeFileSync(ownerPath, JSON.stringify(owner), { flag: 'wx', mode: 0o600 });
33
+ break;
34
+ }
35
+ catch (error) {
36
+ if (error.code !== 'EEXIST')
37
+ throw error;
38
+ // Reap a crashed recovery owner, or a crash between mkdir and owner write.
39
+ // Renaming the orphan into a private quarantine keeps cleanup out of the
40
+ // active path. A grace period protects slow owner-file initialization.
41
+ const orphaned = (directory, metadata) => {
42
+ try {
43
+ const age = Date.now() - statSync(directory).mtimeMs;
44
+ if (age < incompleteGraceMs)
45
+ return false;
46
+ try {
47
+ const previous = JSON.parse(readFileSync(metadata, 'utf8'));
48
+ return previous.host === hostname() && Number.isInteger(previous.pid)
49
+ && !processAlive(previous.pid);
50
+ }
51
+ catch {
52
+ return true;
53
+ }
54
+ }
55
+ catch {
56
+ return false;
57
+ }
58
+ };
59
+ if (orphaned(recoveryGuard, guardOwnerPath)) {
60
+ const quarantine = recoveryGuard + '.orphan-' + process.pid + '-' + createHash('sha256').update(owner.startedAt).digest('hex').slice(0, 10);
61
+ try {
62
+ renameSync(recoveryGuard, quarantine);
63
+ rmSync(quarantine, { recursive: true, force: true });
64
+ }
65
+ catch { /* another recovery contender may already have moved it */ }
66
+ }
67
+ let guarded = false;
68
+ try {
69
+ mkdirSync(recoveryGuard);
70
+ guarded = true;
71
+ writeFileSync(guardOwnerPath, JSON.stringify(owner), { flag: 'wx', mode: 0o600 });
72
+ if (orphaned(path, ownerPath))
73
+ rmSync(path, { recursive: true, force: true });
74
+ }
75
+ catch { /* A live lock or incomplete initialization must be preserved. */ }
76
+ finally {
77
+ if (guarded) {
78
+ try {
79
+ const current = JSON.parse(readFileSync(guardOwnerPath, 'utf8'));
80
+ if (current.pid === owner.pid && current.startedAt === owner.startedAt && current.host === owner.host)
81
+ rmSync(recoveryGuard, { recursive: true, force: true });
82
+ }
83
+ catch { /* keep unknown guard */ }
84
+ }
85
+ }
86
+ if (Date.now() >= deadline)
87
+ throw new Error('Local merge target locked: ' + targetRef);
88
+ sleep(50);
89
+ }
90
+ }
91
+ try {
92
+ return action();
93
+ }
94
+ finally {
95
+ // Never release a lock now owned by a different process.
96
+ try {
97
+ const current = JSON.parse(readFileSync(ownerPath, 'utf8'));
98
+ if (current.pid === owner.pid && current.host === owner.host && current.startedAt === owner.startedAt)
99
+ rmSync(path, { recursive: true, force: true });
100
+ }
101
+ catch { /* preserve any unknown lock */ }
102
+ }
103
+ }