agents-relay 2.0.83 → 2.0.84

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.
@@ -0,0 +1,83 @@
1
+ export const hostInvocationTablesSql = `
2
+ CREATE TABLE IF NOT EXISTS host_invocation_event (
3
+ job_id TEXT NOT NULL,
4
+ task_id TEXT NOT NULL,
5
+ event_id TEXT NOT NULL,
6
+ invocation_id TEXT NOT NULL,
7
+ execution_id TEXT NOT NULL,
8
+ revision TEXT NOT NULL,
9
+ decision TEXT NOT NULL CHECK(decision IN ('approved','changes_requested')),
10
+ outcome TEXT NOT NULL CHECK(outcome IN ('accepted','duplicate','stale','ignored')),
11
+ fingerprint TEXT NOT NULL,
12
+ received_at TEXT NOT NULL,
13
+ PRIMARY KEY(job_id,task_id,event_id)
14
+ );
15
+ CREATE TABLE IF NOT EXISTS host_invocation_decision (
16
+ job_id TEXT NOT NULL,
17
+ task_id TEXT NOT NULL,
18
+ invocation_id TEXT NOT NULL,
19
+ execution_id TEXT NOT NULL,
20
+ revision TEXT NOT NULL,
21
+ decision TEXT NOT NULL CHECK(decision IN ('approved','changes_requested')),
22
+ feedback TEXT,
23
+ fingerprint TEXT NOT NULL,
24
+ event_id TEXT NOT NULL,
25
+ decided_at TEXT NOT NULL,
26
+ PRIMARY KEY(job_id,task_id,invocation_id),
27
+ UNIQUE(job_id,task_id,event_id)
28
+ );
29
+ `;
30
+ export function recordHostInvocationEvent(db, delivery, outcome, fingerprint) {
31
+ db.prepare(`INSERT OR IGNORE INTO host_invocation_event
32
+ (job_id,task_id,event_id,invocation_id,execution_id,revision,decision,outcome,fingerprint,received_at)
33
+ VALUES(?,?,?,?,?,?,?,?,?,?)`).run(delivery.jobId, delivery.taskId, delivery.eventId, delivery.invocationId, delivery.executionId, delivery.revision, delivery.decision, outcome, fingerprint, delivery.deliveredAt);
34
+ }
35
+ export function hasHostInvocationDecision(db, jobId, taskId, invocationId, fingerprint) {
36
+ return db.prepare('SELECT 1 FROM host_invocation_decision WHERE job_id=? AND task_id=? AND invocation_id=? AND (? IS NULL OR fingerprint=?)')
37
+ .get(jobId, taskId, invocationId, fingerprint ?? null, fingerprint ?? null) !== undefined;
38
+ }
39
+ export function recordHostInvocationDecision(db, delivery, fingerprint, writeSnapshot) {
40
+ db.exec('BEGIN IMMEDIATE');
41
+ try {
42
+ const existing = db.prepare('SELECT fingerprint FROM host_invocation_decision WHERE job_id=? AND task_id=? AND invocation_id=?')
43
+ .get(delivery.jobId, delivery.taskId, delivery.invocationId);
44
+ if (existing) {
45
+ const outcome = existing.fingerprint === fingerprint ? 'duplicate' : 'stale';
46
+ recordHostInvocationEvent(db, delivery, outcome, fingerprint);
47
+ db.exec('COMMIT');
48
+ return { outcome, reason: outcome === 'duplicate' ? 'invocation_already_decided' : 'conflicting_invocation_decision' };
49
+ }
50
+ const snapshot = db.prepare('SELECT json FROM job_snapshot WHERE job_id=?').get(delivery.jobId);
51
+ const job = snapshot?.json ? JSON.parse(snapshot.json) : null;
52
+ const task = job?.tasks.find(item => item.id === delivery.taskId);
53
+ const invocation = task?.interactiveInvocation;
54
+ if (!job || !task || !invocation || !(['RUNNING', 'BLOCKED'].includes(task.state)) || invocation.executionId !== delivery.executionId ||
55
+ (task.executionId !== delivery.executionId && task.state !== 'BLOCKED') ||
56
+ invocation.status !== 'awaiting_decision' || invocation.invocationId !== delivery.invocationId || invocation.revision !== delivery.revision) {
57
+ recordHostInvocationEvent(db, delivery, 'stale', fingerprint);
58
+ db.exec('COMMIT');
59
+ return { outcome: 'stale', reason: 'invocation_or_revision_mismatch' };
60
+ }
61
+ db.prepare(`INSERT INTO host_invocation_decision
62
+ (job_id,task_id,invocation_id,execution_id,revision,decision,feedback,fingerprint,event_id,decided_at)
63
+ VALUES(?,?,?,?,?,?,?,?,?,?)`).run(delivery.jobId, delivery.taskId, delivery.invocationId, delivery.executionId, delivery.revision, delivery.decision, delivery.feedback ?? null, fingerprint, delivery.eventId, delivery.deliveredAt);
64
+ recordHostInvocationEvent(db, delivery, 'accepted', fingerprint);
65
+ const decidedInvocation = {
66
+ invocationId: delivery.invocationId, revision: delivery.revision, executionId: delivery.executionId,
67
+ status: delivery.decision === 'approved' ? 'approved' : 'changes_requested',
68
+ boundAt: invocation.boundAt, decidedAt: delivery.deliveredAt, feedback: delivery.feedback,
69
+ };
70
+ task.interactiveInvocation = decidedInvocation;
71
+ const candidate = Date.parse(delivery.deliveredAt);
72
+ const previous = Date.parse(job.updatedAt);
73
+ job.updatedAt = Number.isFinite(candidate) && candidate > previous
74
+ ? delivery.deliveredAt : new Date(previous + 1).toISOString();
75
+ writeSnapshot(job, delivery.deliveredAt);
76
+ db.exec('COMMIT');
77
+ return { outcome: 'accepted', reason: 'latest_interactive_decision', invocation: decidedInvocation };
78
+ }
79
+ catch (error) {
80
+ db.exec('ROLLBACK');
81
+ throw error;
82
+ }
83
+ }
@@ -0,0 +1,233 @@
1
+ import { createHash, randomUUID } from 'node:crypto';
2
+ import { readFileSync } from 'node:fs';
3
+ import { GraphRuntime } from './workflow/graph-v2.js';
4
+ import { dispatchWorkflowGraph } from './workflow/dispatcher.js';
5
+ const graphSource = readFileSync(new URL('../workflow/interactive-invocation.mmd', import.meta.url), 'utf8');
6
+ export const interactiveInvocationGraph = new GraphRuntime(graphSource, {
7
+ states: new Set(['awaiting_decision', 'changes_requested', 'approved']),
8
+ guards: {
9
+ currentInvocation: facts => facts.durableFacts.currentInvocation === true,
10
+ awaitingDecision: facts => facts.durableFacts.awaitingDecision === true,
11
+ currentRevision: facts => facts.durableFacts.currentRevision === true,
12
+ duplicateDelivery: facts => facts.durableFacts.duplicateDelivery === true,
13
+ changesRequested: facts => facts.durableFacts.changesRequested === true,
14
+ },
15
+ actions: {
16
+ 'interactive.recordDecision': { id: 'interactive.recordDecision' },
17
+ 'interactive.requestChanges': { id: 'interactive.requestChanges' },
18
+ 'interactive.approve': { id: 'interactive.approve' },
19
+ 'interactive.wakeWorker': { id: 'interactive.wakeWorker' },
20
+ },
21
+ events: { 'task.interactive_decision': { id: 'task.interactive_decision' }, 'task.interactive_recover': { id: 'task.interactive_recover' } },
22
+ });
23
+ export function hostInteractiveDecisionEvent(context, feedback, receivedAt = new Date().toISOString()) {
24
+ if (!feedback.eventId || !feedback.invocationId || !feedback.revision ||
25
+ !['approved', 'changes_requested'].includes(feedback.decision)) {
26
+ throw new Error('Incomplete generic interactive decision');
27
+ }
28
+ return {
29
+ version: 1, event_id: feedback.eventId, job_id: context.jobId,
30
+ task_id: context.taskId, parent_task_id: null, type: 'task.interactive_decision',
31
+ status: 'running', timestamp: receivedAt, visibility: 'orchestrator', level: 'info',
32
+ message: `Host interactive decision received for revision ${feedback.revision}`,
33
+ source: { id: `job/${context.jobId}/task/${context.taskId}/execution/${context.executionId}`, component: 'agents-relay' },
34
+ data: { interactive: { ...context, ...feedback, eventId: undefined } },
35
+ };
36
+ }
37
+ export function normalizeInteractiveDecision(event) {
38
+ if (event.type !== 'task.interactive_decision')
39
+ return null;
40
+ const payload = event.data?.interactive;
41
+ if (!payload || typeof payload !== 'object' || Array.isArray(payload))
42
+ return null;
43
+ const value = payload;
44
+ const string = (key) => typeof value[key] === 'string' && value[key].length > 0 ? value[key] : null;
45
+ const jobId = string('jobId');
46
+ const taskId = string('taskId');
47
+ const executionId = string('executionId');
48
+ const invocationId = string('invocationId');
49
+ const revision = string('revision');
50
+ const decision = value.decision === 'approved' || value.decision === 'changes_requested' ? value.decision : null;
51
+ if (!jobId || !taskId || !executionId || !invocationId || !revision || !decision)
52
+ return null;
53
+ const feedback = string('feedback');
54
+ return {
55
+ jobId, taskId, executionId, invocationId, revision, decision,
56
+ feedback: feedback ?? undefined,
57
+ eventId: event.event_id,
58
+ deliveredAt: event.timestamp,
59
+ };
60
+ }
61
+ export function interactiveDecisionFingerprint(decision) {
62
+ return createHash('sha256').update(JSON.stringify({
63
+ jobId: decision.jobId,
64
+ taskId: decision.taskId,
65
+ executionId: decision.executionId,
66
+ invocationId: decision.invocationId,
67
+ revision: decision.revision,
68
+ decision: decision.decision,
69
+ feedback: decision.feedback ?? null,
70
+ })).digest('hex');
71
+ }
72
+ function invocationState(invocation) {
73
+ return invocation?.status ?? 'awaiting_decision';
74
+ }
75
+ async function decideInteractiveInvocation(task, delivery, duplicateDelivery) {
76
+ const invocation = task.interactiveInvocation;
77
+ const result = await dispatchWorkflowGraph(interactiveInvocationGraph, {
78
+ initialState: invocationState(invocation),
79
+ input: { transport: 'event', id: 'task.interactive_decision', payload: {} },
80
+ durableFacts: {
81
+ currentInvocation: invocation?.invocationId === delivery.invocationId,
82
+ awaitingDecision: invocation?.status === 'awaiting_decision',
83
+ currentRevision: invocation?.revision === delivery.revision,
84
+ duplicateDelivery,
85
+ changesRequested: delivery.decision === 'changes_requested',
86
+ },
87
+ graphName: 'interactive-invocation',
88
+ observe: false,
89
+ executeAction: () => ({}),
90
+ });
91
+ return {
92
+ accepted: result.accepted,
93
+ actions: result.accepted ? result.actions.map((action) => action.id) : [],
94
+ };
95
+ }
96
+ /** Explicitly bind prototype-backed Store operations; object spread discards them. */
97
+ export function hostInvocationPorts(store) {
98
+ return {
99
+ loadJob: jobId => store.load(jobId),
100
+ mutateJob: (jobId, mutation) => {
101
+ if (!store.mutateJob)
102
+ throw new Error('Store does not support atomic job mutation');
103
+ return store.mutateJob(jobId, mutation);
104
+ },
105
+ recordHostInvocationEvent: (delivery, outcome, fingerprint) => {
106
+ if (!store.recordHostInvocationEvent)
107
+ throw new Error('Store does not support invocation events');
108
+ return store.recordHostInvocationEvent(delivery, outcome, fingerprint);
109
+ },
110
+ hasHostInvocationDecision: (jobId, taskId, invocationId, fingerprint) => store.hasHostInvocationDecision?.(jobId, taskId, invocationId, fingerprint) ?? Promise.resolve(false),
111
+ recordHostInvocationDecision: (delivery, fingerprint) => {
112
+ if (!store.recordHostInvocationDecision)
113
+ throw new Error('Store does not support invocation decisions');
114
+ return store.recordHostInvocationDecision(delivery, fingerprint);
115
+ },
116
+ };
117
+ }
118
+ export class HostInteractiveInvocationCoordinator {
119
+ options;
120
+ waiters = new Map();
121
+ constructor(options) {
122
+ this.options = options;
123
+ }
124
+ async bind(jobId, task, executionId, invocationId, revision) {
125
+ if (task.state !== 'RUNNING')
126
+ throw new Error(`Task ${task.id} must be RUNNING to bind an interactive invocation`);
127
+ if (task.executionId !== executionId)
128
+ throw new Error(`Task ${task.id} does not own execution ${executionId}`);
129
+ const now = (this.options.now ?? (() => new Date()))().toISOString();
130
+ const invocation = { invocationId, revision, executionId, status: 'awaiting_decision', boundAt: now };
131
+ const boundJobId = task.jobId ?? jobId;
132
+ if (!boundJobId)
133
+ throw new Error(`Task ${task.id} cannot bind an interactive invocation without a Job ID`);
134
+ if (!this.options.store.mutateJob)
135
+ throw new Error('Store does not support atomic invocation binding');
136
+ await this.options.store.mutateJob(boundJobId, current => {
137
+ const next = structuredClone(current);
138
+ const nextTask = next.tasks.find((item) => item.id === task.id);
139
+ if (!nextTask || nextTask.executionId !== executionId || nextTask.state !== 'RUNNING')
140
+ return current;
141
+ if (nextTask.interactiveInvocation?.invocationId === invocationId) {
142
+ if (nextTask.interactiveInvocation.revision !== revision)
143
+ throw new Error('Invocation identity cannot change revision');
144
+ return current;
145
+ }
146
+ nextTask.interactiveInvocation = invocation;
147
+ return next;
148
+ });
149
+ const key = `${boundJobId}:${task.id}:${executionId}:${invocationId}`;
150
+ const decision = new Promise((resolve, reject) => {
151
+ this.waiters.set(key, resolve);
152
+ const timer = setTimeout(() => {
153
+ if (this.waiters.get(key) !== resolve)
154
+ return;
155
+ this.waiters.delete(key);
156
+ reject(new Error(`Interactive decision timeout for task ${task.id}, revision ${revision}`));
157
+ }, 60_000);
158
+ timer.unref();
159
+ });
160
+ return { decision };
161
+ }
162
+ async applyEvent(event) {
163
+ const delivery = normalizeInteractiveDecision(event);
164
+ if (!delivery || delivery.jobId !== event.job_id || delivery.taskId !== event.task_id) {
165
+ return { outcome: 'ignored', reason: 'invalid_interactive_delivery' };
166
+ }
167
+ const job = await this.options.store.loadJob(delivery.jobId);
168
+ const task = job?.tasks.find((item) => item.id === delivery.taskId);
169
+ if (!job || !task || task.interactiveInvocation?.executionId !== delivery.executionId ||
170
+ (task.executionId !== delivery.executionId && task.state !== 'BLOCKED')) {
171
+ await this.options.store.recordHostInvocationEvent?.(delivery, 'stale', interactiveDecisionFingerprint(delivery));
172
+ return { outcome: 'stale', reason: 'execution_not_bound' };
173
+ }
174
+ const duplicate = await this.options.store.hasHostInvocationDecision?.(delivery.jobId, delivery.taskId, delivery.invocationId, interactiveDecisionFingerprint(delivery)) ?? false;
175
+ const graphDecision = await decideInteractiveInvocation(task, delivery, duplicate);
176
+ const selected = graphDecision.actions.some(action => action === 'interactive.requestChanges' || action === 'interactive.approve');
177
+ if (!graphDecision.accepted || !selected || duplicate) {
178
+ const outcome = duplicate ? 'duplicate' : 'stale';
179
+ await this.options.store.recordHostInvocationEvent?.(delivery, outcome, interactiveDecisionFingerprint(delivery));
180
+ return { outcome, reason: `${outcome}_interactive_decision`, decision: delivery, resumeWorker: duplicate && graphDecision.actions.includes('interactive.wakeWorker') };
181
+ }
182
+ const recorded = await this.options.store.recordHostInvocationDecision?.(delivery, interactiveDecisionFingerprint(delivery));
183
+ if (!recorded)
184
+ throw new Error('Interactive invocation store does not support durable decisions');
185
+ if (recorded.outcome !== 'accepted')
186
+ return { outcome: recorded.outcome, reason: recorded.reason };
187
+ const accepted = {
188
+ jobId: delivery.jobId, taskId: delivery.taskId, executionId: delivery.executionId,
189
+ invocationId: delivery.invocationId, revision: delivery.revision, decision: delivery.decision,
190
+ feedback: delivery.feedback, event,
191
+ };
192
+ const key = `${accepted.jobId}:${accepted.taskId}:${accepted.executionId}:${accepted.invocationId}`;
193
+ const waiter = this.waiters.get(key);
194
+ if (waiter) {
195
+ this.waiters.delete(key);
196
+ waiter(accepted);
197
+ }
198
+ await this.options.onAccepted?.(accepted);
199
+ await this.options.emit?.(acceptedEvent(accepted));
200
+ return { outcome: 'accepted', reason: 'latest_interactive_decision', invocation: recorded.invocation, decision: delivery,
201
+ resumeWorker: graphDecision.actions.includes('interactive.wakeWorker') };
202
+ }
203
+ close() {
204
+ this.waiters.clear();
205
+ }
206
+ }
207
+ function acceptedEvent(decision) {
208
+ return {
209
+ version: 1,
210
+ event_id: randomUUID(),
211
+ job_id: decision.jobId,
212
+ task_id: decision.taskId,
213
+ parent_task_id: null,
214
+ type: 'task.interactive_decision.accepted',
215
+ status: 'succeeded',
216
+ timestamp: new Date().toISOString(),
217
+ visibility: 'orchestrator',
218
+ level: 'info',
219
+ message: `Interactive decision ${decision.decision} accepted for revision ${decision.revision}`,
220
+ source: { id: `job/${decision.jobId}/task/${decision.taskId}/execution/${decision.executionId}`, component: 'agents-relay' },
221
+ data: { interactive: { ...decision, decision: decision.decision, outcome: 'accepted' } },
222
+ };
223
+ }
224
+ /** Recovery uses the canonical decision Graph, not transport delivery history. */
225
+ export async function shouldRecoverInteractiveDecision(status) {
226
+ const result = await dispatchWorkflowGraph(interactiveInvocationGraph, {
227
+ initialState: status,
228
+ input: { transport: 'event', id: 'task.interactive_recover', payload: {} },
229
+ durableFacts: {}, graphName: 'interactive-invocation', observe: false,
230
+ executeAction: () => ({}),
231
+ });
232
+ return result.accepted && result.actions.some(action => action.id === 'interactive.wakeWorker');
233
+ }
@@ -0,0 +1,41 @@
1
+ import { shouldRecoverInteractiveDecision } from './interactive-invocation.js';
2
+ import { tryTransitionTaskEvent } from './scheduler.js';
3
+ import { applyTaskUnblockRetrySnapshot } from './task-actions.js';
4
+ export async function resumeInteractiveTask(store, jobId, taskId, invocationId, revision, decision) {
5
+ if (!store.mutateJob)
6
+ throw new Error('Interactive resume requires atomic SQLite Job mutation');
7
+ let resumed = false;
8
+ await store.mutateJob(jobId, job => {
9
+ const task = job.tasks.find(item => item.id === taskId);
10
+ const invocation = task?.interactiveInvocation;
11
+ if (!task || task.state !== 'BLOCKED' || !invocation ||
12
+ invocation.invocationId !== invocationId || invocation.revision !== revision ||
13
+ invocation.status !== decision || !invocation.decidedAt)
14
+ return job;
15
+ // Both state transitions and retry eligibility are owned by task-lifecycle.mmd.
16
+ if (!tryTransitionTaskEvent(task, 'task.unblocked').accepted) {
17
+ throw new Error('Interactive Task unblock rejected by canonical lifecycle Graph');
18
+ }
19
+ applyTaskUnblockRetrySnapshot(task);
20
+ task.error = null;
21
+ task.failureReason = undefined;
22
+ task.updatedAt = new Date().toISOString();
23
+ resumed = true;
24
+ return job;
25
+ });
26
+ return resumed;
27
+ }
28
+ /** Reconciliation is recovery-safe even when the accepted event was lost after SQLite commit. */
29
+ export async function recoverInteractiveDecisions(store, jobId) {
30
+ const job = await store.load(jobId);
31
+ let recovered = false;
32
+ for (const task of job.tasks) {
33
+ if (task.state !== 'BLOCKED' || !task.interactiveInvocation?.decidedAt)
34
+ continue;
35
+ const invocation = task.interactiveInvocation;
36
+ if (invocation.status === 'awaiting_decision' || !await shouldRecoverInteractiveDecision(invocation.status))
37
+ continue;
38
+ recovered = await resumeInteractiveTask(store, job.id, task.id, invocation.invocationId, invocation.revision, invocation.status) || recovered;
39
+ }
40
+ return recovered;
41
+ }
@@ -23,6 +23,15 @@ export async function managedWorkerInput(job, task, context) {
23
23
  const output = task.output.kind === 'file'
24
24
  ? `file\nPath: ${task.output.path}`
25
25
  : (projectedReview ? `task/PR\nProjected PR: ${prUrl}\nTask: ${task.id}` : isLocalJob ? `task/PR\nTask: ${task.id}` : `task/PR\nPR: ${prUrl}\nTask: ${task.id}`);
26
+ const interactiveContext = task.interactiveInvocation && task.interactiveInvocation.status !== 'awaiting_decision'
27
+ ? '[Durable interactive review continuation]\n' +
28
+ JSON.stringify({
29
+ invocationId: task.interactiveInvocation.invocationId,
30
+ revision: task.interactiveInvocation.revision,
31
+ decision: task.interactiveInvocation.status,
32
+ feedback: task.interactiveInvocation.feedback ?? null,
33
+ }) + '\nContinue this same Task. For changes_requested, revise the artifact and request review of a NEW immutable revision; for approved, finalize only that approved revision. Never invent a human approval.\n'
34
+ : '';
26
35
  const taskTag = taskTagWorkerPrompt(job);
27
36
  return `[Agents Relay managed task]
28
37
  ${projectedReview ? `Job: local ${job.id} (lifecycle authority)\nProjected GitHub PR: ${prUrl}` : isLocalJob ? `Job: local ${job.id}` : `Job URL: ${prUrl}`}
@@ -38,7 +47,7 @@ ${projectedReview
38
47
  \n[Authoritative execution context]
39
48
  Job objective: ${job.objective ?? '(none recorded)'}
40
49
  Plan: ${task.planningDecisionId ? `${task.planningDecisionId} (round ${task.planningRound ?? 'unknown'})` : 'directly created Task'}
41
- Task attempt/execution: ${task.attempt}/${task.maxAttempts} / ${task.executionId}
50
+ ${interactiveContext}Task attempt/execution: ${task.attempt}/${task.maxAttempts} / ${task.executionId}
42
51
  Dependencies: ${task.dependencies.length ? task.dependencies.join(', ') : 'none'}
43
52
  ${task.attempt > 1 || task.error ? `Retry summary: prior error=${task.error ?? 'none'}; prior generations=${(task.executionHistory ?? []).map(item => `${item.id}@${item.attempt}`).join(', ') || 'none'}` : 'Retry summary: first attempt'}
44
53
  SQLite/RelayStore and the canonical Mermaid Graphs own lifecycle, merge, recovery, and completion decisions. Do not perform Relay-owned merge or completion actions from this worker.
@@ -218,6 +218,23 @@ export class ProjectedDurableStore {
218
218
  async finishExecutionProcess(jobId, taskId, executionId) {
219
219
  await this.relay.finishExecutionProcess(jobId, taskId, executionId);
220
220
  }
221
+ async mutateJob(jobId, mutation) {
222
+ if (!this.relay.mutateJob)
223
+ throw new Error("Relay store does not support atomic job mutation");
224
+ return this.relay.mutateJob(jobId, mutation);
225
+ }
226
+ async markEventProcessed(eventId, stream, sequence) {
227
+ return this.relay.markEventProcessed(eventId, stream ?? null, sequence ?? null);
228
+ }
229
+ async recordHostInvocationEvent(delivery, outcome, fingerprint) {
230
+ await this.relay.recordHostInvocationEvent?.(delivery, outcome, fingerprint);
231
+ }
232
+ async hasHostInvocationDecision(jobId, taskId, invocationId, fingerprint) {
233
+ return await this.relay.hasHostInvocationDecision?.(jobId, taskId, invocationId, fingerprint) ?? false;
234
+ }
235
+ async recordHostInvocationDecision(delivery, fingerprint) {
236
+ return await this.relay.recordHostInvocationDecision?.(delivery, fingerprint) ?? { outcome: 'ignored', reason: 'interactive_invocations_unsupported' };
237
+ }
221
238
  /** Ingest trusted GitHub records without echoing them back through the projection outbox. */
222
239
  async hydrate(job) {
223
240
  await this.relay.ingestJobSnapshot(structuredClone(job));
@@ -10,6 +10,8 @@ import { acceptedReportedTag, eventDataWithoutTag } from './task-tag.js';
10
10
  import { rearmTerminalEventDeadline } from './terminal-event-watchdog.js';
11
11
  import { createWorkerStartAck } from './worker-start-ack.js';
12
12
  import { monitorExecutionProcess } from './execution-process-tracker.js';
13
+ import { HostInteractiveInvocationCoordinator, hostInvocationPorts } from './interactive-invocation.js';
14
+ import { resumeInteractiveTask } from './interactive-resume.js';
13
15
  export function executionEventSource(jobId, taskId, executionId) { return `job/${jobId}/task/${taskId}/execution/${executionId}`; }
14
16
  function eventSourceId(event) { return typeof event.source?.id === 'string' ? event.source.id : undefined; }
15
17
  function executionIdFromSource(sourceId, jobId, taskId) {
@@ -40,12 +42,14 @@ export class ReconcilerRuntimeBase {
40
42
  startAckCleanups = new Set();
41
43
  taskTransitions;
42
44
  eventCoordinator;
45
+ hostInvocations;
43
46
  detachedReconciles = new Set();
44
47
  workflowEffects = new WorkflowEffectExecutor();
45
48
  reconciling = false;
46
49
  constructor(store, options) {
47
50
  this.store = store;
48
51
  this.options = options;
52
+ this.hostInvocations = new HostInteractiveInvocationCoordinator({ store: hostInvocationPorts(store), emit: event => this.emit(event) });
49
53
  this.taskTransitions = new DurableTaskTransitionRuntime(store);
50
54
  this.eventCoordinator = new ReconcileCoordinator(async (jobId, signals) => {
51
55
  let shouldReconcile = false;
@@ -77,6 +81,8 @@ export class ReconcilerRuntimeBase {
77
81
  }
78
82
  });
79
83
  }
84
+ async applyHostInvocationEvent(event) { const result = await this.hostInvocations.applyEvent(event); const resumed = result.resumeWorker && result.decision ? await resumeInteractiveTask(this.store, result.decision.jobId, result.decision.taskId, result.decision.invocationId, result.decision.revision, result.decision.decision) : false; return { applied: result.outcome !== 'ignored', reconcile: resumed, reason: `task.interactive_decision.${result.outcome}` }; }
85
+ bindInteractiveInvocation(jobId, task, executionId, invocationId, revision) { return this.hostInvocations.bind(jobId, task, executionId, invocationId, revision); }
80
86
  async applyJobEffectOutcomes(job, outcomes) {
81
87
  await applyJobEffectOutcomes(this.store, event => this.emit(event), job, outcomes);
82
88
  }
@@ -106,6 +112,8 @@ export class ReconcilerRuntimeBase {
106
112
  }
107
113
  if (['review.started', 'review.approved', 'review.changes_required', 'review.blocked'].includes(event.type))
108
114
  return this.applyReviewEvent(event);
115
+ if (event.type === 'task.interactive_decision')
116
+ return this.applyHostInvocationEvent(event);
109
117
  if (event.type === 'github.webhook')
110
118
  return { applied: true, reconcile: false, reason: 'github.audit_only' };
111
119
  if (event.type === 'job.wake')
@@ -393,6 +401,8 @@ export class ReconcilerRuntimeBase {
393
401
  return false;
394
402
  if (task.agentName === 'reviewer' && event.type === 'task.completed')
395
403
  return false;
404
+ if (event.type === 'task.completed' && task.interactiveInvocation?.status !== undefined && task.interactiveInvocation.status !== 'approved')
405
+ return false; // current interactive revision requires durable approval
396
406
  const modelBacked = task.adapter === 'codex' || task.adapter === 'chatgpt';
397
407
  if (!trustedInternal && modelBacked && !this.isCorrelatedWorkerEvent(task, event)) {
398
408
  const eventExecutionId = executionIdFromSource(eventSourceId(event), _job.id, task.id);
@@ -31,6 +31,7 @@ import { reconcileLocalGitCompletion } from './local-merge-delivery.js';
31
31
  import { repairLegacyOrchestrators } from './legacy-orchestrator-repair.js';
32
32
  import { launchPlanExecution } from './planner-runtime.js';
33
33
  import { classifyFailure, errorMessage, observability, withObservabilityCorrelation } from './observability.js';
34
+ import { recoverInteractiveDecisions } from './interactive-resume.js';
34
35
  const PRIORITY_RANK = { P0: 0, P1: 1, P2: 2, P3: 3 };
35
36
  export function compareTaskPriority(a, b, jobPriority = 'P2') { const ar = PRIORITY_RANK[a.priority ?? jobPriority]; const br = PRIORITY_RANK[b.priority ?? jobPriority]; return ar - br || a.createdAt.localeCompare(b.createdAt) || a.id.localeCompare(b.id); }
36
37
  export class Reconciler extends ReconcilerReviewBase {
@@ -94,6 +95,7 @@ export class Reconciler extends ReconcilerReviewBase {
94
95
  return job;
95
96
  await recoverExpiredExecutions(job, now, this.executionRecoveryPorts(job));
96
97
  await repairLegacyOrchestrators(jobId, now, { store: this.store, owner: this.options.owner, emit: event => this.emit(event) });
98
+ await recoverInteractiveDecisions(this.store, jobId);
97
99
  await this.recoverPlannerChildren(job);
98
100
  await retryPendingPlannerUnblocks({
99
101
  store: this.store,
@@ -5,6 +5,7 @@ import { readdir } from 'node:fs/promises';
5
5
  import { recordPublicationInDatabase, loadPublicationFromDatabase, listPublicationEventsFromDatabase } from './publication-store.js';
6
6
  import { observability } from './observability.js';
7
7
  import { migrateLegacyJobPrNumbers, normalizeLegacyJobPrNumber } from './job-pr-number-migration.js';
8
+ import { hostInvocationTablesSql, recordHostInvocationEvent, hasHostInvocationDecision, recordHostInvocationDecision } from './host-invocation-store.js';
8
9
  import { ProjectionOutbox, SCHEMA_VERSION, logicalOperationKey, } from './projection-outbox.js';
9
10
  function newerRecord(local, remote) {
10
11
  if (!local)
@@ -97,6 +98,7 @@ export class SQLiteRelayStore {
97
98
  ended_at TEXT,
98
99
  PRIMARY KEY(job_id,task_id,execution_id)
99
100
  );
101
+ ${hostInvocationTablesSql}
100
102
  ${ProjectionOutbox.createTableSql()}
101
103
  ${ProjectionOutbox.createIndexSql()}
102
104
  `);
