agents-relay 2.0.93 → 2.0.95
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/README.md +7 -7
- package/dist/cli-help.js +8 -6
- package/dist/cli-job-create.js +41 -6
- package/dist/cli.js +38 -7
- package/dist/local-authority-store.js +9 -0
- package/dist/projected-store.js +12 -4
- package/dist/projection-outbox.js +40 -0
- package/dist/relay-create.js +18 -0
- package/dist/relay-store.js +14 -2
- package/dist/relayd-control.js +35 -10
- package/dist/relayd.js +50 -38
- package/dist/workspace.js +4 -0
- package/package.json +1 -1
- package/skills/agents-relay/SKILL.md +1 -1
- package/skills/agents-relay/contracts/task-job-contract.md +1 -1
package/README.md
CHANGED
|
@@ -58,7 +58,7 @@ These paths always represent the current canonical graphs. Historical graph defi
|
|
|
58
58
|
|
|
59
59
|
## Quick start
|
|
60
60
|
|
|
61
|
-
For new work, create a local Job through Agents Relay.
|
|
61
|
+
For new work, create a local Job through Agents Relay. Use `job adopt` to bind a known legacy PR number without GitHub access; the external GitHub projection creates a display PR after the first meaningful code commit. Tasks are validated against the SQLite Job, not GitHub markers.
|
|
62
62
|
|
|
63
63
|
~~~sh
|
|
64
64
|
cat > job.md <<'EOF'
|
|
@@ -67,7 +67,7 @@ cat > job.md <<'EOF'
|
|
|
67
67
|
Standalone Job context. Put a Mermaid block first when a workflow/state/architecture diagram helps.
|
|
68
68
|
EOF
|
|
69
69
|
|
|
70
|
-
npx agents-relay job create --repo OWNER/REPO
|
|
70
|
+
npx agents-relay job create --repo OWNER/REPO \
|
|
71
71
|
--id job-1 --title "Objective" --objective-file job.md --mode fixed
|
|
72
72
|
|
|
73
73
|
# The orchestrator decides the initial batch separately
|
|
@@ -76,10 +76,10 @@ cat > task.md <<'EOF'
|
|
|
76
76
|
|
|
77
77
|
Implement this phase using the Job objective as context.
|
|
78
78
|
EOF
|
|
79
|
-
npx agents-relay task create --repo OWNER/REPO --
|
|
79
|
+
npx agents-relay task create --repo OWNER/REPO --id job-1 \
|
|
80
80
|
--task-id initial --output task-pr --input-file task.md
|
|
81
81
|
|
|
82
|
-
npx agents-relay task create --repo OWNER/REPO --
|
|
82
|
+
npx agents-relay task create --repo OWNER/REPO --id job-1 \
|
|
83
83
|
--task-id child --output task-pr \
|
|
84
84
|
--input "Implement the described child task and publish a terminal event."
|
|
85
85
|
|
|
@@ -142,12 +142,12 @@ Autonomous jobs can also be scheduled. The Job marker stores a five-field cron e
|
|
|
142
142
|
[Scheduled autonomous jobs](docs/scheduled-autonomous-jobs.md) for the exact
|
|
143
143
|
operational contract.
|
|
144
144
|
|
|
145
|
-
|
|
145
|
+
GitHub projection creates or reuses a display PR after a meaningful source-branch commit; `job adopt` is reserved for existing legacy PR bindings. Re-running it is idempotent: it reuses the PR and maintains exactly one trusted agents-relay:job:v1 marker. Job creation is always create-only and always enables auto-merge; `--no-auto-merge` is not a `job create` option. Existing Jobs can still change that setting with `job update`. Task execution options belong to `task create`, and the orchestrator may create zero, one, or multiple initial Tasks according to user intent.
|
|
146
146
|
|
|
147
|
-
To create a Job on an existing unmanaged PR, use the
|
|
147
|
+
To create a Job on an existing unmanaged PR, use the explicit `job adopt` compatibility action:
|
|
148
148
|
|
|
149
149
|
~~~sh
|
|
150
|
-
npx agents-relay job
|
|
150
|
+
npx agents-relay job adopt --repo OWNER/REPO --pr 6 --id job-6 --title "Existing objective"
|
|
151
151
|
~~~
|
|
152
152
|
|
|
153
153
|
Use `job update` for partial mutable metadata changes on an already healthy Job marker/binding:
|
package/dist/cli-help.js
CHANGED
|
@@ -56,15 +56,17 @@ When provided, --thread-id persists a reviewer destination; otherwise the Job re
|
|
|
56
56
|
|
|
57
57
|
Required: --repo OWNER/REPO --pr NUMBER --id JOB_ID.
|
|
58
58
|
Optional: --thread-id THREAD_ID persists a reviewer override; omitted uses Job reviewer/creator thread or legacy route. Rejects pending reviews and wakes managed reconciliation.`,
|
|
59
|
-
'job create': `Create a SQLite-authoritative Job.
|
|
59
|
+
'job create': `Create a SQLite-authoritative Job (objective-only).
|
|
60
60
|
|
|
61
|
-
Required: --repo OWNER/REPO and a standalone Job objective
|
|
62
|
-
|
|
63
|
-
|
|
64
|
-
Job creation always enables auto-merge. By default it is create-only; --initial-plan follows durable Job creation with the first real Planner round, activates the Job, and publishes the normal wake so Relay can continue without a second orchestrator command. Re-running create against an already healthy Job is idempotent and does not mutate its metadata.
|
|
61
|
+
Required: --repo OWNER/REPO and a standalone Job objective. Job ID defaults to an auto-incrementing repository-scoped number; --id is optional for compatibility. Existing PR adoption is an explicit separate command: job adopt.
|
|
62
|
+
Common: --id JOB_ID (optional), --title TEXT, --objective-file PATH|- (preferred), --objective MARKDOWN (short/simple only), --project-path RELATIVE_PATH, --priority P0|P1|P2|P3, --mode fixed|autonomous, --initial-plan, --no-review, --tag JSON, --reviewer-thread-id ID, --creator-thread-id ID, --implementation-thread-id ID. --objective and --objective-file are mutually exclusive.
|
|
63
|
+
Job creation always enables auto-merge. By default it is create-only; --initial-plan validates the first real Planner round before atomically creating the durable Job and Plan, then publishes the normal wake so Relay can continue without a second orchestrator command. Re-running create against an already healthy Job is idempotent and does not mutate its metadata.
|
|
65
64
|
|
|
66
65
|
Example:
|
|
67
|
-
npx agents-relay job create --repo OWNER/REPO --project-path vlog --title "Episode" --objective-file episode.md --
|
|
66
|
+
npx agents-relay job create --repo OWNER/REPO --project-path vlog --title "Episode" --objective-file episode.md --initial-plan`,
|
|
67
|
+
'job adopt': `Adopt an existing legacy PR as a SQLite-authoritative Job.
|
|
68
|
+
|
|
69
|
+
Required: --repo OWNER/REPO, --pr NUMBER, and --id JOB_ID. This is the only creation path that binds an existing PR.`,
|
|
68
70
|
'job update': `Partially update mutable metadata on a healthy existing Job.
|
|
69
71
|
|
|
70
72
|
Required: --repo OWNER/REPO, --pr NUMBER, and --id JOB_ID.
|
package/dist/cli-job-create.js
CHANGED
|
@@ -4,8 +4,9 @@ import { createJobEntity } from './workflow/entity-creation.js';
|
|
|
4
4
|
import { normalizeThreadId } from './reviewer-thread.js';
|
|
5
5
|
import { discoverLocalRelayStoreSnapshots, relayStoreDirectory } from './relay-store.js';
|
|
6
6
|
/**
|
|
7
|
-
* Job
|
|
8
|
-
*
|
|
7
|
+
* Ordinary Job creation is local and objective-oriented. Legacy PR metadata is
|
|
8
|
+
* handled by adoptLegacyJob below so the normal contract cannot accidentally
|
|
9
|
+
* create a second external lifecycle.
|
|
9
10
|
*/
|
|
10
11
|
export async function ensureManagedGitHubJob(_client, repositoryName, _trustedAuthors, options) {
|
|
11
12
|
const directory = options.storeDirectory ?? relayStoreDirectory();
|
|
@@ -46,7 +47,6 @@ export async function ensureManagedGitHubJob(_client, repositoryName, _trustedAu
|
|
|
46
47
|
throw new Error('Local merge target must always be main');
|
|
47
48
|
if (options.base && options.base !== 'main')
|
|
48
49
|
throw new Error('Job base must always be main');
|
|
49
|
-
const localMergeTarget = localMergeSource ? 'refs/heads/main' : undefined;
|
|
50
50
|
const job = createJobEntity({
|
|
51
51
|
id,
|
|
52
52
|
title: options.title || pr.title || 'Agents Relay job',
|
|
@@ -57,7 +57,7 @@ export async function ensureManagedGitHubJob(_client, repositoryName, _trustedAu
|
|
|
57
57
|
executionMode: options.executionMode ?? 'fixed',
|
|
58
58
|
tag: options.tag,
|
|
59
59
|
autoMerge: true,
|
|
60
|
-
...(localMergeSource
|
|
60
|
+
...(localMergeSource ? { localMerge: { sourceRef: localMergeSource, targetRef: 'refs/heads/main', status: 'pending' } } : {}),
|
|
61
61
|
reviewEnabled: options.reviewEnabled ?? true,
|
|
62
62
|
...(options.reviewerThreadId
|
|
63
63
|
? { reviewerThreadId: normalizeThreadId(options.reviewerThreadId, 'reviewer thread ID') }
|
|
@@ -76,8 +76,43 @@ export async function ensureManagedGitHubJob(_client, repositoryName, _trustedAu
|
|
|
76
76
|
tasks: [],
|
|
77
77
|
...(options.schedule ? { schedule: options.schedule } : {}),
|
|
78
78
|
}, { activate: Boolean(options.schedule) });
|
|
79
|
-
|
|
80
|
-
|
|
79
|
+
if (options.persist !== false) {
|
|
80
|
+
if (local.store.createJobIfAbsent)
|
|
81
|
+
await local.store.createJobIfAbsent(job);
|
|
82
|
+
else
|
|
83
|
+
await local.store.saveJob(job);
|
|
84
|
+
}
|
|
85
|
+
return { job: options.persist === false ? job : await local.store.load(id), pr };
|
|
86
|
+
}
|
|
87
|
+
finally {
|
|
88
|
+
local.relay.close();
|
|
89
|
+
}
|
|
90
|
+
}
|
|
91
|
+
/** Explicit compatibility path for adopting an already-existing PR. */
|
|
92
|
+
export async function adoptLegacyJob(_client, repositoryName, _trustedAuthors, options) {
|
|
93
|
+
if (!options.id || !Number.isInteger(options.prNumber) || options.prNumber <= 0)
|
|
94
|
+
throw new Error('job adopt requires --id and a positive --pr');
|
|
95
|
+
const directory = options.storeDirectory ?? relayStoreDirectory();
|
|
96
|
+
const matches = (await discoverLocalRelayStoreSnapshots(directory)).flatMap(snapshot => snapshot.jobs).filter(job => job.id === options.id);
|
|
97
|
+
if (matches.length > 1)
|
|
98
|
+
throw new Error(`Ambiguous existing immutable Job ID ${options.id}`);
|
|
99
|
+
if (matches.length === 1) {
|
|
100
|
+
const existing = matches[0];
|
|
101
|
+
if (existing.repository !== repositoryName || existing.prNumber !== options.prNumber)
|
|
102
|
+
throw new Error(`Job ID ${options.id} is already bound to another repository or PR`);
|
|
103
|
+
return { job: existing, pr: { number: existing.prNumber, title: existing.title, body: existing.objective ?? '', state: 'OPEN', head: '', base: 'main' } };
|
|
104
|
+
}
|
|
105
|
+
const local = openLocalManagedStore(repositoryName, options.prNumber, { directory, jobId: options.id, noGitHub: true });
|
|
106
|
+
try {
|
|
107
|
+
const existing = await local.relay.loadJob(options.id);
|
|
108
|
+
if (existing)
|
|
109
|
+
return { job: existing, pr: { number: existing.prNumber, title: existing.title, body: existing.objective ?? '', state: 'OPEN', head: '', base: '' } };
|
|
110
|
+
const now = new Date().toISOString();
|
|
111
|
+
const job = createJobEntity({ id: options.id, title: options.title ?? options.id, objective: options.objective, projectPath: options.projectPath,
|
|
112
|
+
description: options.objective, priority: 'P2', executionMode: 'fixed', autoMerge: true, reviewEnabled: true,
|
|
113
|
+
prNumber: options.prNumber, repository: repositoryName, continuation: null, createdAt: now, updatedAt: now, tasks: [] });
|
|
114
|
+
const created = local.store.createJobIfAbsent ? await local.store.createJobIfAbsent(job) : (await local.store.saveJob(job), job);
|
|
115
|
+
return { job: created, pr: { number: options.prNumber, title: created.title, body: created.objective ?? '', state: 'OPEN', head: '', base: 'main' } };
|
|
81
116
|
}
|
|
82
117
|
finally {
|
|
83
118
|
local.relay.close();
|
package/dist/cli.js
CHANGED
|
@@ -23,7 +23,7 @@ import { applyJobActivateAction, applyJobCancelAction } from './job-actions.js';
|
|
|
23
23
|
import { resolveExecutionContext } from './execution-context.js';
|
|
24
24
|
import { startServiceExternalActionFlusher } from './service-external-actions.js';
|
|
25
25
|
import { handleServicePublication } from './service-publication.js';
|
|
26
|
-
import { defaultWorkspaceRoot, prepareLocalJobExecutionWorktree, resolveManagedProjectPath, resolveRepositoryProjectPath } from './workspace.js';
|
|
26
|
+
import { defaultWorkspaceRoot, ensureLocalJobSourceBranch, jobRequiresManagedWorktree, prepareLocalJobExecutionWorktree, resolveManagedProjectPath, resolveRepositoryProjectPath } from './workspace.js';
|
|
27
27
|
import { runBlockerCommand } from './cli-blocker.js';
|
|
28
28
|
import { runEscalationCommand } from './cli-escalation.js';
|
|
29
29
|
import { runProjectionCommand } from './cli-projection.js';
|
|
@@ -363,7 +363,7 @@ async function runContextCommand(action, args) {
|
|
|
363
363
|
console.log(JSON.stringify(context, null, 2));
|
|
364
364
|
}
|
|
365
365
|
async function runJobCommand(action, args) {
|
|
366
|
-
if (!['create', 'update', 'show', 'list', 'merge', 'unblock', 'cancel'].includes(action))
|
|
366
|
+
if (!['create', 'adopt', 'update', 'show', 'list', 'merge', 'unblock', 'cancel'].includes(action))
|
|
367
367
|
throw new Error(usage);
|
|
368
368
|
const repo = repository(args);
|
|
369
369
|
let createObjective;
|
|
@@ -375,8 +375,23 @@ async function runJobCommand(action, args) {
|
|
|
375
375
|
if (invalid)
|
|
376
376
|
throw new Error(`job create does not accept task execution option ${invalid}; create the job first, then let the orchestrator create the initial plan/task batch`);
|
|
377
377
|
createObjective = await textOption(args, '--objective', '--objective-file');
|
|
378
|
-
if (
|
|
379
|
-
throw new Error('job create
|
|
378
|
+
if (!createObjective.trim())
|
|
379
|
+
throw new Error('job create requires --objective-file PATH|- or --objective MARKDOWN containing the complete standalone Job context');
|
|
380
|
+
const legacy = ['--pr', '--head', '--base', '--body', '--local-merge-source', '--local-merge-target', '--idempotency-key'].find(flag => hasArg(args, flag));
|
|
381
|
+
if (legacy)
|
|
382
|
+
throw new Error(`job create does not accept ${legacy}; use job adopt only for existing PR bindings`);
|
|
383
|
+
}
|
|
384
|
+
if (action === 'adopt') {
|
|
385
|
+
const adoptId = arg(args, '--id');
|
|
386
|
+
const adoptPr = Number(arg(args, '--pr'));
|
|
387
|
+
if (!adoptId || !Number.isInteger(adoptPr) || adoptPr <= 0)
|
|
388
|
+
throw new Error('job adopt requires --id and a positive --pr');
|
|
389
|
+
const objective = await textOption(args, '--objective', '--objective-file');
|
|
390
|
+
const result = await relayControlCommand(args, { command: 'job.adopt', repository: repo, options: {
|
|
391
|
+
id: adoptId, prNumber: adoptPr, title: arg(args, '--title') || undefined, objective: objective || undefined, projectPath: arg(args, '--project-path') || undefined,
|
|
392
|
+
} });
|
|
393
|
+
console.log(JSON.stringify(result, null, 2));
|
|
394
|
+
return;
|
|
380
395
|
}
|
|
381
396
|
if (action === 'list') {
|
|
382
397
|
console.log(JSON.stringify(await relayJobList(args), null, 2));
|
|
@@ -397,8 +412,6 @@ async function runJobCommand(action, args) {
|
|
|
397
412
|
id: id || undefined, idempotencyKey: arg(args, '--idempotency-key') || undefined, title: arg(args, '--title') || undefined, objective: objective || undefined, projectPath: arg(args, '--project-path') || undefined,
|
|
398
413
|
priority: priority(args), executionMode: requestedExecutionMode(args), schedule: scheduleFromArgs(args), tag: jobTagFromArgs(args), reviewEnabled: !hasArg(args, '--no-review'), continuation: continuation(args),
|
|
399
414
|
reviewerThreadId: hasArg(args, '--reviewer-thread-id') ? arg(args, '--reviewer-thread-id') : undefined, creatorThreadId: hasArg(args, '--creator-thread-id') ? arg(args, '--creator-thread-id') : undefined, implementationThreadId: hasArg(args, '--implementation-thread-id') ? arg(args, '--implementation-thread-id') : undefined,
|
|
400
|
-
prNumber, head: arg(args, '--head') || undefined, base: arg(args, '--base', 'main'), body: arg(args, '--body') || objective || undefined,
|
|
401
|
-
localMergeSource: arg(args, '--local-merge-source') || undefined, localMergeTarget: arg(args, '--local-merge-target') || undefined,
|
|
402
415
|
} });
|
|
403
416
|
console.log(JSON.stringify(result, null, 2));
|
|
404
417
|
return;
|
|
@@ -478,7 +491,25 @@ async function runJobCommand(action, args) {
|
|
|
478
491
|
console.log(JSON.stringify(await relayControlCommand(args, { command: 'job.update', jobId: id, patch }), null, 2));
|
|
479
492
|
}
|
|
480
493
|
export function runtimePlanner(args) { const plannerCommand = arg([...args], '--planner-command'); const modelRuntime = arg([...args], '--codex', 'codex'); return plannerCommand ? new CommandObjectivePlanner(plannerCommand) : new AgentObjectivePlanner(modelRuntime); }
|
|
481
|
-
export function runtime(store, args, bus, usageRegistry = codexAndZaiUsageRegistry()) {
|
|
494
|
+
export function runtime(store, args, bus, usageRegistry = codexAndZaiUsageRegistry()) {
|
|
495
|
+
const emit = (event) => { process.stderr.write(`${JSON.stringify(event)}\n`); };
|
|
496
|
+
const modelRuntime = arg(args, '--codex', 'codex');
|
|
497
|
+
const policy = chatGptTabClosePolicy(args);
|
|
498
|
+
const planner = runtimePlanner(args);
|
|
499
|
+
return new Reconciler(store, { noGitHub: noGitHubMode(args), owner: arg(args, '--owner', `cli-${process.pid}`), maxConcurrent: Number(arg(args, '--concurrency', '4')), leaseMs: Number(arg(args, '--lease-ms', '300000')), adapters: [new CodexAdapter(modelRuntime), new ChatGptAdapter({ tabClosePolicy: policy })], continuations: [new CodexThreadContinuation(modelRuntime), new CommandContinuation(), new WebhookContinuation()], planner, usageSnapshotProvider: () => usageRegistry.snapshot(), decisionEngine: executionDecisionEngine(), decisionMode: decisionMode(), reviewer: runtimeReviewer(args), eventBus: bus, emit, chatgptTabClosePolicy: policy, projectPathForJob: async (job) => {
|
|
500
|
+
const workspaceRoot = arg(args, '--workspace-root') || defaultWorkspaceRoot();
|
|
501
|
+
const canonical = await resolveManagedProjectPath(noGitHubMode(args) ? undefined : job.repository, arg(args, '--project-path'), workspaceRoot);
|
|
502
|
+
if (!jobRequiresManagedWorktree(job))
|
|
503
|
+
return resolveRepositoryProjectPath(canonical, job.projectPath);
|
|
504
|
+
const sourceRef = await ensureLocalJobSourceBranch(canonical, job);
|
|
505
|
+
if (!job.localMerge?.sourceRef) {
|
|
506
|
+
if (!store.mutateJob)
|
|
507
|
+
throw new Error('Managed worktree requires atomic SQLite Job mutation');
|
|
508
|
+
await store.mutateJob(job.id, current => current.localMerge ? current : ({ ...current, localMerge: { sourceRef, targetRef: 'refs/heads/main', status: 'pending' }, updatedAt: new Date().toISOString() }));
|
|
509
|
+
}
|
|
510
|
+
return prepareLocalJobExecutionWorktree(canonical, await store.load(job.id), workspaceRoot);
|
|
511
|
+
} });
|
|
512
|
+
}
|
|
482
513
|
export function dashboardJobAction(store, reconciler, action) {
|
|
483
514
|
return async (jobId) => {
|
|
484
515
|
const job = await store.load(jobId);
|
|
@@ -221,6 +221,15 @@ export class LocalAuthorityDurableStore {
|
|
|
221
221
|
return job;
|
|
222
222
|
});
|
|
223
223
|
}
|
|
224
|
+
async createJobIfAbsent(job) {
|
|
225
|
+
if (this.authority.createJobIfAbsent)
|
|
226
|
+
return this.authority.createJobIfAbsent(job);
|
|
227
|
+
const existing = await this.authority.loadJob(job.id);
|
|
228
|
+
if (existing)
|
|
229
|
+
return existing;
|
|
230
|
+
await this.authority.saveJob(job);
|
|
231
|
+
return structuredClone(job);
|
|
232
|
+
}
|
|
224
233
|
async savePlan(plan) {
|
|
225
234
|
await this.mutate(plan.jobId, job => {
|
|
226
235
|
const plans = [...(job.plans ?? [])];
|
package/dist/projected-store.js
CHANGED
|
@@ -81,11 +81,13 @@ export class GitHubProjectionWriter {
|
|
|
81
81
|
github;
|
|
82
82
|
onConflict;
|
|
83
83
|
externalOnly;
|
|
84
|
-
|
|
84
|
+
externalEffect;
|
|
85
|
+
constructor(relay, github, onConflict, externalOnly = false, externalEffect) {
|
|
85
86
|
this.relay = relay;
|
|
86
87
|
this.github = github;
|
|
87
88
|
this.onConflict = onConflict;
|
|
88
89
|
this.externalOnly = externalOnly;
|
|
90
|
+
this.externalEffect = externalEffect;
|
|
89
91
|
}
|
|
90
92
|
async flushOnce(limit = 50, now = new Date()) {
|
|
91
93
|
const items = await this.relay.claimDueProjections(limit, now);
|
|
@@ -93,7 +95,7 @@ export class GitHubProjectionWriter {
|
|
|
93
95
|
let deferred = 0;
|
|
94
96
|
for (const item of items) {
|
|
95
97
|
try {
|
|
96
|
-
if (this.externalOnly && !['merge_pull_request', 'close_pull_request', 'update_pr_body'].includes(item.operation)) {
|
|
98
|
+
if (this.externalOnly && !['merge_pull_request', 'close_pull_request', 'update_pr_body', 'create_pull_request'].includes(item.operation)) {
|
|
97
99
|
// Drop legacy lifecycle projections; GitHub may not override SQLite truth.
|
|
98
100
|
await this.relay.completeProjection(item.id);
|
|
99
101
|
continue;
|
|
@@ -109,7 +111,7 @@ export class GitHubProjectionWriter {
|
|
|
109
111
|
observedAt: new Date().toISOString(),
|
|
110
112
|
});
|
|
111
113
|
}
|
|
112
|
-
await this.relay.
|
|
114
|
+
await this.relay.completeProjectionWithReceipt(item.id, item.operationKey, 'succeeded', item.attempts, { operation: item.operation });
|
|
113
115
|
projected += 1;
|
|
114
116
|
observability().record({
|
|
115
117
|
stage: 'projection', event: 'projection.completed', outcome: 'succeeded',
|
|
@@ -134,7 +136,7 @@ export class GitHubProjectionWriter {
|
|
|
134
136
|
}
|
|
135
137
|
else {
|
|
136
138
|
const retryAt = githubProjectionRetryAt(error, item.attempts, now);
|
|
137
|
-
await this.relay.
|
|
139
|
+
await this.relay.retryProjectionWithReceipt(item.id, item.operationKey, message, retryAt, item.attempts + 1, { operation: item.operation, error: message });
|
|
138
140
|
deferred += 1;
|
|
139
141
|
observability().record({
|
|
140
142
|
stage: 'projection', event: 'projection.retry_scheduled', level: 'warn', outcome: 'failed',
|
|
@@ -157,6 +159,12 @@ export class GitHubProjectionWriter {
|
|
|
157
159
|
}
|
|
158
160
|
}
|
|
159
161
|
async apply(item) {
|
|
162
|
+
if (item.operation === 'create_pull_request') {
|
|
163
|
+
if (!this.externalEffect)
|
|
164
|
+
throw new Error('GitHub PR creation effect is unavailable');
|
|
165
|
+
await this.externalEffect(item);
|
|
166
|
+
return;
|
|
167
|
+
}
|
|
160
168
|
const payload = item.payload;
|
|
161
169
|
switch (payload.operation) {
|
|
162
170
|
case 'save_job':
|
|
@@ -87,6 +87,15 @@ export class ProjectionOutbox {
|
|
|
87
87
|
CREATE INDEX IF NOT EXISTS projection_coalesce_idx ON projection_outbox(coalesce_key, status);
|
|
88
88
|
`;
|
|
89
89
|
}
|
|
90
|
+
static createReceiptTableSql() {
|
|
91
|
+
return `CREATE TABLE IF NOT EXISTS projection_receipt (
|
|
92
|
+
operation_key TEXT PRIMARY KEY,
|
|
93
|
+
status TEXT NOT NULL CHECK(status IN ('succeeded','failed')),
|
|
94
|
+
attempts INTEGER NOT NULL,
|
|
95
|
+
detail_json TEXT,
|
|
96
|
+
recorded_at TEXT NOT NULL
|
|
97
|
+
);`;
|
|
98
|
+
}
|
|
90
99
|
/** Rebuild a pre-v5 outbox, backfill logical operation keys, and classify legacy dead conflicts. */
|
|
91
100
|
static upgrade(db, previousVersion) {
|
|
92
101
|
const schema = db.prepare("SELECT sql FROM sqlite_master WHERE type='table' AND name='projection_outbox'").get();
|
|
@@ -206,6 +215,32 @@ export class ProjectionOutbox {
|
|
|
206
215
|
complete(id) {
|
|
207
216
|
this.db.prepare('DELETE FROM projection_outbox WHERE id=?').run(id);
|
|
208
217
|
}
|
|
218
|
+
completeWithReceipt(id, operationKey, status, attempts, detail, now) {
|
|
219
|
+
this.db.exec('BEGIN IMMEDIATE');
|
|
220
|
+
try {
|
|
221
|
+
this.recordReceipt(operationKey, status, attempts, detail, now);
|
|
222
|
+
this.db.prepare('DELETE FROM projection_outbox WHERE id=?').run(id);
|
|
223
|
+
this.db.exec('COMMIT');
|
|
224
|
+
}
|
|
225
|
+
catch (error) {
|
|
226
|
+
this.db.exec('ROLLBACK');
|
|
227
|
+
throw error;
|
|
228
|
+
}
|
|
229
|
+
}
|
|
230
|
+
retryWithReceipt(id, operationKey, error, nextAttemptAt, attempts, detail, now) {
|
|
231
|
+
this.db.exec('BEGIN IMMEDIATE');
|
|
232
|
+
try {
|
|
233
|
+
this.db.prepare(`UPDATE projection_outbox
|
|
234
|
+
SET status='pending', attempts=?, next_attempt_at=?, last_error=?, updated_at=? WHERE id=?`)
|
|
235
|
+
.run(attempts, nextAttemptAt.toISOString(), error, now, id);
|
|
236
|
+
this.recordReceipt(operationKey, 'failed', attempts, detail, now);
|
|
237
|
+
this.db.exec('COMMIT');
|
|
238
|
+
}
|
|
239
|
+
catch (caught) {
|
|
240
|
+
this.db.exec('ROLLBACK');
|
|
241
|
+
throw caught;
|
|
242
|
+
}
|
|
243
|
+
}
|
|
209
244
|
retry(id, error, nextAttemptAt) {
|
|
210
245
|
this.db.prepare(`
|
|
211
246
|
UPDATE projection_outbox
|
|
@@ -220,6 +255,11 @@ export class ProjectionOutbox {
|
|
|
220
255
|
WHERE id=?
|
|
221
256
|
`).run(error, new Date().toISOString(), id);
|
|
222
257
|
}
|
|
258
|
+
recordReceipt(operationKey, status, attempts, detail, now) {
|
|
259
|
+
this.db.prepare(`INSERT INTO projection_receipt(operation_key,status,attempts,detail_json,recorded_at)
|
|
260
|
+
VALUES(?,?,?,?,?) ON CONFLICT(operation_key) DO UPDATE SET status=excluded.status, attempts=excluded.attempts,
|
|
261
|
+
detail_json=excluded.detail_json, recorded_at=excluded.recorded_at`).run(operationKey, status, attempts, JSON.stringify(detail ?? null), now);
|
|
262
|
+
}
|
|
223
263
|
recordConflict(id, diagnostic) {
|
|
224
264
|
const evidence = JSON.stringify({
|
|
225
265
|
kind: diagnostic.kind,
|
|
@@ -0,0 +1,18 @@
|
|
|
1
|
+
import { normalizeLegacyJobPrNumber } from './job-pr-number-migration.js';
|
|
2
|
+
export function createJobIfAbsent(db, job) {
|
|
3
|
+
db.exec('BEGIN IMMEDIATE');
|
|
4
|
+
try {
|
|
5
|
+
const row = db.prepare('SELECT json FROM job_snapshot WHERE job_id=?').get(job.id);
|
|
6
|
+
if (row?.json) {
|
|
7
|
+
db.exec('COMMIT');
|
|
8
|
+
return normalizeLegacyJobPrNumber(JSON.parse(row.json));
|
|
9
|
+
}
|
|
10
|
+
db.prepare('INSERT INTO job_snapshot(job_id,json,updated_at) VALUES(?,?,?)').run(job.id, JSON.stringify(job), new Date().toISOString());
|
|
11
|
+
db.exec('COMMIT');
|
|
12
|
+
return structuredClone(job);
|
|
13
|
+
}
|
|
14
|
+
catch (error) {
|
|
15
|
+
db.exec('ROLLBACK');
|
|
16
|
+
throw error;
|
|
17
|
+
}
|
|
18
|
+
}
|
package/dist/relay-store.js
CHANGED
|
@@ -5,6 +5,7 @@ import { mkdirSync } from 'node:fs';
|
|
|
5
5
|
import { readdir } from 'node:fs/promises';
|
|
6
6
|
import { recordPublicationInDatabase, loadPublicationFromDatabase, listPublicationEventsFromDatabase } from './publication-store.js';
|
|
7
7
|
import { observability } from './observability.js';
|
|
8
|
+
import { createJobIfAbsent } from './relay-create.js';
|
|
8
9
|
import { migrateLegacyJobPrNumbers, normalizeLegacyJobPrNumber } from './job-pr-number-migration.js';
|
|
9
10
|
import { hostInvocationTablesSql, recordHostInvocationEvent, hasHostInvocationDecision, recordHostInvocationDecision } from './host-invocation-store.js';
|
|
10
11
|
import { ProjectionOutbox, SCHEMA_VERSION, logicalOperationKey, } from './projection-outbox.js';
|
|
@@ -101,6 +102,7 @@ export class SQLiteRelayStore {
|
|
|
101
102
|
);
|
|
102
103
|
${hostInvocationTablesSql}
|
|
103
104
|
${ProjectionOutbox.createTableSql()}
|
|
105
|
+
${ProjectionOutbox.createReceiptTableSql()}
|
|
104
106
|
${ProjectionOutbox.createIndexSql()}
|
|
105
107
|
`);
|
|
106
108
|
migrateLegacyJobPrNumbers(this.db);
|
|
@@ -133,6 +135,9 @@ export class SQLiteRelayStore {
|
|
|
133
135
|
async saveJob(job) {
|
|
134
136
|
this.writeJobSnapshot(job, new Date().toISOString());
|
|
135
137
|
}
|
|
138
|
+
async createJobIfAbsent(job) {
|
|
139
|
+
return createJobIfAbsent(this.db, job);
|
|
140
|
+
}
|
|
136
141
|
/** Atomic read/modify/write for local authoritative Task and Plan updates. */
|
|
137
142
|
async mutateJob(jobId, mutation) {
|
|
138
143
|
this.db.exec('BEGIN IMMEDIATE');
|
|
@@ -343,13 +348,13 @@ export class SQLiteRelayStore {
|
|
|
343
348
|
* must never reset backoff or queue a second merge while a first one runs.
|
|
344
349
|
*/
|
|
345
350
|
async enqueueExternalActionOnce(request) {
|
|
346
|
-
if (!['merge_pull_request', 'close_pull_request', 'update_pr_body'].includes(request.operation)) {
|
|
351
|
+
if (!['merge_pull_request', 'close_pull_request', 'update_pr_body', 'create_pull_request'].includes(request.operation)) {
|
|
347
352
|
throw new Error('Lifecycle projection is forbidden in external-only outbox');
|
|
348
353
|
}
|
|
349
354
|
const key = logicalOperationKey(request.idempotencyKey);
|
|
350
355
|
this.db.exec('BEGIN IMMEDIATE');
|
|
351
356
|
try {
|
|
352
|
-
const existing = this.db.prepare('SELECT id FROM projection_outbox WHERE operation_key=? LIMIT 1').get(key);
|
|
357
|
+
const existing = this.db.prepare('SELECT id FROM projection_outbox WHERE operation_key=? LIMIT 1').get(key) ?? this.db.prepare('SELECT operation_key FROM projection_receipt WHERE operation_key=?').get(key);
|
|
353
358
|
if (existing) {
|
|
354
359
|
this.db.exec('COMMIT');
|
|
355
360
|
return false;
|
|
@@ -367,7 +372,13 @@ export class SQLiteRelayStore {
|
|
|
367
372
|
return this.outbox.claimDue(limit, now);
|
|
368
373
|
}
|
|
369
374
|
async completeProjection(id) { this.outbox.complete(id); }
|
|
375
|
+
async completeProjectionWithReceipt(id, operationKey, status, attempts, detail) {
|
|
376
|
+
this.outbox.completeWithReceipt(id, operationKey, status, attempts, detail, new Date().toISOString());
|
|
377
|
+
}
|
|
370
378
|
async retryProjection(id, error, nextAttemptAt) { this.outbox.retry(id, error, nextAttemptAt); }
|
|
379
|
+
async retryProjectionWithReceipt(id, operationKey, error, nextAttemptAt, attempts, detail) {
|
|
380
|
+
this.outbox.retryWithReceipt(id, operationKey, error, nextAttemptAt, attempts, detail, new Date().toISOString());
|
|
381
|
+
}
|
|
371
382
|
async deadLetterProjection(id, error) { this.outbox.deadLetter(id, error); }
|
|
372
383
|
async recordProjectionConflict(id, diagnostic) { this.outbox.recordConflict(id, diagnostic); }
|
|
373
384
|
async listProjectionConflicts() { return this.outbox.listConflicts(); }
|
|
@@ -375,6 +386,7 @@ export class SQLiteRelayStore {
|
|
|
375
386
|
async conflictProjectionCount() { return this.outbox.conflictCount(); }
|
|
376
387
|
async pendingProjectionCount() { return this.outbox.pendingCount(); }
|
|
377
388
|
async terminalProjectionCount() { return this.outbox.terminalCount(); }
|
|
389
|
+
async recordProjectionReceipt(operationKey, status, attempts, detail) { this.outbox.recordReceipt(operationKey, status, attempts, detail, new Date().toISOString()); }
|
|
378
390
|
close() { this.db.close(); }
|
|
379
391
|
}
|
|
380
392
|
/** Canonical on-disk Relay Store location for one managed GitHub pull request. */
|
package/dist/relayd-control.js
CHANGED
|
@@ -1,6 +1,10 @@
|
|
|
1
1
|
import { appendTaskComment } from './task-comments.js';
|
|
2
|
+
import { InMemoryStore } from './store.js';
|
|
3
|
+
import { openLocalManagedStore } from './local-managed-store.js';
|
|
4
|
+
import { relayStoreDirectory } from './relay-store.js';
|
|
2
5
|
import { normalizeThreadId } from './reviewer-thread.js';
|
|
3
6
|
import { supersedeCancelledTask } from './task-actions.js';
|
|
7
|
+
import { adoptLegacyJob } from './cli-job-create.js';
|
|
4
8
|
export function createWorkspaceControl(deps) {
|
|
5
9
|
const { client, trusted, noGitHub, repositories, refreshAll, requireLocalJobBinding, makeReconciler, concurrency, pool, bus, isEffectivelyBlocked, unblockAutonomousTasks, unblockTask, publishTaskUnblocked, dispatchTaskAction, registerBlockerDependency, discoverAgents, ensureManagedGitHubJob } = deps;
|
|
6
10
|
const workspaceControl = async (body) => {
|
|
@@ -25,31 +29,36 @@ export function createWorkspaceControl(deps) {
|
|
|
25
29
|
tag: options.tag,
|
|
26
30
|
reviewEnabled: typeof options.reviewEnabled === 'boolean' ? options.reviewEnabled : undefined,
|
|
27
31
|
continuation: options.continuation,
|
|
28
|
-
prNumber: typeof options.prNumber === 'number' ? options.prNumber : undefined,
|
|
29
|
-
head: typeof options.head === 'string' ? options.head : undefined,
|
|
30
|
-
base: typeof options.base === 'string' ? options.base : undefined,
|
|
31
|
-
body: typeof options.body === 'string' ? options.body : undefined,
|
|
32
32
|
reviewerThreadId: typeof options.reviewerThreadId === 'string' ? options.reviewerThreadId : undefined,
|
|
33
33
|
creatorThreadId: typeof options.creatorThreadId === 'string' ? options.creatorThreadId : undefined,
|
|
34
34
|
implementationThreadId: typeof options.implementationThreadId === 'string' ? options.implementationThreadId : undefined,
|
|
35
35
|
noGitHub,
|
|
36
|
-
|
|
37
|
-
localMergeTarget: typeof options.localMergeTarget === 'string' ? options.localMergeTarget : undefined,
|
|
36
|
+
persist: body.initialPlan !== true,
|
|
38
37
|
});
|
|
39
38
|
if (!repositories.includes(repository))
|
|
40
39
|
repositories.push(repository);
|
|
41
40
|
await refreshAll();
|
|
42
41
|
if (body.initialPlan === true) {
|
|
43
|
-
const
|
|
44
|
-
const current = await binding.store.load(result.job.id);
|
|
42
|
+
const current = result.job;
|
|
45
43
|
const existingPlan = current.plans?.at(-1);
|
|
46
44
|
if (existingPlan)
|
|
47
45
|
return { repository, pr: result.pr.number, job: current.id, executionMode: current.executionMode, state: current.state, initialPlan: existingPlan.id, reused: true };
|
|
48
|
-
const
|
|
46
|
+
const unpublished = new InMemoryStore(current);
|
|
47
|
+
const reconciler = makeReconciler(unpublished, concurrency);
|
|
49
48
|
try {
|
|
50
49
|
const planned = await reconciler.planJob(result.job.id);
|
|
50
|
+
const unpublishedStore = openLocalManagedStore(repository, 0, { directory: relayStoreDirectory(), jobId: result.job.id, noGitHub: true });
|
|
51
|
+
let committed = planned.job;
|
|
52
|
+
try {
|
|
53
|
+
committed = unpublishedStore.store.createJobIfAbsent
|
|
54
|
+
? await unpublishedStore.store.createJobIfAbsent(planned.job)
|
|
55
|
+
: (await unpublishedStore.store.saveJob(planned.job), planned.job);
|
|
56
|
+
}
|
|
57
|
+
finally {
|
|
58
|
+
unpublishedStore.relay.close();
|
|
59
|
+
}
|
|
51
60
|
await pool.reconcileLocalJob(result.job.id, 'control.job.create.initial-plan');
|
|
52
|
-
return { repository, pr:
|
|
61
|
+
return { repository, pr: committed.prNumber, job: committed.id, executionMode: committed.executionMode, state: committed.state, initialPlan: committed.plans?.at(-1)?.id };
|
|
53
62
|
}
|
|
54
63
|
finally {
|
|
55
64
|
await reconciler.close();
|
|
@@ -57,6 +66,22 @@ export function createWorkspaceControl(deps) {
|
|
|
57
66
|
}
|
|
58
67
|
return { repository, pr: result.pr.number, job: result.job.id, executionMode: result.job.executionMode, state: result.job.state };
|
|
59
68
|
}
|
|
69
|
+
if (command === 'job.adopt') {
|
|
70
|
+
const repository = typeof body.repository === 'string' ? body.repository : '';
|
|
71
|
+
const options = (body.options && typeof body.options === 'object' ? body.options : {});
|
|
72
|
+
if (!repository || typeof options.id !== 'string' || typeof options.prNumber !== 'number')
|
|
73
|
+
throw new Error('job.adopt requires repository, id, and prNumber');
|
|
74
|
+
const result = await adoptLegacyJob(client, repository, trusted, {
|
|
75
|
+
id: options.id, title: typeof options.title === 'string' ? options.title : undefined,
|
|
76
|
+
objective: typeof options.objective === 'string' ? options.objective : undefined,
|
|
77
|
+
projectPath: typeof options.projectPath === 'string' ? options.projectPath : undefined,
|
|
78
|
+
prNumber: options.prNumber,
|
|
79
|
+
});
|
|
80
|
+
if (!repositories.includes(repository))
|
|
81
|
+
repositories.push(repository);
|
|
82
|
+
await refreshAll();
|
|
83
|
+
return { repository, pr: result.pr.number, job: result.job.id, state: result.job.state };
|
|
84
|
+
}
|
|
60
85
|
if (!jobId)
|
|
61
86
|
throw new Error(command + ' requires jobId');
|
|
62
87
|
const binding = await requireLocalJobBinding(jobId);
|
package/dist/relayd.js
CHANGED
|
@@ -34,7 +34,7 @@ import { applyJobActivateAction } from './job-actions.js';
|
|
|
34
34
|
import { managedTaskUpdateHandler } from './task-update.js';
|
|
35
35
|
import { dashboardJobSetState, dashboardTaskAction, dashboardTaskSetState } from './dashboard-actions.js';
|
|
36
36
|
import { codexAndZaiUsageRegistry } from './usage.js';
|
|
37
|
-
import { defaultWorkspaceRoot, discoverWorkspaceRepositories, ensureLocalJobSourceBranch, prepareLocalJobExecutionWorktree, resolveManagedProjectPath, resolveRepositoryProjectPath } from './workspace.js';
|
|
37
|
+
import { defaultWorkspaceRoot, discoverWorkspaceRepositories, ensureLocalJobSourceBranch, jobRequiresManagedWorktree, prepareLocalJobExecutionWorktree, resolveManagedProjectPath, resolveRepositoryProjectPath } from './workspace.js';
|
|
38
38
|
import { deriveJobRuntimeStatus } from './runtime-status.js';
|
|
39
39
|
import { createRuntimeStatusPublisher } from './runtime-status-events.js';
|
|
40
40
|
import { subscribeLocalCommits } from './local-commit-events.js';
|
|
@@ -154,7 +154,7 @@ export async function runDaemon(argv) {
|
|
|
154
154
|
cachedOverviews = await inventoryFromLocal();
|
|
155
155
|
const makeReconciler = (store, maxConcurrent) => new Reconciler(store, { noGitHub, owner: `relayd-${process.pid}`, maxConcurrent, leaseMs: Number(value(argv, '--lease-ms', '300000')), adapters: [new CodexAdapter(value(argv, '--codex', 'codex')), new ChatGptAdapter()], continuations: [new CodexThreadContinuation(value(argv, '--codex', 'codex')), new CommandContinuation(), new WebhookContinuation()], planner: runtimePlanner(argv), usageSnapshotProvider: () => usageRegistry.snapshot(), runtimeSummaryProvider: () => ({ chatgptRunning: summarizeRuntime(cachedOverviews.map(item => item.job)).workers.chatgpt }), decisionEngine: executionDecisionEngine(), decisionMode: decisionMode(), decisionAuditStore, eventBus: bus, reviewer: runtimeReviewer(argv), projectPathForJob: async (job) => {
|
|
156
156
|
const canonical = repositoryPaths.get(job.repository) ?? await resolveManagedProjectPath(job.repository, explicitProjectPath, workspaceRoot);
|
|
157
|
-
if (!job
|
|
157
|
+
if (!jobRequiresManagedWorktree(job))
|
|
158
158
|
return resolveRepositoryProjectPath(canonical, job.projectPath);
|
|
159
159
|
const sourceRef = await ensureLocalJobSourceBranch(canonical, job);
|
|
160
160
|
if (!store.mutateJob)
|
|
@@ -222,12 +222,9 @@ export async function runDaemon(argv) {
|
|
|
222
222
|
const snapshot = { path: candidate.snapshotPath };
|
|
223
223
|
const discovered = candidate.job;
|
|
224
224
|
try {
|
|
225
|
-
if (discovered.state === 'CANCELLED')
|
|
226
|
-
continue;
|
|
227
225
|
if (!repositoryPaths.has(discovered.repository) || !discovered.localMerge?.sourceRef || !discovered.localMerge?.targetRef)
|
|
228
226
|
continue;
|
|
229
|
-
const sourceRef = discovered.localMerge.sourceRef;
|
|
230
|
-
const targetRef = discovered.localMerge.targetRef;
|
|
227
|
+
const sourceRef = discovered.localMerge.sourceRef, targetRef = discovered.localMerge.targetRef;
|
|
231
228
|
if (!sourceRef.startsWith('refs/heads/') || !targetRef.startsWith('refs/heads/'))
|
|
232
229
|
continue;
|
|
233
230
|
if (targetRef !== 'refs/heads/main')
|
|
@@ -244,68 +241,83 @@ export async function runDaemon(argv) {
|
|
|
244
241
|
if (discovered.state === 'COMPLETED' && discovered.localMerge?.status === 'succeeded'
|
|
245
242
|
&& discovered.prNumber <= 0) {
|
|
246
243
|
const published = projectAuthoritativeLocalMerge(canonical, discovered);
|
|
244
|
+
if (relay.mutateJob && (discovered.githubProjection?.status !== 'merged_without_pr' || discovered.githubProjection.sourceSha !== published.localMergedCommitSha))
|
|
245
|
+
await relay.mutateJob(discovered.id, current => ({ ...current, githubProjection: { status: 'merged_without_pr', sourceSha: published.localMergedCommitSha, observedAt: new Date().toISOString() } }));
|
|
247
246
|
if (published.status === 'projected')
|
|
248
247
|
await bus?.publish(eventFor(discovered.id, 'projection', null, 'projection.delivered', 'succeeded', `GitHub main synchronization ${published.status}`, 'orchestrator', { operation: 'github.syncMain', deliveryStatus: published.status, mergedCommitSha: published.localMergedCommitSha }, { agent: 'relay', component: 'github-projection' }));
|
|
249
248
|
continue;
|
|
250
249
|
}
|
|
251
|
-
const existingPrNumber = discovered.prNumber > 0 ? discovered.prNumber : 0;
|
|
252
250
|
const terminal = ['COMPLETED', 'CANCELLED'].includes(discovered.state);
|
|
253
|
-
if (terminal && existingPrNumber <= 0)
|
|
254
|
-
continue;
|
|
255
251
|
const sourceSha = await gitText(canonical, ['rev-parse', '--verify', sourceRef]).catch(() => '');
|
|
256
252
|
if (!sourceSha)
|
|
257
253
|
continue;
|
|
258
|
-
const facts = await relay.loadPullRequestFacts();
|
|
259
|
-
if (facts?.head?.sha !== sourceSha) {
|
|
260
|
-
await gitText(canonical, ['fetch', '--no-tags', 'origin',
|
|
261
|
-
'+refs/heads/main:refs/remotes/origin/main']);
|
|
262
|
-
await gitText(canonical, ['fetch', '--no-tags', 'origin',
|
|
263
|
-
`+refs/heads/${branch}:refs/remotes/origin/${branch}`]).catch(() => '');
|
|
264
|
-
const remoteRef = `refs/remotes/origin/${branch}`;
|
|
265
|
-
const remoteHeadSha = await gitText(canonical, ['rev-parse', '--verify', remoteRef]).catch(() => '');
|
|
266
|
-
const remoteTreeMatchesBase = remoteHeadSha
|
|
267
|
-
? await gitText(canonical, ['diff', '--quiet', 'refs/remotes/origin/main', remoteRef]).then(() => true, () => false)
|
|
268
|
-
: false;
|
|
269
|
-
await gitText(canonical, authoritativeBranchPushArgs({
|
|
270
|
-
sourceRef,
|
|
271
|
-
branch,
|
|
272
|
-
remoteHeadSha: remoteHeadSha || null,
|
|
273
|
-
remoteTreeMatchesBase,
|
|
274
|
-
}));
|
|
275
|
-
}
|
|
276
254
|
let job = (await relay.loadJob(discovered.id)) ?? discovered;
|
|
277
255
|
let prNumber = job.prNumber > 0 ? job.prNumber : 0;
|
|
278
|
-
|
|
279
|
-
|
|
256
|
+
// The callback is part of the durable create intent, not daemon memory.
|
|
257
|
+
// Rebuild it on every scan so a reclaimed row remains executable after a
|
|
258
|
+
// crash, including when the binding mutation committed first.
|
|
259
|
+
const effect = async (item) => {
|
|
260
|
+
const payload = item.payload;
|
|
261
|
+
await gitText(canonical, ['fetch', '--no-tags', 'origin', `+refs/heads/${payload.base}:refs/remotes/origin/${payload.base}`]);
|
|
262
|
+
const remoteRef = `refs/remotes/origin/${payload.head}`;
|
|
263
|
+
const remoteHeadSha = await gitText(canonical, ['rev-parse', '--verify', remoteRef]).catch(() => '');
|
|
264
|
+
const remoteTreeMatchesBase = remoteHeadSha ? await gitText(canonical, ['diff', '--quiet', `refs/remotes/origin/${payload.base}`, remoteRef]).then(() => true, () => false) : false;
|
|
265
|
+
await gitText(canonical, authoritativeBranchPushArgs({ sourceRef: payload.sourceRef, branch: payload.head, remoteHeadSha: remoteHeadSha || null, remoteTreeMatchesBase }));
|
|
266
|
+
// Lookup by head/base first: a crash after GitHub created the PR must
|
|
267
|
+
// bind the existing collaboration projection rather than create one.
|
|
268
|
+
const pr = await resolvePullRequest(client, payload.repository, { head: payload.head, base: payload.base, title: payload.title, body: payload.body });
|
|
269
|
+
if (!relay.mutateJob)
|
|
270
|
+
throw new Error('Atomic SQLite mutation is required for GitHub PR binding');
|
|
271
|
+
await relay.mutateJob(job.id, current => ({ ...current, prNumber: pr.number, githubProjection: { status: 'pr_bound', sourceSha, observedAt: new Date().toISOString() }, updatedAt: new Date().toISOString() }));
|
|
272
|
+
observability().record({ stage: 'projection', event: 'github.pr_bound', outcome: 'succeeded', message: `Bound local Job ${job.id} to GitHub PR #${pr.number}`, correlation: { repository: job.repository, prNumber: pr.number, jobId: job.id }, data: { head: branch, base } });
|
|
273
|
+
};
|
|
274
|
+
projectionWriters.set(`job:${job.id}`, new GitHubProjectionWriter(relay, prNumber > 0 ? new GitHubStore(client, job.repository, prNumber, trusted) : new InMemoryStore(job), undefined, true, effect));
|
|
275
|
+
if (job.state === 'CANCELLED')
|
|
276
|
+
continue; // Drain committed effects only; never push.
|
|
280
277
|
if (prNumber === 0) {
|
|
281
278
|
// Do not bootstrap an empty remote-only PR commit: it would diverge from
|
|
282
279
|
// the real local Job branch before its first Worker commit. Publish the
|
|
283
280
|
// review-only PR when there is an actual diff against main.
|
|
284
281
|
const ahead = Number(await gitText(canonical, ['rev-list', '--count', `${targetRef}..${sourceRef}`]));
|
|
285
|
-
if (!Number.isFinite(ahead) || ahead <= 0)
|
|
282
|
+
if (!Number.isFinite(ahead) || ahead <= 0) {
|
|
283
|
+
if (terminal && relay.mutateJob && job.githubProjection?.sourceSha !== sourceSha)
|
|
284
|
+
await relay.mutateJob(job.id, current => ({ ...current, githubProjection: { status: current.localMerge?.status === 'succeeded' ? 'merged_without_pr' : 'no_diff', sourceSha, observedAt: new Date().toISOString() } }));
|
|
286
285
|
continue;
|
|
286
|
+
}
|
|
287
287
|
// GitHub compares origin/main, not local main. Do not invoke the legacy
|
|
288
288
|
// empty-PR bootstrap path when the published base already contains the
|
|
289
289
|
// source head (for example when main was pushed by another Job).
|
|
290
290
|
await gitText(canonical, ['fetch', '--no-tags', 'origin', `+refs/heads/main:refs/remotes/origin/main`]);
|
|
291
291
|
const remoteAhead = Number(await gitText(canonical, ['rev-list', '--count', `refs/remotes/origin/main..${sourceRef}`]));
|
|
292
|
-
if (!Number.isFinite(remoteAhead) || remoteAhead <= 0)
|
|
292
|
+
if (!Number.isFinite(remoteAhead) || remoteAhead <= 0) {
|
|
293
|
+
if (terminal && relay.mutateJob && job.githubProjection?.sourceSha !== sourceSha)
|
|
294
|
+
await relay.mutateJob(job.id, current => ({ ...current, githubProjection: { status: 'merged_without_pr', sourceSha, observedAt: new Date().toISOString() } }));
|
|
293
295
|
continue;
|
|
294
|
-
|
|
295
|
-
|
|
296
|
-
|
|
297
|
-
|
|
298
|
-
|
|
299
|
-
|
|
296
|
+
}
|
|
297
|
+
// A terminal Job may drain an already-committed intent, but terminal
|
|
298
|
+
// state is never permission to create a new external PR intent.
|
|
299
|
+
if (!terminal && relay.enqueueExternalActionOnce)
|
|
300
|
+
await relay.enqueueExternalActionOnce({ idempotencyKey: `create_pull_request:${job.id}:${sourceSha}`, operation: 'create_pull_request', payload: { operation: 'create_pull_request', repository: job.repository, head: branch, base, title: job.title, body: job.objective ?? job.description ?? '', sourceRef, targetRef }, priority: 100 });
|
|
301
|
+
if (terminal)
|
|
302
|
+
continue;
|
|
303
|
+
continue;
|
|
300
304
|
}
|
|
301
305
|
if (prNumber <= 0)
|
|
302
306
|
continue;
|
|
307
|
+
if ((await relay.loadPullRequestFacts())?.head?.sha !== sourceSha) {
|
|
308
|
+
await gitText(canonical, ['fetch', '--no-tags', 'origin', '+refs/heads/main:refs/remotes/origin/main']);
|
|
309
|
+
const remoteRef = `refs/remotes/origin/${branch}`;
|
|
310
|
+
await gitText(canonical, ['fetch', '--no-tags', 'origin', `+refs/heads/${branch}:${remoteRef}`]).catch(() => '');
|
|
311
|
+
const remoteHeadSha = await gitText(canonical, ['rev-parse', '--verify', remoteRef]).catch(() => '');
|
|
312
|
+
const sameTree = remoteHeadSha ? await gitText(canonical, ['diff', '--quiet', 'refs/remotes/origin/main', remoteRef]).then(() => true, () => false) : false;
|
|
313
|
+
await gitText(canonical, authoritativeBranchPushArgs({ sourceRef, branch, remoteHeadSha: remoteHeadSha || null, remoteTreeMatchesBase: sameTree }));
|
|
314
|
+
}
|
|
303
315
|
const latestFacts = await relay.loadPullRequestFacts();
|
|
304
316
|
if (latestFacts?.state !== 'MERGED' && (latestFacts?.head?.sha !== sourceSha || latestFacts?.head?.ref !== branch)) {
|
|
305
317
|
await relay.savePullRequestFacts({ state: latestFacts?.state === 'CLOSED' ? 'CLOSED' : 'OPEN', head: { ref: branch, sha: sourceSha }, observedAt: new Date().toISOString() });
|
|
306
318
|
}
|
|
307
319
|
const github = new GitHubStore(client, job.repository, prNumber, trusted);
|
|
308
|
-
projectionWriters.set(`job:${job.id}`, new GitHubProjectionWriter(relay, github, undefined, true));
|
|
320
|
+
projectionWriters.set(`job:${job.id}`, new GitHubProjectionWriter(relay, github, undefined, true, effect));
|
|
309
321
|
if (job.state === 'COMPLETED' && job.localMerge?.status === 'succeeded' && job.localMerge.projectionStrategy === 'target_ref_push') {
|
|
310
322
|
const injection = await consumeMethodInjection(relay, job.repository, job.id, 'github.projectLocalMerge');
|
|
311
323
|
const projectedMerge = await withTestInjection('github.projectLocalMerge', injection, () => projectAuthoritativeLocalMerge(canonical, job));
|
package/dist/workspace.js
CHANGED
|
@@ -189,6 +189,10 @@ export function managedLocalJobWorktreePath(repository, jobId, workspaceRoot = d
|
|
|
189
189
|
const digest = createHash('sha256').update(jobId).digest('hex').slice(0, 12);
|
|
190
190
|
return join(workspaceRoot, '.worktrees', 'agents-relay', `${repository.replace('/', '-')}-job-${safeJob}-${digest}`, 'job');
|
|
191
191
|
}
|
|
192
|
+
/** Only code artifacts need an isolated Git source branch and worktree. */
|
|
193
|
+
export function jobRequiresManagedWorktree(job) {
|
|
194
|
+
return !(job.prNumber > 0) && !job.schedule && (Boolean(job.localMerge?.sourceRef) || job.tasks.some(task => task.output?.kind === 'task_pr'));
|
|
195
|
+
}
|
|
192
196
|
/** Bind an ordinary SQLite Job to one deterministic local work branch.
|
|
193
197
|
* The target is always main, and branch creation never switches the user's checkout.
|
|
194
198
|
* This is a Git-local operation and never needs GitHub credentials.
|
package/package.json
CHANGED
|
@@ -61,7 +61,7 @@ For repository work, create/adopt/repair the PR-backed job through the Agents Re
|
|
|
61
61
|
Example:
|
|
62
62
|
|
|
63
63
|
~~~sh
|
|
64
|
-
npx agents-relay job create --repo OWNER/REPO --
|
|
64
|
+
npx agents-relay job create --repo OWNER/REPO --id JOB_ID --title "Objective" --objective-file job.md --initial-plan
|
|
65
65
|
|
|
66
66
|
npx agents-relay task create --repo OWNER/REPO --pr 12 --id JOB_ID --task-id implement --output task-pr --input "Implement the requested change, validate it, and update the managed task/PR."
|
|
67
67
|
|
|
@@ -15,7 +15,7 @@ For long-form content, use files instead of shell-quoted arguments:
|
|
|
15
15
|
|
|
16
16
|
When creating a new Job from an active discussion, the orchestrator must consolidate **all context needed to finish the Job without the originating thread** into the Job objective before creation. Include, as applicable: goal, relevant background, decisions already made, requirements, constraints, lifecycle/workflow semantics, non-goals, implementation boundaries, acceptance criteria, delivery/review instructions, and any important known issues. Do not create a Job whose objective is only a one-line summary when the discussion contains material implementation context.
|
|
17
17
|
|
|
18
|
-
|
|
18
|
+
Ordinary `job create` requires standalone objective Markdown. An external display PR is projected after the first meaningful code commit; existing PRs are adopted with `job adopt`. The PR body is the single durable Job surface: one hidden `agents-relay:job:v1` marker followed by the objective Markdown. Updating Job metadata or objective updates that same body. New Jobs must not create a separate Job comment that repeats the objective. Readers must remain backward-compatible with legacy Job-comment markers, and a successful Job save may migrate such a legacy marker into the PR body only after validating the replacement before deleting the redundant legacy Job comment.
|
|
19
19
|
|
|
20
20
|
## Task producers
|
|
21
21
|
|