agents-relay 2.0.68 → 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 (52) hide show
  1. package/README.md +32 -1
  2. package/dist/adapters.js +39 -3
  3. package/dist/blockers.js +17 -4
  4. package/dist/cli-blocker.js +12 -28
  5. package/dist/cli-help.js +11 -8
  6. package/dist/cli-job-create.js +17 -9
  7. package/dist/cli-local-merge-config.js +4 -2
  8. package/dist/cli-projection.js +23 -55
  9. package/dist/cli-review.js +7 -24
  10. package/dist/cli-support.js +57 -1
  11. package/dist/cli-worker-task.js +63 -0
  12. package/dist/cli.js +177 -125
  13. package/dist/dashboard-html.js +15 -12
  14. package/dist/dashboard.js +26 -1
  15. package/dist/e2e-injection.js +76 -0
  16. package/dist/github-binding-order.js +15 -0
  17. package/dist/github-client.js +7 -2
  18. package/dist/github-pr-archive.js +22 -0
  19. package/dist/github-webhook.js +31 -6
  20. package/dist/job-actions.js +29 -0
  21. package/dist/local-authority-store.js +10 -1
  22. package/dist/local-commit-events.js +2 -0
  23. package/dist/local-git-merge.js +17 -6
  24. package/dist/local-job-registry.js +10 -4
  25. package/dist/local-merge-delivery.js +3 -2
  26. package/dist/local-merge-projection.js +60 -0
  27. package/dist/managed-worker-input.js +13 -6
  28. package/dist/manual-plan.js +29 -7
  29. package/dist/planner-runtime.js +182 -41
  30. package/dist/planner.js +35 -2
  31. package/dist/pool.js +22 -6
  32. package/dist/reconciler-cancellation.js +21 -0
  33. package/dist/reconciler-review.js +74 -18
  34. package/dist/reconciler-runtime.js +69 -34
  35. package/dist/reconciler.js +92 -26
  36. package/dist/relayd-control.js +184 -0
  37. package/dist/relayd-dashboard-actions.js +108 -0
  38. package/dist/relayd-utilities.js +107 -0
  39. package/dist/relayd.js +216 -206
  40. package/dist/review-generation-fence.js +12 -0
  41. package/dist/runtime-status-events.js +21 -0
  42. package/dist/runtime-status.js +13 -0
  43. package/dist/task-actions.js +59 -1
  44. package/dist/troubleshooter.js +21 -2
  45. package/dist/unblock.js +47 -15
  46. package/dist/worker-rejection.js +11 -0
  47. package/dist/workflow-effect-failures.js +30 -0
  48. package/dist/workspace.js +95 -6
  49. package/package.json +4 -3
  50. package/skills/agents-relay/agents/planner.agent.md +3 -0
  51. package/skills/agents-relay/contracts/orchestrator-workflows.md +4 -7
  52. package/workflow/review-lifecycle.mmd +3 -0