@@ -298,6 +300,15 @@ export class SQLiteRelayStore {
298
300
  this.db.prepare('UPDATE execution_process SET ended_at=COALESCE(ended_at,?) WHERE job_id=? AND task_id=? AND execution_id=?')
299
301
  .run(at, jobId, taskId, executionId);
300
302
  }
303
+ async recordHostInvocationEvent(delivery, outcome, fingerprint) {
304
+ recordHostInvocationEvent(this.db, delivery, outcome, fingerprint);
305
+ }
306
+ async hasHostInvocationDecision(jobId, taskId, invocationId, fingerprint) {
307
+ return hasHostInvocationDecision(this.db, jobId, taskId, invocationId, fingerprint);
308
+ }
309
+ async recordHostInvocationDecision(delivery, fingerprint) {
310
+ return recordHostInvocationDecision(this.db, delivery, fingerprint, (job, at) => this.writeJobSnapshot(job, at));
311
+ }
301
312
  async markEventProcessed(eventId, stream = null, sequence = null) {
302
313
  const result = this.db.prepare(`
303
314
  INSERT OR IGNORE INTO event_receipt(event_id,stream,sequence,received_at) VALUES(?,?,?,?)
@@ -54,7 +54,7 @@ export function pendingTroubleshooterTasks(job) {
54
54
  failedWork: task.state === 'FAILED',
55
55
  ordinaryWork: task.kind !== 'planner' && task.agentName !== 'troubleshooter',
56
56
  currentRun: belongsToCurrentRun(job, task),
57
- needsRecovery: true,
57
+ needsRecovery: !(task.state === 'BLOCKED' && (Boolean(task.unblock) || Boolean(task.interactiveInvocation))),
58
58
  idUnused: !existing.has(id),
59
59
  noRecoveryBarrier: !hasRecoveryBarrier(job, task),
60
60
  }, () => { create = true; });
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "agents-relay",
3
- "version": "2.0.83",
3
+ "version": "2.0.84",
4
4
  "description": "Durable async agent jobs coordinated through GitHub pull requests",
5
5
  "type": "module",
6
6
  "bin": {
@@ -0,0 +1,37 @@
1
+ flowchart LR
2
+ %% relay: initial awaiting_decision
3
+ awaiting_decision["state: awaiting_decision"]
4
+ changes["state: changes_requested"]
5
+ approved["state: approved"]
6
+ context["guard: currentInvocation"]
7
+ awaiting["guard: awaitingDecision"]
8
+ revision["guard: currentRevision"]
9
+ duplicateChanges["guard: duplicateDelivery"]
10
+ duplicateApproved["guard: duplicateDelivery"]
11
+ decisionKind["guard: changesRequested"]
12
+ requestChanges["action: interactive.requestChanges"]
13
+ approve["action: interactive.approve"]
14
+ wake["action: interactive.wakeWorker"]
15
+ wakeChanges["action: interactive.wakeWorker"]
16
+
17
+ awaiting_decision -->|on: task.interactive_decision| context
18
+ changes -->|on: task.interactive_decision| duplicateChanges
19
+ approved -->|on: task.interactive_decision| duplicateApproved
20
+ context -->|true| awaiting
21
+ context -->|false| awaiting_decision
22
+ awaiting -->|true| revision
23
+ awaiting -->|false| awaiting_decision
24
+ revision -->|true| decisionKind
25
+ revision -->|false| awaiting_decision
26
+ decisionKind -->|true| requestChanges
27
+ decisionKind -->|false| approve
28
+ requestChanges --> wakeChanges
29
+ wakeChanges -->|to: changes_requested| changes
30
+ approve --> wake
31
+ wake -->|to: approved| approved
32
+ duplicateChanges -->|true| wakeChanges
33
+ duplicateChanges -->|false| changes
34
+ duplicateApproved -->|true| wake
35
+ duplicateApproved -->|false| approved
36
+ changes -->|on: task.interactive_recover| wakeChanges
37
+ approved -->|on: task.interactive_recover| wake