agents-relay 2.0.83 → 2.0.85
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/host-invocation-store.js +83 -0
- package/dist/interactive-invocation.js +233 -0
- package/dist/interactive-resume.js +41 -0
- package/dist/managed-worker-input.js +10 -1
- package/dist/projected-store.js +17 -0
- package/dist/reconciler-runtime.js +10 -0
- package/dist/reconciler.js +2 -0
- package/dist/relay-store.js +11 -0
- package/dist/troubleshooter.js +1 -1
- package/package.json +1 -1
- package/workflow/interactive-invocation.mmd +37 -0
|
@@ -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.
|
package/dist/projected-store.js
CHANGED
|
@@ -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);
|
package/dist/reconciler.js
CHANGED
|
@@ -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,
|
package/dist/relay-store.js
CHANGED
|
@@ -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(?,?,?,?)
|
package/dist/troubleshooter.js
CHANGED
|
@@ -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:
|
|
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
|
@@ -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
|