agents-relay 2.0.94 → 2.0.96
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/dist/adapters.js +2 -53
- package/dist/projected-store.js +2 -4
- package/dist/projection-outbox.js +26 -0
- package/dist/relay-store.js +6 -0
- package/dist/relayd.js +26 -21
- package/package.json +1 -1
package/dist/adapters.js
CHANGED
|
@@ -228,63 +228,12 @@ export async function ensureChatGptBrowserWorkspace(command = process.env.AGENTS
|
|
|
228
228
|
}
|
|
229
229
|
await admin.ensureWorkspace(CHATGPT_BROWSER_WORKSPACE, CHATGPT_BROWSER_WORKSPACE_POOL_SIZE);
|
|
230
230
|
}
|
|
231
|
-
|
|
232
|
-
* The upstream new-turn action accepts idempotency_key but does not enforce it.
|
|
233
|
-
* Inspect the current thread before resubmitting after an ambiguous exit.
|
|
234
|
-
* If the transcript is not observable, fail closed rather than duplicate.
|
|
235
|
-
*/
|
|
236
|
-
async function findExistingReviewTurn(cli, sessionId, delivery, signal) {
|
|
237
|
-
const pathname = delivery.projectId
|
|
238
|
-
? `/g/${encodeURIComponent(delivery.projectId)}/c/${encodeURIComponent(delivery.threadId)}`
|
|
239
|
-
: `/c/${encodeURIComponent(delivery.threadId)}`;
|
|
240
|
-
const marker = `Relay delivery ID: ${delivery.requestId}`;
|
|
241
|
-
const inspectJs = `(() => {
|
|
242
|
-
const legacy = [...document.querySelectorAll('[data-message-author-role="user"]')]
|
|
243
|
-
.map(e => ({text: (e.innerText || '').trim(), id: e.getAttribute('data-message-id') || ''})).filter(x => x.text);
|
|
244
|
-
if (legacy.length) return legacy;
|
|
245
|
-
return [...document.querySelectorAll('h4')].filter(e => (e.innerText || '').trim() === 'You said:')
|
|
246
|
-
.map((e,i) => ({text: (e.parentElement?.innerText || '').trim(), id: 'dom-user-' + i})).filter(x => x.text);
|
|
247
|
-
})()`;
|
|
248
|
-
const script = [
|
|
249
|
-
'import json, time',
|
|
250
|
-
'from urllib.parse import urlsplit',
|
|
251
|
-
`url = ${JSON.stringify(`https://chatgpt.com${pathname}`)}`,
|
|
252
|
-
`marker = ${JSON.stringify(marker)}`,
|
|
253
|
-
'goto_url(url)',
|
|
254
|
-
'wait_for_load()',
|
|
255
|
-
'if urlsplit(page_info().get("url", "")).path.rstrip("/") != urlsplit(url).path.rstrip("/"): raise RuntimeError("review thread redirect mismatch")',
|
|
256
|
-
'turns = []',
|
|
257
|
-
'for attempt in range(30):',
|
|
258
|
-
` turns = js(${JSON.stringify(inspectJs)}) or []`,
|
|
259
|
-
' if turns: break',
|
|
260
|
-
' time.sleep(0.5)',
|
|
261
|
-
'if not turns: raise RuntimeError("review thread transcript unavailable; refusing ambiguous resubmission")',
|
|
262
|
-
'matches = [turn for turn in turns if marker in turn.get("text", "")]',
|
|
263
|
-
'if len(matches) > 1: raise RuntimeError("duplicate review delivery detected; manual reconciliation required")',
|
|
264
|
-
'print(json.dumps({"found": bool(matches), "user_message_id": matches[0].get("id") if matches else None}), flush=True)',
|
|
265
|
-
].join('\n');
|
|
266
|
-
const envelope = parseWorkspaceJson(await browserWorkspaceCommand(cli, ['session', 'exec', sessionId], script, signal));
|
|
267
|
-
if (envelope.ok !== true || typeof envelope.stdout !== 'string')
|
|
268
|
-
throw new Error('review thread inspection returned no executable result');
|
|
269
|
-
const observation = parseWorkspaceJson(envelope.stdout);
|
|
270
|
-
if (typeof observation.found !== 'boolean')
|
|
271
|
-
throw new Error('review thread inspection returned no authoritative result');
|
|
272
|
-
if (!observation.found)
|
|
273
|
-
return null;
|
|
274
|
-
return { status: 'submitted', mode: 'new-turn', thread_id: delivery.threadId, project_id: delivery.projectId,
|
|
275
|
-
verified_by: 'existing-user-turn', user_message_id: typeof observation.user_message_id === 'string' ? observation.user_message_id : null };
|
|
276
|
-
}
|
|
277
|
-
async function runBrowserWorker(cli, configFile, signal, action = 'temporary-submit', delivery) {
|
|
231
|
+
async function runBrowserWorker(cli, configFile, signal, action = 'temporary-submit') {
|
|
278
232
|
const session = parseWorkspaceJson(await browserWorkspaceCommand(cli, ['session', 'start', '--workspace', CHATGPT_BROWSER_WORKSPACE], undefined, signal));
|
|
279
233
|
const sessionId = session.session_id;
|
|
280
234
|
if (typeof sessionId !== 'string' || !sessionId)
|
|
281
235
|
throw new Error('browser-workspace session start returned no session_id');
|
|
282
236
|
try {
|
|
283
|
-
if (delivery) {
|
|
284
|
-
const existing = await findExistingReviewTurn(cli, sessionId, delivery, signal);
|
|
285
|
-
if (existing)
|
|
286
|
-
return JSON.stringify(existing);
|
|
287
|
-
}
|
|
288
237
|
return await browserWorkspaceCommand(cli, ['platform', 'run', 'chatgpt', action, '--session-id', sessionId, '--config', configFile], undefined, signal);
|
|
289
238
|
}
|
|
290
239
|
finally {
|
|
@@ -310,7 +259,7 @@ function browserWorkerRunner() {
|
|
|
310
259
|
if (signal.aborted)
|
|
311
260
|
throw new Error('browser-worker execution aborted');
|
|
312
261
|
const action = delivery ? 'new-turn' : 'temporary-submit';
|
|
313
|
-
const output = await runBrowserWorker(cli, configFile, signal, action
|
|
262
|
+
const output = await runBrowserWorker(cli, configFile, signal, action);
|
|
314
263
|
return JSON.stringify(parseDriverJson(output));
|
|
315
264
|
}
|
|
316
265
|
finally {
|
package/dist/projected-store.js
CHANGED
|
@@ -111,8 +111,7 @@ export class GitHubProjectionWriter {
|
|
|
111
111
|
observedAt: new Date().toISOString(),
|
|
112
112
|
});
|
|
113
113
|
}
|
|
114
|
-
await this.relay.
|
|
115
|
-
await this.relay.recordProjectionReceipt?.(item.operationKey, 'succeeded', item.attempts, { operation: item.operation });
|
|
114
|
+
await this.relay.completeProjectionWithReceipt(item.id, item.operationKey, 'succeeded', item.attempts, { operation: item.operation });
|
|
116
115
|
projected += 1;
|
|
117
116
|
observability().record({
|
|
118
117
|
stage: 'projection', event: 'projection.completed', outcome: 'succeeded',
|
|
@@ -137,8 +136,7 @@ export class GitHubProjectionWriter {
|
|
|
137
136
|
}
|
|
138
137
|
else {
|
|
139
138
|
const retryAt = githubProjectionRetryAt(error, item.attempts, now);
|
|
140
|
-
await this.relay.
|
|
141
|
-
await this.relay.recordProjectionReceipt?.(item.operationKey, 'failed', item.attempts + 1, { operation: item.operation, error: message });
|
|
139
|
+
await this.relay.retryProjectionWithReceipt(item.id, item.operationKey, message, retryAt, item.attempts + 1, { operation: item.operation, error: message });
|
|
142
140
|
deferred += 1;
|
|
143
141
|
observability().record({
|
|
144
142
|
stage: 'projection', event: 'projection.retry_scheduled', level: 'warn', outcome: 'failed',
|
|
@@ -215,6 +215,32 @@ export class ProjectionOutbox {
|
|
|
215
215
|
complete(id) {
|
|
216
216
|
this.db.prepare('DELETE FROM projection_outbox WHERE id=?').run(id);
|
|
217
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
|
+
}
|
|
218
244
|
retry(id, error, nextAttemptAt) {
|
|
219
245
|
this.db.prepare(`
|
|
220
246
|
UPDATE projection_outbox
|
package/dist/relay-store.js
CHANGED
|
@@ -372,7 +372,13 @@ export class SQLiteRelayStore {
|
|
|
372
372
|
return this.outbox.claimDue(limit, now);
|
|
373
373
|
}
|
|
374
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
|
+
}
|
|
375
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
|
+
}
|
|
376
382
|
async deadLetterProjection(id, error) { this.outbox.deadLetter(id, error); }
|
|
377
383
|
async recordProjectionConflict(id, diagnostic) { this.outbox.recordConflict(id, diagnostic); }
|
|
378
384
|
async listProjectionConflicts() { return this.outbox.listConflicts(); }
|
package/dist/relayd.js
CHANGED
|
@@ -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')
|
|
@@ -250,13 +247,33 @@ export async function runDaemon(argv) {
|
|
|
250
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' }));
|
|
251
248
|
continue;
|
|
252
249
|
}
|
|
253
|
-
const existingPrNumber = discovered.prNumber > 0 ? discovered.prNumber : 0;
|
|
254
250
|
const terminal = ['COMPLETED', 'CANCELLED'].includes(discovered.state);
|
|
255
251
|
const sourceSha = await gitText(canonical, ['rev-parse', '--verify', sourceRef]).catch(() => '');
|
|
256
252
|
if (!sourceSha)
|
|
257
253
|
continue;
|
|
258
254
|
let job = (await relay.loadJob(discovered.id)) ?? discovered;
|
|
259
255
|
let prNumber = job.prNumber > 0 ? job.prNumber : 0;
|
|
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.
|
|
260
277
|
if (prNumber === 0) {
|
|
261
278
|
// Do not bootstrap an empty remote-only PR commit: it would diverge from
|
|
262
279
|
// the real local Job branch before its first Worker commit. Publish the
|
|
@@ -277,21 +294,9 @@ export async function runDaemon(argv) {
|
|
|
277
294
|
await relay.mutateJob(job.id, current => ({ ...current, githubProjection: { status: 'merged_without_pr', sourceSha, observedAt: new Date().toISOString() } }));
|
|
278
295
|
continue;
|
|
279
296
|
}
|
|
280
|
-
|
|
281
|
-
|
|
282
|
-
|
|
283
|
-
const remoteRef = `refs/remotes/origin/${payload.head}`;
|
|
284
|
-
const remoteHeadSha = await gitText(canonical, ['rev-parse', '--verify', remoteRef]).catch(() => '');
|
|
285
|
-
const remoteTreeMatchesBase = remoteHeadSha ? await gitText(canonical, ['diff', '--quiet', `refs/remotes/origin/${payload.base}`, remoteRef]).then(() => true, () => false) : false;
|
|
286
|
-
await gitText(canonical, authoritativeBranchPushArgs({ sourceRef: payload.sourceRef, branch: payload.head, remoteHeadSha: remoteHeadSha || null, remoteTreeMatchesBase }));
|
|
287
|
-
const pr = await resolvePullRequest(client, payload.repository, { head: payload.head, base: payload.base, title: payload.title, body: payload.body });
|
|
288
|
-
if (!relay.mutateJob)
|
|
289
|
-
throw new Error('Atomic SQLite mutation is required for GitHub PR binding');
|
|
290
|
-
await relay.mutateJob(job.id, current => ({ ...current, prNumber: pr.number, githubProjection: { status: 'pr_bound', sourceSha, observedAt: new Date().toISOString() }, updatedAt: new Date().toISOString() }));
|
|
291
|
-
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 } });
|
|
292
|
-
};
|
|
293
|
-
projectionWriters.set(`job:${job.id}`, new GitHubProjectionWriter(relay, new InMemoryStore(job), undefined, true, effect));
|
|
294
|
-
if (relay.enqueueExternalActionOnce)
|
|
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)
|
|
295
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 });
|
|
296
301
|
if (terminal)
|
|
297
302
|
continue;
|
|
@@ -312,7 +317,7 @@ export async function runDaemon(argv) {
|
|
|
312
317
|
await relay.savePullRequestFacts({ state: latestFacts?.state === 'CLOSED' ? 'CLOSED' : 'OPEN', head: { ref: branch, sha: sourceSha }, observedAt: new Date().toISOString() });
|
|
313
318
|
}
|
|
314
319
|
const github = new GitHubStore(client, job.repository, prNumber, trusted);
|
|
315
|
-
projectionWriters.set(`job:${job.id}`, new GitHubProjectionWriter(relay, github, undefined, true));
|
|
320
|
+
projectionWriters.set(`job:${job.id}`, new GitHubProjectionWriter(relay, github, undefined, true, effect));
|
|
316
321
|
if (job.state === 'COMPLETED' && job.localMerge?.status === 'succeeded' && job.localMerge.projectionStrategy === 'target_ref_push') {
|
|
317
322
|
const injection = await consumeMethodInjection(relay, job.repository, job.id, 'github.projectLocalMerge');
|
|
318
323
|
const projectedMerge = await withTestInjection('github.projectLocalMerge', injection, () => projectAuthoritativeLocalMerge(canonical, job));
|