conductor-remote 1.96.2 → 1.96.3
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 +41 -8
- package/dist/assets/index-7EOD11nV.css +1 -0
- package/dist/assets/index-BtAuvfL4.js +52 -0
- package/dist/index.html +2 -2
- package/dist/sw.js +1 -1
- package/dist-node/src/agent-config.js +76 -0
- package/dist-node/src/conductor.applescript +24 -8
- package/dist-node/src/delegations.js +556 -0
- package/dist-node/src/firstprompt.js +15 -1
- package/dist-node/src/mcp-tools.js +182 -8
- package/dist-node/src/notify.js +9 -14
- package/dist-node/src/reads.js +21 -1
- package/dist-node/src/roles.js +195 -0
- package/dist-node/src/routes.js +9 -0
- package/dist-node/src/server.js +731 -53
- package/dist-node/src/session-poller.js +51 -0
- package/dist-node/src/shared.js +89 -0
- package/dist-node/src/workflow.js +33 -0
- package/dist-node/src/writes.js +26 -3
- package/package.json +1 -1
- package/dist/assets/index-35kfVzU0.css +0 -1
- package/dist/assets/index-DS9xqkPd.js +0 -50
|
@@ -0,0 +1,556 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Worktree-scoped delegated-job and session-role persistence.
|
|
3
|
+
*
|
|
4
|
+
* Conductor owns the chat history; these small files hold only active/failed work
|
|
5
|
+
* plus durable role identity. A decoder failure is public state, not permission to
|
|
6
|
+
* delete a file: callers receive warnings and the bytes remain for repair/dismissal.
|
|
7
|
+
*/
|
|
8
|
+
import fs from 'node:fs';
|
|
9
|
+
import path from 'node:path';
|
|
10
|
+
import { decodeRoles } from "./roles.js";
|
|
11
|
+
const DIRECTORY = path.join('.context', 'delegations');
|
|
12
|
+
const SESSIONS_FILE = 'sessions.json';
|
|
13
|
+
const SAFE_ID = /^[A-Za-z0-9][A-Za-z0-9_-]{0,127}$/;
|
|
14
|
+
const MAX_TEXT = 1_000_000;
|
|
15
|
+
const STATUSES = new Set([
|
|
16
|
+
'queued',
|
|
17
|
+
'opening',
|
|
18
|
+
'configuring',
|
|
19
|
+
'sending',
|
|
20
|
+
'running',
|
|
21
|
+
'returning',
|
|
22
|
+
'returned',
|
|
23
|
+
'failed'
|
|
24
|
+
]);
|
|
25
|
+
const ERROR_CODES = new Set([
|
|
26
|
+
'invalid_request',
|
|
27
|
+
'role_not_found',
|
|
28
|
+
'model_missing',
|
|
29
|
+
'provider_unknown',
|
|
30
|
+
'same_provider',
|
|
31
|
+
'workspace_not_found',
|
|
32
|
+
'session_not_found',
|
|
33
|
+
'delegation_not_found',
|
|
34
|
+
'worktree_unavailable',
|
|
35
|
+
'state_invalid',
|
|
36
|
+
'opening_failed',
|
|
37
|
+
'configuration_failed',
|
|
38
|
+
'send_failed',
|
|
39
|
+
'completion_failed',
|
|
40
|
+
'return_failed'
|
|
41
|
+
]);
|
|
42
|
+
function object(raw) {
|
|
43
|
+
return raw !== null && typeof raw === 'object' && !Array.isArray(raw) ? raw : null;
|
|
44
|
+
}
|
|
45
|
+
function text(value, field, maximum = 256) {
|
|
46
|
+
if (typeof value !== 'string' || !value.trim() || value.length > maximum)
|
|
47
|
+
throw new Error(`${field} is invalid`);
|
|
48
|
+
return value;
|
|
49
|
+
}
|
|
50
|
+
function integer(value, field, minimum = 0) {
|
|
51
|
+
if (!Number.isSafeInteger(value) || value < minimum)
|
|
52
|
+
throw new Error(`${field} is invalid`);
|
|
53
|
+
return value;
|
|
54
|
+
}
|
|
55
|
+
function decodeError(raw, field) {
|
|
56
|
+
const value = object(raw);
|
|
57
|
+
if (!value || !ERROR_CODES.has(value.code))
|
|
58
|
+
throw new Error(`${field}.code is invalid`);
|
|
59
|
+
return {
|
|
60
|
+
code: value.code,
|
|
61
|
+
message: text(value.message, `${field}.message`, 10_000),
|
|
62
|
+
retryable: value.retryable === true
|
|
63
|
+
};
|
|
64
|
+
}
|
|
65
|
+
function decodeOutcome(raw) {
|
|
66
|
+
const value = object(raw);
|
|
67
|
+
if (!value)
|
|
68
|
+
throw new Error('outcome is invalid');
|
|
69
|
+
if (value.kind === 'success') {
|
|
70
|
+
return {
|
|
71
|
+
kind: 'success',
|
|
72
|
+
assistantRowid: integer(value.assistantRowid, 'outcome.assistantRowid', 1),
|
|
73
|
+
text: text(value.text, 'outcome.text', MAX_TEXT)
|
|
74
|
+
};
|
|
75
|
+
}
|
|
76
|
+
if (value.kind === 'error') {
|
|
77
|
+
return {
|
|
78
|
+
kind: 'error',
|
|
79
|
+
...(value.assistantRowid === undefined
|
|
80
|
+
? {}
|
|
81
|
+
: { assistantRowid: integer(value.assistantRowid, 'outcome.assistantRowid', 1) }),
|
|
82
|
+
...(value.text === undefined ? {} : { text: text(value.text, 'outcome.text', MAX_TEXT) }),
|
|
83
|
+
error: text(value.error, 'outcome.error', 10_000)
|
|
84
|
+
};
|
|
85
|
+
}
|
|
86
|
+
throw new Error('outcome.kind is invalid');
|
|
87
|
+
}
|
|
88
|
+
function decodeAttachment(raw) {
|
|
89
|
+
const value = object(raw);
|
|
90
|
+
if (!value)
|
|
91
|
+
throw new Error('handoff is invalid');
|
|
92
|
+
return {
|
|
93
|
+
name: text(value.name, 'handoff.name', 256),
|
|
94
|
+
path: text(value.path, 'handoff.path', 1_024),
|
|
95
|
+
bytes: integer(value.bytes, 'handoff.bytes'),
|
|
96
|
+
token: text(value.token, 'handoff.token', 2_048)
|
|
97
|
+
};
|
|
98
|
+
}
|
|
99
|
+
function decodeResolvedRole(raw) {
|
|
100
|
+
const value = object(raw);
|
|
101
|
+
if (!value)
|
|
102
|
+
throw new Error('resolvedRole is invalid');
|
|
103
|
+
const agentType = text(value.agentType, 'resolvedRole.agentType', 64);
|
|
104
|
+
const role = decodeRoles({
|
|
105
|
+
version: 1,
|
|
106
|
+
roles: { resolved: Object.fromEntries(Object.entries(value).filter(([field]) => field !== 'agentType')) }
|
|
107
|
+
}).roles.resolved;
|
|
108
|
+
return { ...role, agentType };
|
|
109
|
+
}
|
|
110
|
+
function requireStageFields(job) {
|
|
111
|
+
if (['configuring', 'sending', 'running', 'returning', 'returned'].includes(job.status) && !job.childSessionId) {
|
|
112
|
+
throw new Error(`${job.status} requires childSessionId`);
|
|
113
|
+
}
|
|
114
|
+
if (job.status === 'sending' && !job.handoff)
|
|
115
|
+
throw new Error('sending requires handoff');
|
|
116
|
+
if (['running', 'returning', 'returned'].includes(job.status) && job.sentRowid === undefined) {
|
|
117
|
+
throw new Error(`${job.status} requires sentRowid`);
|
|
118
|
+
}
|
|
119
|
+
if (['returning', 'returned'].includes(job.status) && !job.outcome) {
|
|
120
|
+
throw new Error(`${job.status} requires outcome`);
|
|
121
|
+
}
|
|
122
|
+
if (job.returnCursor !== undefined && (!job.returnAttachment || !job.returnText)) {
|
|
123
|
+
throw new Error('a dispatched return requires returnAttachment and returnText');
|
|
124
|
+
}
|
|
125
|
+
if (job.status === 'returned' && job.returnRowid === undefined)
|
|
126
|
+
throw new Error('returned requires returnRowid');
|
|
127
|
+
if (job.status === 'failed' && !job.failure)
|
|
128
|
+
throw new Error('failed requires failure');
|
|
129
|
+
}
|
|
130
|
+
export function decodeDelegation(raw) {
|
|
131
|
+
const value = object(raw);
|
|
132
|
+
if (!value)
|
|
133
|
+
throw new Error('delegation must be an object');
|
|
134
|
+
if (value.version !== 1)
|
|
135
|
+
throw new Error(`unsupported delegation version ${String(value.version)}`);
|
|
136
|
+
const id = text(value.id, 'id', 128);
|
|
137
|
+
if (!SAFE_ID.test(id))
|
|
138
|
+
throw new Error('id is invalid');
|
|
139
|
+
const status = value.status;
|
|
140
|
+
if (!STATUSES.has(status))
|
|
141
|
+
throw new Error('status is invalid');
|
|
142
|
+
const returnMode = value.returnMode;
|
|
143
|
+
if (returnMode !== 'queue' && returnMode !== 'steer')
|
|
144
|
+
throw new Error('returnMode is invalid');
|
|
145
|
+
if (typeof value.includeThinking !== 'boolean')
|
|
146
|
+
throw new Error('includeThinking is invalid');
|
|
147
|
+
const job = {
|
|
148
|
+
version: 1,
|
|
149
|
+
id,
|
|
150
|
+
workspaceId: text(value.workspaceId, 'workspaceId'),
|
|
151
|
+
parentSessionId: text(value.parentSessionId, 'parentSessionId'),
|
|
152
|
+
...(value.childSessionId === undefined ? {} : { childSessionId: text(value.childSessionId, 'childSessionId') }),
|
|
153
|
+
role: text(value.role, 'role', 64),
|
|
154
|
+
resolvedRole: decodeResolvedRole(value.resolvedRole),
|
|
155
|
+
prompt: text(value.prompt, 'prompt', MAX_TEXT),
|
|
156
|
+
returnMode,
|
|
157
|
+
includeThinking: value.includeThinking,
|
|
158
|
+
...(value.throughRowid === undefined ? {} : { throughRowid: integer(value.throughRowid, 'throughRowid', 1) }),
|
|
159
|
+
status,
|
|
160
|
+
attempts: integer(value.attempts, 'attempts'),
|
|
161
|
+
createdAt: integer(value.createdAt, 'createdAt'),
|
|
162
|
+
updatedAt: integer(value.updatedAt, 'updatedAt'),
|
|
163
|
+
...(value.handoff === undefined ? {} : { handoff: decodeAttachment(value.handoff) }),
|
|
164
|
+
...(value.sentRowid === undefined ? {} : { sentRowid: integer(value.sentRowid, 'sentRowid', 1) }),
|
|
165
|
+
...(value.completionRowid === undefined
|
|
166
|
+
? {}
|
|
167
|
+
: { completionRowid: integer(value.completionRowid, 'completionRowid', 1) }),
|
|
168
|
+
...(value.returnCursor === undefined ? {} : { returnCursor: integer(value.returnCursor, 'returnCursor') }),
|
|
169
|
+
...(value.returnAttachment === undefined ? {} : { returnAttachment: decodeAttachment(value.returnAttachment) }),
|
|
170
|
+
...(value.returnText === undefined ? {} : { returnText: text(value.returnText, 'returnText', MAX_TEXT) }),
|
|
171
|
+
...(value.returnRowid === undefined ? {} : { returnRowid: integer(value.returnRowid, 'returnRowid', 1) }),
|
|
172
|
+
...(value.outcome === undefined ? {} : { outcome: decodeOutcome(value.outcome) }),
|
|
173
|
+
...(value.failure === undefined ? {} : { failure: decodeError(value.failure, 'failure') }),
|
|
174
|
+
...(value.lastAttemptAt === undefined ? {} : { lastAttemptAt: integer(value.lastAttemptAt, 'lastAttemptAt') })
|
|
175
|
+
};
|
|
176
|
+
requireStageFields(job);
|
|
177
|
+
return job;
|
|
178
|
+
}
|
|
179
|
+
function decodeSessionRoles(raw) {
|
|
180
|
+
const value = object(raw);
|
|
181
|
+
if (!value)
|
|
182
|
+
throw new Error('session roles must be an object');
|
|
183
|
+
if (value.version !== 1)
|
|
184
|
+
throw new Error(`unsupported session roles version ${String(value.version)}`);
|
|
185
|
+
const sessions = object(value.sessions);
|
|
186
|
+
if (!sessions)
|
|
187
|
+
throw new Error('sessions must be an object');
|
|
188
|
+
const decoded = {};
|
|
189
|
+
for (const [sessionId, rawAssignment] of Object.entries(sessions)) {
|
|
190
|
+
text(sessionId, 'session id');
|
|
191
|
+
const assignment = object(rawAssignment);
|
|
192
|
+
if (!assignment)
|
|
193
|
+
throw new Error(`session ${sessionId} role is invalid`);
|
|
194
|
+
const delegationId = assignment.delegationId;
|
|
195
|
+
if (delegationId !== undefined && (typeof delegationId !== 'string' || !SAFE_ID.test(delegationId))) {
|
|
196
|
+
throw new Error(`session ${sessionId} delegationId is invalid`);
|
|
197
|
+
}
|
|
198
|
+
decoded[sessionId] = {
|
|
199
|
+
role: text(assignment.role, `session ${sessionId} role`, 64),
|
|
200
|
+
...(delegationId === undefined ? {} : { delegationId }),
|
|
201
|
+
assignedAt: integer(assignment.assignedAt, `session ${sessionId} assignedAt`)
|
|
202
|
+
};
|
|
203
|
+
}
|
|
204
|
+
return decoded;
|
|
205
|
+
}
|
|
206
|
+
function atomicWrite(file, value) {
|
|
207
|
+
const temporary = `${file}.${process.pid}.tmp`;
|
|
208
|
+
fs.mkdirSync(path.dirname(file), { recursive: true });
|
|
209
|
+
try {
|
|
210
|
+
fs.writeFileSync(temporary, `${JSON.stringify(value, null, '\t')}\n`, { mode: 0o600 });
|
|
211
|
+
fs.chmodSync(temporary, 0o600);
|
|
212
|
+
fs.renameSync(temporary, file);
|
|
213
|
+
}
|
|
214
|
+
catch (err) {
|
|
215
|
+
try {
|
|
216
|
+
fs.unlinkSync(temporary);
|
|
217
|
+
}
|
|
218
|
+
catch { }
|
|
219
|
+
throw err;
|
|
220
|
+
}
|
|
221
|
+
}
|
|
222
|
+
export class DelegationStore {
|
|
223
|
+
directory;
|
|
224
|
+
constructor(worktree) {
|
|
225
|
+
this.directory = path.join(worktree, DIRECTORY);
|
|
226
|
+
}
|
|
227
|
+
list() {
|
|
228
|
+
let files;
|
|
229
|
+
try {
|
|
230
|
+
files = fs
|
|
231
|
+
.readdirSync(this.directory)
|
|
232
|
+
.filter(file => file.endsWith('.json') && file !== SESSIONS_FILE)
|
|
233
|
+
.sort();
|
|
234
|
+
}
|
|
235
|
+
catch (err) {
|
|
236
|
+
if (err.code === 'ENOENT')
|
|
237
|
+
return { jobs: [], warnings: [] };
|
|
238
|
+
return { jobs: [], warnings: [{ file: this.directory, message: String(err) }] };
|
|
239
|
+
}
|
|
240
|
+
const jobs = [];
|
|
241
|
+
const warnings = [];
|
|
242
|
+
for (const file of files) {
|
|
243
|
+
try {
|
|
244
|
+
jobs.push(decodeDelegation(JSON.parse(fs.readFileSync(path.join(this.directory, file), 'utf8'))));
|
|
245
|
+
}
|
|
246
|
+
catch (err) {
|
|
247
|
+
warnings.push({ file, message: err instanceof Error ? err.message : String(err) });
|
|
248
|
+
}
|
|
249
|
+
}
|
|
250
|
+
return { jobs, warnings };
|
|
251
|
+
}
|
|
252
|
+
get(id) {
|
|
253
|
+
if (!SAFE_ID.test(id))
|
|
254
|
+
return null;
|
|
255
|
+
try {
|
|
256
|
+
return decodeDelegation(JSON.parse(fs.readFileSync(path.join(this.directory, `${id}.json`), 'utf8')));
|
|
257
|
+
}
|
|
258
|
+
catch (err) {
|
|
259
|
+
if (err.code === 'ENOENT')
|
|
260
|
+
return null;
|
|
261
|
+
throw err;
|
|
262
|
+
}
|
|
263
|
+
}
|
|
264
|
+
put(raw) {
|
|
265
|
+
const job = decodeDelegation(raw);
|
|
266
|
+
atomicWrite(path.join(this.directory, `${job.id}.json`), job);
|
|
267
|
+
return job;
|
|
268
|
+
}
|
|
269
|
+
remove(id) {
|
|
270
|
+
if (!SAFE_ID.test(id))
|
|
271
|
+
return false;
|
|
272
|
+
try {
|
|
273
|
+
fs.unlinkSync(path.join(this.directory, `${id}.json`));
|
|
274
|
+
return true;
|
|
275
|
+
}
|
|
276
|
+
catch (err) {
|
|
277
|
+
if (err.code === 'ENOENT')
|
|
278
|
+
return false;
|
|
279
|
+
throw err;
|
|
280
|
+
}
|
|
281
|
+
}
|
|
282
|
+
sessionRoles() {
|
|
283
|
+
const file = path.join(this.directory, SESSIONS_FILE);
|
|
284
|
+
try {
|
|
285
|
+
return { sessions: decodeSessionRoles(JSON.parse(fs.readFileSync(file, 'utf8'))) };
|
|
286
|
+
}
|
|
287
|
+
catch (err) {
|
|
288
|
+
if (err.code === 'ENOENT')
|
|
289
|
+
return { sessions: {} };
|
|
290
|
+
return { sessions: {}, warning: err instanceof Error ? err.message : String(err) };
|
|
291
|
+
}
|
|
292
|
+
}
|
|
293
|
+
assign(sessionId, assignment) {
|
|
294
|
+
text(sessionId, 'session id');
|
|
295
|
+
const current = this.sessionRoles();
|
|
296
|
+
if (current.warning)
|
|
297
|
+
throw new Error(`cannot update malformed sessions.json: ${current.warning}`);
|
|
298
|
+
const sessions = decodeSessionRoles({
|
|
299
|
+
version: 1,
|
|
300
|
+
sessions: { ...current.sessions, [sessionId]: assignment }
|
|
301
|
+
});
|
|
302
|
+
atomicWrite(path.join(this.directory, SESSIONS_FILE), { version: 1, sessions });
|
|
303
|
+
}
|
|
304
|
+
}
|
|
305
|
+
// ── guarded transitions and the single queue producer ──────────────────────────
|
|
306
|
+
const NEXT = {
|
|
307
|
+
queued: ['opening', 'failed'],
|
|
308
|
+
opening: ['configuring', 'failed'],
|
|
309
|
+
configuring: ['sending', 'failed'],
|
|
310
|
+
sending: ['running', 'failed'],
|
|
311
|
+
running: ['returning', 'failed'],
|
|
312
|
+
returning: ['returned', 'failed'],
|
|
313
|
+
returned: [],
|
|
314
|
+
failed: []
|
|
315
|
+
};
|
|
316
|
+
/** Pure state edge: only stage data may change; the accepted envelope stays frozen. */
|
|
317
|
+
export function transitionDelegation(job, status, patch, updatedAt = Date.now()) {
|
|
318
|
+
if (!NEXT[job.status].includes(status))
|
|
319
|
+
throw new Error(`illegal delegation transition ${job.status} → ${status}`);
|
|
320
|
+
if (status === 'failed' && !patch.failure)
|
|
321
|
+
throw new Error('failed requires failure');
|
|
322
|
+
return decodeDelegation({
|
|
323
|
+
...job,
|
|
324
|
+
status,
|
|
325
|
+
updatedAt,
|
|
326
|
+
...(patch.childSessionId === undefined ? {} : { childSessionId: patch.childSessionId }),
|
|
327
|
+
...(patch.handoff === undefined ? {} : { handoff: patch.handoff }),
|
|
328
|
+
...(patch.sentRowid === undefined ? {} : { sentRowid: patch.sentRowid }),
|
|
329
|
+
...(patch.completionRowid === undefined ? {} : { completionRowid: patch.completionRowid }),
|
|
330
|
+
...(patch.returnCursor === undefined ? {} : { returnCursor: patch.returnCursor }),
|
|
331
|
+
...(patch.returnAttachment === undefined ? {} : { returnAttachment: patch.returnAttachment }),
|
|
332
|
+
...(patch.returnText === undefined ? {} : { returnText: patch.returnText }),
|
|
333
|
+
...(patch.returnRowid === undefined ? {} : { returnRowid: patch.returnRowid }),
|
|
334
|
+
...(patch.outcome === undefined ? {} : { outcome: patch.outcome }),
|
|
335
|
+
...(patch.failure === undefined ? {} : { failure: patch.failure }),
|
|
336
|
+
...(patch.attempts === undefined ? {} : { attempts: patch.attempts }),
|
|
337
|
+
...(patch.lastAttemptAt === undefined ? {} : { lastAttemptAt: patch.lastAttemptAt })
|
|
338
|
+
});
|
|
339
|
+
}
|
|
340
|
+
function actionCode(status) {
|
|
341
|
+
if (status === 'opening')
|
|
342
|
+
return 'opening_failed';
|
|
343
|
+
if (status === 'configuring')
|
|
344
|
+
return 'configuration_failed';
|
|
345
|
+
if (status === 'sending')
|
|
346
|
+
return 'send_failed';
|
|
347
|
+
if (status === 'returning')
|
|
348
|
+
return 'return_failed';
|
|
349
|
+
return 'state_invalid';
|
|
350
|
+
}
|
|
351
|
+
/**
|
|
352
|
+
* One producer for every worktree registered with this process. It performs at
|
|
353
|
+
* most one side effect at a time and stops at `running`; the shared session poller
|
|
354
|
+
* calls `wake()` on later ticks. That keeps waiting jobs out of the UI lock.
|
|
355
|
+
*/
|
|
356
|
+
export class DelegationQueue {
|
|
357
|
+
deps;
|
|
358
|
+
now;
|
|
359
|
+
retryDelayMs;
|
|
360
|
+
maxAttempts;
|
|
361
|
+
blockedError;
|
|
362
|
+
onError;
|
|
363
|
+
stores = new Set();
|
|
364
|
+
completionCandidates = new Map();
|
|
365
|
+
pumping = null;
|
|
366
|
+
rerun = false;
|
|
367
|
+
constructor(deps, options = {}) {
|
|
368
|
+
this.deps = deps;
|
|
369
|
+
this.now = options.now ?? Date.now;
|
|
370
|
+
this.retryDelayMs = options.retryDelayMs ?? 5_000;
|
|
371
|
+
this.maxAttempts = options.maxAttempts ?? 3;
|
|
372
|
+
this.blockedError = options.blockedError ?? (() => false);
|
|
373
|
+
this.onError = options.onError ?? (message => console.warn(`[relay] ${message}`));
|
|
374
|
+
}
|
|
375
|
+
/** Persist intake before returning, then let the queue finish independently. */
|
|
376
|
+
enqueue(store, raw) {
|
|
377
|
+
const roles = store.sessionRoles();
|
|
378
|
+
if (roles.warning)
|
|
379
|
+
throw new Error(`cannot enqueue beside malformed sessions.json: ${roles.warning}`);
|
|
380
|
+
if (!roles.sessions[raw.parentSessionId]) {
|
|
381
|
+
store.assign(raw.parentSessionId, { role: 'planning', assignedAt: raw.createdAt });
|
|
382
|
+
}
|
|
383
|
+
const job = store.put(raw);
|
|
384
|
+
this.stores.add(store);
|
|
385
|
+
void this.wake();
|
|
386
|
+
return job;
|
|
387
|
+
}
|
|
388
|
+
/** Register persisted work from startup without rewriting it. */
|
|
389
|
+
resume(stores) {
|
|
390
|
+
for (const store of stores)
|
|
391
|
+
this.stores.add(store);
|
|
392
|
+
void this.wake();
|
|
393
|
+
}
|
|
394
|
+
/** One shared-poller tick (or intake); concurrent calls join the same producer. */
|
|
395
|
+
wake() {
|
|
396
|
+
this.rerun = true;
|
|
397
|
+
if (!this.pumping) {
|
|
398
|
+
this.pumping = this.pump()
|
|
399
|
+
.catch(err => this.onError(`delegation queue failed: ${err instanceof Error ? err.message : err}`))
|
|
400
|
+
.finally(() => {
|
|
401
|
+
this.pumping = null;
|
|
402
|
+
if (this.rerun)
|
|
403
|
+
void this.wake();
|
|
404
|
+
});
|
|
405
|
+
}
|
|
406
|
+
return this.pumping;
|
|
407
|
+
}
|
|
408
|
+
async invoke(status, action) {
|
|
409
|
+
try {
|
|
410
|
+
return await action();
|
|
411
|
+
}
|
|
412
|
+
catch (err) {
|
|
413
|
+
return {
|
|
414
|
+
ok: false,
|
|
415
|
+
code: actionCode(status),
|
|
416
|
+
error: err instanceof Error ? err.message : String(err),
|
|
417
|
+
retryable: true,
|
|
418
|
+
blocked: this.blockedError(err)
|
|
419
|
+
};
|
|
420
|
+
}
|
|
421
|
+
}
|
|
422
|
+
async pump() {
|
|
423
|
+
do {
|
|
424
|
+
this.rerun = false;
|
|
425
|
+
while (await this.stepOne()) { }
|
|
426
|
+
} while (this.rerun);
|
|
427
|
+
}
|
|
428
|
+
/** Find one job able to make progress. Waiting/blocked jobs do not hold up others. */
|
|
429
|
+
async stepOne() {
|
|
430
|
+
const entries = [...this.stores]
|
|
431
|
+
.flatMap(store => store.list().jobs.map(job => ({ store, job })))
|
|
432
|
+
.sort((a, b) => a.job.createdAt - b.job.createdAt || a.job.id.localeCompare(b.job.id));
|
|
433
|
+
for (const { store, job } of entries) {
|
|
434
|
+
if (job.status === 'failed')
|
|
435
|
+
continue;
|
|
436
|
+
if (job.status === 'returned') {
|
|
437
|
+
store.remove(job.id);
|
|
438
|
+
return true;
|
|
439
|
+
}
|
|
440
|
+
if (job.status === 'queued') {
|
|
441
|
+
store.put(transitionDelegation(job, 'opening', {}, this.now()));
|
|
442
|
+
return true;
|
|
443
|
+
}
|
|
444
|
+
if (this.retryPending(job))
|
|
445
|
+
continue;
|
|
446
|
+
if (job.status === 'opening') {
|
|
447
|
+
const result = await this.invoke('opening', () => this.deps.open(job));
|
|
448
|
+
if (!result.ok) {
|
|
449
|
+
if (this.recordFailure(store, job, result))
|
|
450
|
+
return true;
|
|
451
|
+
continue;
|
|
452
|
+
}
|
|
453
|
+
store.assign(result.childSessionId, {
|
|
454
|
+
role: job.role,
|
|
455
|
+
delegationId: job.id,
|
|
456
|
+
assignedAt: this.now()
|
|
457
|
+
});
|
|
458
|
+
store.put(transitionDelegation(job, 'configuring', { childSessionId: result.childSessionId, handoff: result.handoff }, this.now()));
|
|
459
|
+
return true;
|
|
460
|
+
}
|
|
461
|
+
if (job.status === 'configuring') {
|
|
462
|
+
const result = await this.invoke('configuring', () => this.deps.configure(job));
|
|
463
|
+
if (!result.ok) {
|
|
464
|
+
if (this.recordFailure(store, job, result))
|
|
465
|
+
return true;
|
|
466
|
+
continue;
|
|
467
|
+
}
|
|
468
|
+
store.put(transitionDelegation(job, 'sending', {}, this.now()));
|
|
469
|
+
return true;
|
|
470
|
+
}
|
|
471
|
+
if (job.status === 'sending') {
|
|
472
|
+
const result = await this.invoke('sending', () => this.deps.send(job));
|
|
473
|
+
if (!result.ok) {
|
|
474
|
+
if (this.recordFailure(store, job, result))
|
|
475
|
+
return true;
|
|
476
|
+
continue;
|
|
477
|
+
}
|
|
478
|
+
store.put(transitionDelegation(job, 'running', { sentRowid: result.sentRowid }, this.now()));
|
|
479
|
+
return true;
|
|
480
|
+
}
|
|
481
|
+
if (job.status === 'running') {
|
|
482
|
+
let completion;
|
|
483
|
+
try {
|
|
484
|
+
completion = await this.deps.completion(job);
|
|
485
|
+
}
|
|
486
|
+
catch (err) {
|
|
487
|
+
console.warn(`[relay] delegation completion read failed: ${err instanceof Error ? err.message : err}`);
|
|
488
|
+
continue;
|
|
489
|
+
}
|
|
490
|
+
const key = `${job.workspaceId}\0${job.id}`;
|
|
491
|
+
if (!completion) {
|
|
492
|
+
this.completionCandidates.delete(key);
|
|
493
|
+
continue;
|
|
494
|
+
}
|
|
495
|
+
const signature = JSON.stringify(completion);
|
|
496
|
+
if (this.completionCandidates.get(key) !== signature) {
|
|
497
|
+
this.completionCandidates.set(key, signature);
|
|
498
|
+
continue;
|
|
499
|
+
}
|
|
500
|
+
this.completionCandidates.delete(key);
|
|
501
|
+
store.put(transitionDelegation(job, 'returning', { outcome: completion.outcome, completionRowid: completion.completionRowid }, this.now()));
|
|
502
|
+
return true;
|
|
503
|
+
}
|
|
504
|
+
if (job.status === 'returning') {
|
|
505
|
+
const result = await this.invoke('returning', () => this.deps.returnResult(job));
|
|
506
|
+
if (!result.ok) {
|
|
507
|
+
if (this.recordFailure(store, job, result))
|
|
508
|
+
return true;
|
|
509
|
+
continue;
|
|
510
|
+
}
|
|
511
|
+
if ('pending' in result) {
|
|
512
|
+
// A queued Baton clears the composer before SQLite exposes any row.
|
|
513
|
+
// Persist the pre-dispatch cursor once, then only poll for the exact
|
|
514
|
+
// eventual row; repeating the UI action here would enqueue a duplicate.
|
|
515
|
+
if (job.returnCursor === result.returnCursor)
|
|
516
|
+
continue;
|
|
517
|
+
store.put(decodeDelegation({
|
|
518
|
+
...job,
|
|
519
|
+
returnCursor: result.returnCursor,
|
|
520
|
+
returnAttachment: result.returnAttachment,
|
|
521
|
+
returnText: result.returnText,
|
|
522
|
+
updatedAt: this.now()
|
|
523
|
+
}));
|
|
524
|
+
return true;
|
|
525
|
+
}
|
|
526
|
+
store.put(transitionDelegation(job, 'returned', { returnRowid: result.returnRowid }, this.now()));
|
|
527
|
+
return true;
|
|
528
|
+
}
|
|
529
|
+
}
|
|
530
|
+
return false;
|
|
531
|
+
}
|
|
532
|
+
retryPending(job) {
|
|
533
|
+
return job.attempts > 0 && job.lastAttemptAt !== undefined && this.now() - job.lastAttemptAt < this.retryDelayMs;
|
|
534
|
+
}
|
|
535
|
+
/** True when persistence changed; false for a lock-blocked/no-cost attempt. */
|
|
536
|
+
recordFailure(store, job, result) {
|
|
537
|
+
if (result.blocked)
|
|
538
|
+
return false;
|
|
539
|
+
const attempts = job.attempts + 1;
|
|
540
|
+
const now = this.now();
|
|
541
|
+
if (attempts >= this.maxAttempts || result.retryable === false) {
|
|
542
|
+
store.put(transitionDelegation(job, 'failed', {
|
|
543
|
+
attempts,
|
|
544
|
+
lastAttemptAt: now,
|
|
545
|
+
failure: {
|
|
546
|
+
code: result.code ?? actionCode(job.status),
|
|
547
|
+
message: result.error,
|
|
548
|
+
retryable: result.retryable !== false
|
|
549
|
+
}
|
|
550
|
+
}, now));
|
|
551
|
+
return true;
|
|
552
|
+
}
|
|
553
|
+
store.put(decodeDelegation({ ...job, attempts, lastAttemptAt: now, updatedAt: now }));
|
|
554
|
+
return true;
|
|
555
|
+
}
|
|
556
|
+
}
|
|
@@ -89,7 +89,7 @@ export class FirstPromptQueue {
|
|
|
89
89
|
* prompt lands (`null`) or is given up on (the failed entry) — awaited by API
|
|
90
90
|
* callers that asked to block, ignored by the phone.
|
|
91
91
|
*/
|
|
92
|
-
enqueue(workspaceId, text, sendImmediately = true, attachmentIds = [], agent) {
|
|
92
|
+
enqueue(workspaceId, text, sendImmediately = true, attachmentIds = [], agent, sessionRole) {
|
|
93
93
|
this.entries = [
|
|
94
94
|
...this.entries.filter(e => e.workspaceId !== workspaceId),
|
|
95
95
|
{
|
|
@@ -97,6 +97,7 @@ export class FirstPromptQueue {
|
|
|
97
97
|
text,
|
|
98
98
|
...(agent && Object.keys(agent).length ? { agent } : {}),
|
|
99
99
|
...(attachmentIds.length ? { attachmentIds } : {}),
|
|
100
|
+
...(sessionRole ? { sessionRole } : {}),
|
|
100
101
|
status: 'waiting',
|
|
101
102
|
attempts: 0,
|
|
102
103
|
createdAt: Date.now(),
|
|
@@ -185,6 +186,17 @@ export class FirstPromptQueue {
|
|
|
185
186
|
}
|
|
186
187
|
if (!target?.sessionId)
|
|
187
188
|
return;
|
|
189
|
+
if (entry.sessionRole && !entry.sessionRoleAssigned) {
|
|
190
|
+
if (!target.worktree)
|
|
191
|
+
return;
|
|
192
|
+
if (!this.deps.assignRole)
|
|
193
|
+
return this.fail(entry, 'the relay cannot assign the workflow root role');
|
|
194
|
+
const assigned = await this.deps.assignRole(entry.workspaceId, target.sessionId, entry.sessionRole, entry.createdAt);
|
|
195
|
+
if (!assigned.ok)
|
|
196
|
+
return this.fail(entry, assigned.error ?? 'the workflow root role could not be saved');
|
|
197
|
+
entry.sessionRoleAssigned = true;
|
|
198
|
+
this.save();
|
|
199
|
+
}
|
|
188
200
|
// It already went — the user sent it from the Mac, where the deep link left it
|
|
189
201
|
// pre-filled in the composer. Sending again would double it, and changing the
|
|
190
202
|
// agent now would affect a later turn instead of the first one they configured.
|
|
@@ -309,6 +321,8 @@ export class FirstPromptQueue {
|
|
|
309
321
|
Date.now() - (e.createdAt ?? 0) < KEEP_FAILED_MS)
|
|
310
322
|
.map(e => ({
|
|
311
323
|
...e,
|
|
324
|
+
sessionRole: typeof e.sessionRole === 'string' && e.sessionRole.trim() ? e.sessionRole.trim() : undefined,
|
|
325
|
+
sessionRoleAssigned: e.sessionRoleAssigned === true ? true : undefined,
|
|
312
326
|
attachmentIds: Array.isArray(e.attachmentIds)
|
|
313
327
|
? e.attachmentIds.filter((id) => typeof id === 'string')
|
|
314
328
|
: undefined
|