@@ -0,0 +1,76 @@
1
+ const legacy = {
2
+ quota_exceeded: { method: 'worker.codex.launch', action: 'return', execution: 'skip', mock: 'worker_quota' },
3
+ model_not_found: { method: 'worker.codex.launch', action: 'return', execution: 'skip', mock: 'worker_model_not_found' },
4
+ provider_unavailable: { method: 'worker.codex.launch', action: 'return', execution: 'skip', mock: 'worker_provider_unavailable' },
5
+ github_projection_failure: { method: 'github.projectLocalMerge', action: 'throw', execution: 'skip', error: { code: 'SERVICE_UNAVAILABLE', message: 'E2E transient GitHub projection transport failure', status: 503 } },
6
+ };
7
+ export function parseTestInjection(value) {
8
+ if (typeof value === 'string')
9
+ return legacy[value];
10
+ if (!value || typeof value !== 'object' || Array.isArray(value))
11
+ return undefined;
12
+ const v = value;
13
+ if (v.method !== 'worker.codex.launch' && v.method !== 'github.projectLocalMerge' && v.method !== 'browserWorkspace.command')
14
+ return undefined;
15
+ if (v.action !== 'throw' && v.action !== 'return' && v.action !== 'pass')
16
+ return undefined;
17
+ if (v.execution !== 'skip' && v.execution !== 'after' && v.execution !== 'run')
18
+ return undefined;
19
+ const error = v.error;
20
+ if (v.action === 'throw' && (!error || typeof error !== 'object' || typeof error.message !== 'string' || typeof error.code !== 'string'))
21
+ return undefined;
22
+ const mock = v.mock;
23
+ if (v.action === 'return' && (v.method !== 'worker.codex.launch' || !['worker_quota', 'worker_model_not_found', 'worker_provider_unavailable'].includes(String(mock))))
24
+ return undefined;
25
+ if (v.method === 'worker.codex.launch' && v.action !== 'pass' && (v.action !== 'return' || v.execution !== 'skip'))
26
+ return undefined;
27
+ if (v.action === 'pass' && v.execution !== 'run')
28
+ return undefined;
29
+ if (v.action !== 'pass' && v.execution === 'run')
30
+ return undefined;
31
+ return { method: v.method, action: v.action, execution: v.execution, error: error, mock: mock };
32
+ }
33
+ export function authorizedInjection(repository, raw, env = process.env) {
34
+ return env.E2E_REPO && repository === env.E2E_REPO ? parseTestInjection(raw) : undefined;
35
+ }
36
+ function mockWorker(name) {
37
+ const message = name === 'worker_quota' ? 'rate_limit_exceeded: usage limit reached' : name === 'worker_model_not_found' ? 'model_not_found: model is unavailable' : 'provider_unavailable: provider refused the request';
38
+ const promise = Promise.reject(new Error(message));
39
+ promise.catch(() => { });
40
+ return { id: 'e2e-injected', launched: Promise.resolve(), promise, cancel: () => { }, processExit: { exitCode: 1, signal: null, stderr: message, terminationReason: null } };
41
+ }
42
+ /** Caller must supply a configuration already consumed durably before this invocation. */
43
+ export function withTestInjection(method, config, execute) {
44
+ if (!config || config.method !== method || config.action === 'pass')
45
+ return execute();
46
+ const apply = (original) => {
47
+ if (config.action === 'throw') {
48
+ const error = Object.assign(new Error(config.error.message), { code: config.error.code, status: config.error.status });
49
+ throw error;
50
+ }
51
+ if (method === 'worker.codex.launch' && config.mock)
52
+ return mockWorker(config.mock);
53
+ return original;
54
+ };
55
+ return config.execution === 'after' ? Promise.resolve(execute()).then(apply) : apply();
56
+ }
57
+ /** Shared atomic consumer, scoped solely by E2E_REPO and exact method. */
58
+ export async function consumeMethodInjection(relay, repository, jobId, method) {
59
+ if (!process.env.E2E_REPO || process.env.E2E_REPO !== repository || !relay.mutateJob)
60
+ return undefined;
61
+ let config;
62
+ await relay.mutateJob(jobId, job => {
63
+ if (job.repository !== repository)
64
+ return job;
65
+ const task = job.tasks.find(t => authorizedInjection(job.repository, t.testInjection)?.method === method);
66
+ if (!task)
67
+ return job;
68
+ config = authorizedInjection(job.repository, task.testInjection);
69
+ delete task.testInjection;
70
+ task.updatedAt = new Date(Math.max(Date.now(), Date.parse(task.updatedAt) + 1)).toISOString();
71
+ return job;
72
+ });
73
+ if (config && (await relay.loadJob(jobId))?.tasks.some(t => authorizedInjection(repository, t.testInjection)?.method === method))
74
+ throw Error(`E2E injection consumption not committed: ${jobId}`);
75
+ return config;
76
+ }
@@ -0,0 +1,15 @@
1
+ /** Fresh unbound ACTIVE jobs are highest priority; terminal projection debt is last. */
2
+ export function orderGitHubBindingCandidates(items) {
3
+ return [...items].sort((a, b) => score(a.job) - score(b.job) || a.job.createdAt.localeCompare(b.job.createdAt));
4
+ }
5
+ function score(job) {
6
+ const bound = (job.externalPrNumber ?? (job.prNumber > 0 ? job.prNumber : 0)) > 0;
7
+ const terminal = ['COMPLETED', 'CANCELLED'].includes(job.state);
8
+ if (!terminal && !bound)
9
+ return 0;
10
+ if (!terminal && bound)
11
+ return 1;
12
+ if (terminal && bound)
13
+ return 2;
14
+ return 3;
15
+ }
@@ -241,13 +241,18 @@ export class GhApiClient {
241
241
  let stdout = '';
242
242
  child.stdout?.on('data', (chunk) => { stdout += chunk.toString(); });
243
243
  child.on('error', () => resolve(null));
244
- child.on('close', () => {
244
+ child.on('close', (code) => {
245
+ if (code !== 0) {
246
+ resolve(null);
247
+ return;
248
+ }
245
249
  try {
246
250
  const value = JSON.parse(stdout);
247
251
  const hosts = value.hosts;
248
252
  const github = hosts?.['github.com'];
249
253
  const active = Array.isArray(github) ? github.find(item => item.active === true) : undefined;
250
- resolve(typeof active?.login === 'string' && active.login.length > 0 ? active.login : null);
254
+ const healthy = active && active.state !== 'error' && typeof active.error !== 'string';
255
+ resolve(healthy && typeof active?.login === 'string' && active.login.length > 0 ? active.login : null);
251
256
  }
252
257
  catch {
253
258
  resolve(null);
@@ -0,0 +1,22 @@
1
+ /** A PR is a read-only collaboration record after authoritative local completion. */
2
+ export async function archiveDisplayPullRequest(client, repository, number, jobId, publishedSha, state) {
3
+ if (state !== 'OPEN')
4
+ return 'already_closed';
5
+ const marker = `Agents Relay local completion: ${jobId}`;
6
+ const path = `repos/${repository}/issues/${number}/comments?per_page=100`;
7
+ const comments = await client.request(path);
8
+ if (!Array.isArray(comments))
9
+ throw new Error('Cannot verify PR archive comment idempotency');
10
+ const alreadyCommented = comments.some(item => item && typeof item === 'object'
11
+ && 'body' in item && typeof item.body === 'string' && item.body.includes(marker));
12
+ if (!alreadyCommented) {
13
+ await client.request(`repos/${repository}/issues/${number}/comments`, {
14
+ method: 'POST',
15
+ body: JSON.stringify({ body: `${marker}\nPublished local main commit: ${publishedSha}.\nThis PR is closed as a review record; GitHub did not perform a merge.` }),
16
+ });
17
+ }
18
+ await client.request(`repos/${repository}/pulls/${number}`, {
19
+ method: 'PATCH', body: JSON.stringify({ state: 'closed' }),
20
+ });
21
+ return 'archived';
22
+ }
@@ -14,7 +14,7 @@ export function parsePackagePublishFailureMarker(body) {
14
14
  const data = JSON.parse(body.slice(start + prefix.length, end));
15
15
  if (typeof data.runId !== 'string' || !/^\d+$/.test(data.runId) || !Number.isInteger(data.attempt) || Number(data.attempt) < 1 || typeof data.sha !== 'string' || !/^[a-f0-9]{7,40}$/i.test(data.sha) || typeof data.stage !== 'string' || !data.stage)
16
16
  return null;
17
- return { runId: data.runId, attempt: Number(data.attempt), sha: data.sha, stage: data.stage };
17
+ return { runId: data.runId, attempt: Number(data.attempt), sha: data.sha, stage: data.stage, ...(typeof data.jobId === 'string' && data.jobId ? { jobId: data.jobId } : {}) };
18
18
  }
19
19
  catch {
20
20
  return null;
@@ -45,6 +45,7 @@ export function parsePackagePublicationMarker(body) {
45
45
  version: value.version,
46
46
  registry: typeof value.registry === 'string' ? value.registry : null,
47
47
  sha: typeof value.sha === 'string' ? value.sha : null,
48
+ ...(typeof value.jobId === 'string' && value.jobId ? { jobId: value.jobId } : {}),
48
49
  };
49
50
  }
50
51
  export function verifyGitHubWebhook(secret, body, signature) {
@@ -74,7 +75,7 @@ function pullRequestFacts(payload, action) {
74
75
  export function parseGitHubWebhook(headers, body) {
75
76
  const deliveryId = String(headers['x-github-delivery'] ?? '');
76
77
  const event = String(headers['x-github-event'] ?? '');
77
- if (!deliveryId || (event !== 'pull_request' && event !== 'issue_comment'))
78
+ if (!deliveryId || (event !== 'pull_request' && event !== 'issue_comment' && event !== 'commit_comment'))
78
79
  return null;
79
80
  let payload;
80
81
  try {
@@ -86,26 +87,50 @@ export function parseGitHubWebhook(headers, body) {
86
87
  const repository = payload.repository;
87
88
  const repositoryName = typeof repository?.full_name === 'string' ? repository.full_name : null;
88
89
  const action = typeof payload.action === 'string' ? payload.action : null;
90
+ if (!repositoryName)
91
+ return null;
92
+ if (event === 'commit_comment') {
93
+ const comment = payload.comment;
94
+ const trustedPublisher = comment?.user?.login === 'github-actions[bot]';
95
+ const publication = trustedPublisher ? parsePackagePublicationMarker(comment?.body) : null;
96
+ const publishFailure = trustedPublisher ? parsePackagePublishFailureMarker(comment?.body) : null;
97
+ const jobId = publication?.jobId ?? publishFailure?.jobId;
98
+ if ((!publication && !publishFailure) || !jobId)
99
+ return null;
100
+ const commentTime = comment?.created_at;
101
+ const publicationObservedAt = typeof commentTime === 'string' && Number.isFinite(Date.parse(commentTime))
102
+ ? new Date(commentTime).toISOString() : undefined;
103
+ return { deliveryId, event, action, repository: repositoryName, pullRequest: null, jobId, publication,
104
+ ...(publicationObservedAt ? { publicationObservedAt } : {}),
105
+ ...(publishFailure ? { publishFailure } : {}), pullRequestFacts: null };
106
+ }
89
107
  const pr = payload.pull_request;
90
108
  const issue = payload.issue;
91
- const number = Number(pr?.number ?? (issue?.pull_request ? issue.number : 0));
92
- if (!repositoryName || !Number.isInteger(number) || number <= 0)
93
- return null;
94
109
  if (event === 'issue_comment') {
110
+ const issueNumber = Number(issue?.number ?? 0);
111
+ if (!Number.isInteger(issueNumber) || issueNumber <= 0)
112
+ return null;
95
113
  const comment = payload.comment;
96
114
  const trustedPublisher = comment?.user?.login === 'github-actions[bot]';
97
115
  const publication = trustedPublisher ? parsePackagePublicationMarker(comment?.body) : null;
98
116
  const publishFailure = trustedPublisher
99
117
  ? parsePackagePublishFailureMarker(comment?.body) : null;
118
+ const jobId = publication?.jobId ?? publishFailure?.jobId;
100
119
  if (!publication && !publishFailure)
101
120
  return null;
121
+ if (!issue?.pull_request && !jobId)
122
+ return null;
102
123
  const commentTime = comment?.created_at;
103
124
  const publicationObservedAt = typeof commentTime === 'string' && Number.isFinite(Date.parse(commentTime))
104
125
  ? new Date(commentTime).toISOString() : undefined;
105
- return { deliveryId, event, action, repository: repositoryName, pullRequest: number, publication,
126
+ return { deliveryId, event, action, repository: repositoryName,
127
+ pullRequest: issue?.pull_request ? issueNumber : null, ...(jobId ? { jobId } : {}), publication,
106
128
  ...(publicationObservedAt ? { publicationObservedAt } : {}),
107
129
  ...(publishFailure ? { publishFailure } : {}), pullRequestFacts: null };
108
130
  }
131
+ const number = Number(pr?.number ?? 0);
132
+ if (!Number.isInteger(number) || number <= 0)
133
+ return null;
109
134
  const facts = pullRequestFacts(payload, action);
110
135
  if (!facts)
111
136
  return null;
@@ -1,5 +1,6 @@
1
1
  import { transitionJobEvent } from './scheduler.js';
2
2
  import { tryApplyTaskCancelAction } from './task-actions.js';
3
+ import { decideJobLifecycle, dispatchJobLifecycleSync } from './workflow/lifecycle.js';
3
4
  export const JOB_ACTIVATE_ACTION = 'job.activate';
4
5
  export const JOB_CANCEL_ACTION = 'job.cancel';
5
6
  async function transitionJobAction(job, event, action) {
@@ -34,8 +35,36 @@ async function cancelActiveWork(store, job, reason) {
34
35
  await store.saveTask(task);
35
36
  }
36
37
  }
38
+ function cancelActiveWorkSnapshot(job, reason) {
39
+ for (const plan of (job.plans ?? []).filter(item => item.status === 'PLANNING')) {
40
+ plan.status = 'FAILED';
41
+ plan.error = reason;
42
+ plan.assessment = `Planning failed: ${reason}`;
43
+ plan.leaseOwner = null;
44
+ plan.leaseExpiresAt = null;
45
+ plan.executionId = null;
46
+ plan.updatedAt = new Date().toISOString();
47
+ }
48
+ for (const task of job.tasks)
49
+ tryApplyTaskCancelAction(task, reason);
50
+ }
37
51
  export async function applyJobCancelAction(store, job, reason = 'Job cancelled') {
38
52
  const from = job.state;
53
+ if (store.mutateJob) {
54
+ const updated = await store.mutateJob(job.id, current => {
55
+ const decision = decideJobLifecycle(current, 'cancel');
56
+ if (!decision.accepted)
57
+ throw new Error(`Job ${current.id} is not cancellable (${current.state})`);
58
+ dispatchJobLifecycleSync(current, 'cancel');
59
+ if (!decision.actions.includes('effect.cancelActiveTasks'))
60
+ throw new Error('Unsupported job.cancel effect');
61
+ cancelActiveWorkSnapshot(current, reason);
62
+ current.updatedAt = new Date().toISOString();
63
+ return current;
64
+ });
65
+ Object.assign(job, structuredClone(updated));
66
+ return job;
67
+ }
39
68
  try {
40
69
  await transitionJobEvent(job, 'cancel', {}, async (actionId) => {
41
70
  if (actionId !== 'effect.cancelActiveTasks')
@@ -262,11 +262,20 @@ export class LocalAuthorityDurableStore {
262
262
  return job;
263
263
  });
264
264
  }
265
- async mutate(jobId, mutation) {
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) {
266
271
  if (!this.authority.mutateJob)
267
272
  throw new Error('Atomic SQLite mutation is required for local lifecycle authority');
268
273
  const changed = await this.authority.mutateJob(jobId, mutation);
269
274
  publishLocalCommit(changed);
275
+ return structuredClone(changed);
276
+ }
277
+ async mutate(jobId, mutation) {
278
+ await this.mutateJob(jobId, mutation);
270
279
  }
271
280
  async listAgents() {
272
281
  return structuredClone(await this.authority.loadAgents() ?? []);
@@ -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
  }
@@ -29,6 +29,14 @@ export function localMergeEligible(job) {
29
29
  implementationComplete(job) && completionGateSatisfied(job) &&
30
30
  (job.reviewEnabled === false || job.review?.outcome === 'approved');
31
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
+ }
32
40
  function checkedOutTarget(repoPath, targetRef) {
33
41
  const records = git(repoPath, 'worktree', 'list', '--porcelain').split(/\n\n/);
34
42
  const paths = records.filter(record => record.split('\n').includes('branch ' + targetRef))
@@ -39,7 +47,7 @@ function checkedOutTarget(repoPath, targetRef) {
39
47
  return paths[0] ?? null;
40
48
  }
41
49
  /** Merge a checked-out branch using Git porcelain so its worktree and index stay coherent. */
42
- function mergeCheckedOutTarget(repoPath, targetRef, sourceSha, targetSha) {
50
+ function mergeCheckedOutTarget(repoPath, targetRef, sourceSha, targetSha, message) {
43
51
  const worktree = checkedOutTarget(repoPath, targetRef);
44
52
  if (!worktree)
45
53
  throw new Error('Target worktree disappeared before local merge');
@@ -47,8 +55,8 @@ function mergeCheckedOutTarget(repoPath, targetRef, sourceSha, targetSha) {
47
55
  || git(worktree, 'rev-parse', 'HEAD') !== targetSha) {
48
56
  throw new Error('Local merge target moved before delivery');
49
57
  }
50
- if (git(worktree, 'status', '--porcelain', '--untracked-files=all')) {
51
- throw new Error('Local merge target worktree is not clean');
58
+ if (git(worktree, 'status', '--porcelain', '--untracked-files=no')) {
59
+ throw new Error('Local merge target worktree has tracked or index changes');
52
60
  }
53
61
  if (existsSync(resolve(worktree, git(worktree, 'rev-parse', '--git-path', 'MERGE_HEAD')))) {
54
62
  throw new Error('Existing MERGE_HEAD is not owned by Relay; manual resolution required');
@@ -56,7 +64,7 @@ function mergeCheckedOutTarget(repoPath, targetRef, sourceSha, targetSha) {
56
64
  // No checkout, reset or force update. Git controls index and working tree,
57
65
  // reports conflicts and leaves a recoverable merge state when interrupted.
58
66
  try {
59
- git(worktree, 'merge', '--no-ff', '--no-edit', '-m', 'Agents Relay local merge', sourceSha);
67
+ git(worktree, 'merge', '--no-ff', '--no-edit', '-m', message, sourceSha);
60
68
  }
61
69
  catch (error) {
62
70
  // Only a MERGE_HEAD matching our exact source SHA can be attributed to
@@ -86,6 +94,8 @@ function mergeCheckedOutTarget(repoPath, targetRef, sourceSha, targetSha) {
86
94
  /** Deliver to a Git ref without ever updating a checked-out ref behind Git's back. */
87
95
  export function deliverLocalMerge(repoPath, request) {
88
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');
89
99
  if (sourceRef === targetRef)
90
100
  throw new Error('Local merge source and target must differ');
91
101
  const commonDir = resolve(repoPath, git(repoPath, 'rev-parse', '--git-common-dir'));
@@ -99,14 +109,15 @@ export function deliverLocalMerge(repoPath, request) {
99
109
  return { sourceSha, mergedCommitSha: targetSha }; // Idempotent after acknowledged or crashed delivery.
100
110
  }
101
111
  catch { /* merge required */ }
112
+ const message = localMergeCommitMessage(request, sourceSha);
102
113
  if (checkedOutTarget(repoPath, targetRef)) {
103
- return { sourceSha, mergedCommitSha: mergeCheckedOutTarget(repoPath, targetRef, sourceSha, targetSha) };
114
+ return { sourceSha, mergedCommitSha: mergeCheckedOutTarget(repoPath, targetRef, sourceSha, targetSha, message) };
104
115
  }
105
116
  // Unchecked-out refs can be updated atomically without touching any worktree.
106
117
  const tree = git(repoPath, 'merge-tree', '--write-tree', targetSha, sourceSha).split('\n')[0];
107
118
  if (!/^[0-9a-f]{40,64}$/.test(tree))
108
119
  throw new Error('Local merge-tree returned no valid tree');
109
- const mergedCommitSha = git(repoPath, 'commit-tree', tree, '-p', targetSha, '-p', sourceSha, '-m', 'Agents Relay local merge');
120
+ const mergedCommitSha = git(repoPath, 'commit-tree', tree, '-p', targetSha, '-p', sourceSha, '-m', message);
110
121
  git(repoPath, 'update-ref', targetRef, mergedCommitSha, targetSha);
111
122
  return { sourceSha, mergedCommitSha };
112
123
  });
@@ -1,8 +1,8 @@
1
- import { InMemoryStore } from './store.js';
1
+ import { GitHubStore, InMemoryStore } from './store.js';
2
2
  import { LocalAuthorityDurableStore } from './local-authority-store.js';
3
3
  import { discoverLocalRelayStoreSnapshots, SQLiteRelayStore } from './relay-store.js';
4
4
  /** Resolve exactly one SQLite-backed Job from its immutable ID, independent of PR binding. */
5
- export function createJobIdRegistryResolver(directory, openStores) {
5
+ export function createJobIdRegistryResolver(directory, openStores, options = {}) {
6
6
  return async (jobId) => {
7
7
  const snapshots = await discoverLocalRelayStoreSnapshots(directory);
8
8
  const matches = snapshots.flatMap(snapshot => snapshot.jobs.filter(job => job.id === jobId).map(job => ({ snapshot, job })));
@@ -17,7 +17,13 @@ export function createJobIdRegistryResolver(directory, openStores) {
17
17
  relay = new SQLiteRelayStore(snapshot.path);
18
18
  openStores.set(snapshot.path, relay);
19
19
  }
20
- // The GitHub adapter cannot enter the lifecycle execution store.
21
- return { job, relay, store: new LocalAuthorityDurableStore(relay, new InMemoryStore(), undefined, true) };
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) };
22
28
  };
23
29
  }
@@ -27,8 +27,9 @@ export async function reconcileLocalGitCompletion(store, finalState, headSha, no
27
27
  if (!projectPathForJob)
28
28
  throw new Error('Local Git merge requires an explicit project path');
29
29
  const repoPath = await projectPathForJob(finalState);
30
- const delivered = deliverLocalMerge(repoPath, { ...activeReceipt, sourceSha: activeReceipt.sourceSha ?? headSha ?? undefined });
31
- finalState.localMerge = { ...activeReceipt, ...delivered, status: 'succeeded',
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',
32
33
  error: undefined, nextAttemptAt: undefined, updatedAt: new Date().toISOString() };
33
34
  }
34
35
  catch (error) {
@@ -0,0 +1,60 @@
1
+ import { execFileSync } from 'node:child_process';
2
+ function git(cwd, ...args) {
3
+ return execFileSync('git', ['-C', cwd, ...args], {
4
+ encoding: 'utf8', timeout: 120000, maxBuffer: 1024 * 1024,
5
+ env: { ...process.env, GIT_TERMINAL_PROMPT: '0' },
6
+ }).trim();
7
+ }
8
+ function branchRef(name) {
9
+ if (!/^refs\/heads\/[A-Za-z0-9][A-Za-z0-9._/-]*$/.test(name) || name.includes('..') || name.endsWith('.lock'))
10
+ throw new Error('Local merge projection requires explicit refs/heads/ target');
11
+ return name;
12
+ }
13
+ function validSha(value, label) {
14
+ if (!value || !/^[0-9a-f]{40,64}$/.test(value))
15
+ throw new Error(`Local merge projection requires valid ${label} SHA`);
16
+ return value;
17
+ }
18
+ /**
19
+ * Publish SQLite-authoritative local main using a normal fast-forward push.
20
+ * GitHub never performs a second merge. Divergence is retried only after
21
+ * an operator or a separate Git synchronization workflow resolves it.
22
+ */
23
+ export function projectAuthoritativeLocalMerge(repoPath, job) {
24
+ const receipt = job.localMerge;
25
+ if (job.state !== 'COMPLETED' || receipt?.status !== 'succeeded')
26
+ throw new Error('Authoritative local merge projection requires COMPLETED Job with succeeded merge receipt');
27
+ const targetRef = branchRef(receipt.targetRef);
28
+ if (targetRef !== 'refs/heads/main')
29
+ throw new Error('Local Git target must always be refs/heads/main');
30
+ const sourceSha = validSha(receipt.sourceSha, 'source');
31
+ const mergedCommitSha = validSha(receipt.mergedCommitSha, 'merged commit');
32
+ const localTargetSha = git(repoPath, 'rev-parse', '--verify', targetRef);
33
+ if (localTargetSha !== mergedCommitSha) {
34
+ try {
35
+ git(repoPath, 'merge-base', '--is-ancestor', mergedCommitSha, localTargetSha);
36
+ }
37
+ catch {
38
+ throw new Error(`Local merge target ${targetRef} diverged after authoritative delivery for ${job.id}`);
39
+ }
40
+ }
41
+ const branch = targetRef.slice('refs/heads/'.length);
42
+ const remoteTracking = `refs/remotes/origin/${branch}`;
43
+ git(repoPath, 'fetch', '--no-tags', 'origin', `+${targetRef}:${remoteTracking}`);
44
+ const remoteTargetSha = git(repoPath, 'rev-parse', '--verify', remoteTracking);
45
+ try {
46
+ git(repoPath, 'merge-base', '--is-ancestor', localTargetSha, remoteTargetSha);
47
+ return { targetRef, localMergedCommitSha: mergedCommitSha, remoteTargetSha, projectedCommitSha: remoteTargetSha, status: 'already_projected' };
48
+ }
49
+ catch { /* remote projection required */ }
50
+ // GitHub mirrors the SQLite-authoritative local main; it must never synthesize
51
+ // a second merge or rewrite the tree when the remote has diverged.
52
+ try {
53
+ git(repoPath, 'merge-base', '--is-ancestor', remoteTargetSha, localTargetSha);
54
+ }
55
+ catch {
56
+ throw new Error(`GitHub sync deferred: origin/${branch} diverged from authoritative local main; resolve the remote conflict without force push`);
57
+ }
58
+ git(repoPath, 'push', 'origin', `${targetRef}:${targetRef}`);
59
+ return { targetRef, localMergedCommitSha: mergedCommitSha, remoteTargetSha, projectedCommitSha: localTargetSha, status: 'projected' };
60
+ }
@@ -4,7 +4,10 @@ import { createReviewChairExecutionPlan } from './review-chair.js';
4
4
  import { taskTagWorkerPrompt } from './task-tag.js';
5
5
  import { executionEventSource } from './reconciler-runtime.js';
6
6
  export async function managedWorkerInput(job, task, context) {
7
- if (!['codex', 'chatgpt'].includes(task.adapter) || !job.repository || job.prNumber <= 0)
7
+ const isLocalJob = job.prNumber === 0;
8
+ const externalPrNumber = job.externalPrNumber && job.externalPrNumber > 0 ? job.externalPrNumber : (job.prNumber > 0 ? job.prNumber : 0);
9
+ const projectedReview = isLocalJob && task.agentName === 'reviewer' && externalPrNumber > 0;
10
+ if (!['codex', 'chatgpt'].includes(task.adapter) || !job.repository)
8
11
  return task.input;
9
12
  const selectedAgent = task.agentName && context ? await agentContractPrompt(context, task.agentName) : '';
10
13
  const selectedGraph = task.agentGraph && context ? await agentGraphPrompt(context, task.agentGraph) : '';
@@ -16,19 +19,23 @@ export async function managedWorkerInput(job, task, context) {
16
19
  if (!task.executionId)
17
20
  throw new Error(`Task ${task.id} requires an executionId before worker input is built`);
18
21
  const sourceId = executionEventSource(job.id, task.id, task.executionId);
19
- const prUrl = `https://github.com/${job.repository}/pull/${job.prNumber}`;
20
- const taskUrl = task.taskUrl ?? `${prUrl}#issuecomment-${task.id}`;
22
+ const prUrl = externalPrNumber > 0 ? `https://github.com/${job.repository}/pull/${externalPrNumber}` : null;
23
+ const taskUrl = task.taskUrl ?? (isLocalJob ? `local:${job.id}/task/${task.id}` : `${prUrl}#issuecomment-${task.id}`);
21
24
  const output = task.output.kind === 'file'
22
25
  ? `file\nPath: ${task.output.path}`
23
- : `task/PR\nPR: ${prUrl}\nTask: ${task.id}`;
26
+ : (projectedReview ? `task/PR\nProjected PR: ${prUrl}\nTask: ${task.id}` : isLocalJob ? `task/PR\nTask: ${task.id}` : `task/PR\nPR: ${prUrl}\nTask: ${task.id}`);
24
27
  const taskTag = taskTagWorkerPrompt(job);
25
28
  return `[Agents Relay managed task]
26
- Job URL: ${prUrl}
29
+ ${projectedReview ? `Job: local ${job.id} (lifecycle authority)\nProjected GitHub PR: ${prUrl}` : isLocalJob ? `Job: local ${job.id}` : `Job URL: ${prUrl}`}
27
30
  Job ID: ${job.id}
28
31
  Task URL: ${taskUrl}
29
32
  Task ID: ${task.id}
30
33
  Project: ${task.projectName ?? context?.project.name ?? 'unspecified'}
31
- Retrieve the durable job and task from GitHub before doing the work. Use whatever GitHub access this environment provides: the URLs, a GitHub integration/tool, or GitHub CLI/API. GitHub is the source of truth for task intent and declared output.
34
+ ${projectedReview
35
+ ? `The local SQLite Job is the lifecycle authority. The projected GitHub PR exists only for artifact/head verification. Review the projected PR at ${prUrl} and verify its exact immutable head against the durable review task input below. Do not use GitHub comments or PR metadata as lifecycle authority.\n\n${task.input}`
36
+ : isLocalJob
37
+ ? `Treat the following durable task input as the authoritative work contract; do not query GitHub.\n\n${task.input}`
38
+ : `Retrieve the durable job and task from GitHub before doing the work. Use whatever GitHub access this environment provides: the URLs, a GitHub integration/tool, or GitHub CLI/API. GitHub is the source of truth for task intent and declared output.`}
32
39
  ${context ? executionContextPrompt(context) + '\n\n' : ''}${selectedAgent ? selectedAgent + '\n\n' : ''}${selectedGraph ? selectedGraph + '\n\n' : ''}${chairPlan ? `[Review Chair execution plan]\n${JSON.stringify(chairPlan, null, 2)}\n\nUse this runtime-discovered panel to create sibling durable specialist Tasks. Preserve each specialistTasks entry as review metadata, keep the target immutable, and synthesize structured results with synthesizeReview semantics before returning the unified revision plan.\n\n` : ''}Follow the resolved project instructions and Agents Relay/events-bus worker contracts.
33
40
  [Output contract]
34
41
  ${output}
@@ -7,14 +7,36 @@ export async function executeManualPlan(store, jobId, guidance, timeoutMs, launc
7
7
  const active = pendingPlan(job);
8
8
  if (active && active.executionId)
9
9
  throw new Error(`Planning round ${active.id} is already PLANNING`);
10
- const round = active ?? createPlanRound(job, { guidance, manual: true, timeoutMs });
11
- if (!active)
12
- await store.appendPlan(round);
13
- job = await store.load(jobId);
14
- if (job.state === 'DRAFT') {
15
- await applyJobActivateAction(job);
16
- await store.saveJob(job);
10
+ let round = active ?? createPlanRound(job, { guidance, manual: true, timeoutMs });
11
+ if (store.mutateJob) {
12
+ const committed = await store.mutateJob(jobId, current => {
13
+ if (['COMPLETED', 'CANCELLED'].includes(current.state))
14
+ throw new Error(`Cannot plan terminal job ${current.id} (${current.state})`);
15
+ const concurrent = pendingPlan(current);
16
+ if (concurrent) {
17
+ if (concurrent.executionId)
18
+ throw new Error(`Planning round ${concurrent.id} is already PLANNING`);
19
+ round = structuredClone(concurrent);
20
+ }
21
+ else {
22
+ round = createPlanRound(current, { guidance, manual: true, timeoutMs });
23
+ current.plans = [...(current.plans ?? []), structuredClone(round)].sort((a, b) => a.round - b.round);
24
+ }
25
+ if (current.state === 'DRAFT')
26
+ applyJobActivateAction(current);
27
+ return current;
28
+ });
29
+ job = structuredClone(committed);
30
+ }
31
+ else {
32
+ if (!active)
33
+ await store.appendPlan(round);
17
34
  job = await store.load(jobId);
35
+ if (job.state === 'DRAFT') {
36
+ await applyJobActivateAction(job);
37
+ await store.saveJob(job);
38
+ job = await store.load(jobId);
39
+ }
18
40
  }
19
41
  const durableRound = (job.plans ?? []).find(plan => plan.id === round.id);
20
42
  if (!durableRound)