clay-server 4.0.0 → 4.1.0-beta.1
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/lib/daemon-project-access.js +118 -0
- package/lib/daemon-projects.js +19 -5
- package/lib/daemon.js +19 -18
- package/lib/project-logs-access-scope.js +94 -0
- package/lib/project-logs-query.js +3 -0
- package/lib/project-logs-service.js +86 -84
- package/lib/project-logs-store.js +5 -3
- package/lib/project-pair-lifecycle.js +71 -61
- package/lib/project-pair-message.js +50 -0
- package/lib/project-pair-replacement-state.js +72 -0
- package/lib/project-pair-task-control.js +264 -0
- package/lib/project-pair-usage.js +73 -0
- package/lib/project-session-pair.js +115 -116
- package/lib/project-sessions.js +40 -6
- package/lib/project-worker-proposal.js +54 -57
- package/lib/project.js +27 -4
- package/lib/public/app.js +1 -0
- package/lib/public/css/command-palette.css +26 -0
- package/lib/public/css/icon-strip.css +40 -0
- package/lib/public/index.html +2 -2
- package/lib/public/modules/app-message-router.js +1 -1
- package/lib/public/modules/app-messages.js +9 -0
- package/lib/public/modules/project-access.js +141 -0
- package/lib/public/modules/project-grouping.js +22 -0
- package/lib/public/modules/search-clay-chat.js +47 -6
- package/lib/public/modules/search-clay-controls.js +86 -0
- package/lib/public/modules/sidebar-projects.js +14 -171
- package/lib/public/modules/worker-proposal.js +4 -0
- package/lib/public/style.css +1 -1
- package/lib/sdk-bridge.js +21 -1
- package/lib/server-admin.js +3 -3
- package/lib/server-global-ws.js +10 -0
- package/lib/server-home-chat-events.js +3 -0
- package/lib/server-home-chat.js +35 -0
- package/lib/server-mates.js +4 -3
- package/lib/server-palette.js +17 -14
- package/lib/server.js +184 -102
- package/lib/session-pair-factory.js +3 -0
- package/lib/session-pair-history.js +40 -0
- package/lib/session-pair-mcp-server.js +12 -1
- package/lib/session-pair-operation.js +11 -0
- package/lib/session-pair-prompts.js +24 -2
- package/lib/session-pair-turn-control.js +8 -3
- package/lib/worker-proposal-control.js +69 -0
- package/lib/worker-proposal-decision.js +7 -0
- package/lib/worker-runtime-catalog.js +52 -0
- package/lib/workspace-query-access.js +3 -2
- package/lib/workspace-query-service.js +1 -1
- package/lib/worktree.js +8 -4
- package/lib/ws-schema.js +7 -0
- package/lib/yoke/adapters/codex.js +6 -2
- package/package.json +1 -1
|
@@ -341,11 +341,12 @@ function createProjectLogsStore(opts) {
|
|
|
341
341
|
// revision that changes category updates the vocabulary without rewriting a
|
|
342
342
|
// single historical record. A shared project shares this list because it
|
|
343
343
|
// shares this store.
|
|
344
|
-
function categories() {
|
|
344
|
+
function categories(options) {
|
|
345
345
|
var seen = {};
|
|
346
346
|
var out = [];
|
|
347
347
|
var live = entries(false);
|
|
348
348
|
for (var i = 0; i < live.length; i++) {
|
|
349
|
+
if (!logsQuery.matchesContext(live[i], options || {})) continue;
|
|
349
350
|
var value = live[i].category;
|
|
350
351
|
if (!value || seen[value]) continue;
|
|
351
352
|
seen[value] = true;
|
|
@@ -382,6 +383,7 @@ function createProjectLogsStore(opts) {
|
|
|
382
383
|
var out = [];
|
|
383
384
|
var total = 0;
|
|
384
385
|
for (var i = 0; i < live.length; i++) {
|
|
386
|
+
if (!logsQuery.matchesContext(live[i], options2)) continue;
|
|
385
387
|
var awaiting = logsComments.pendingReviewComments(live[i].comments);
|
|
386
388
|
for (var j = 0; j < awaiting.length; j++) {
|
|
387
389
|
total++;
|
|
@@ -408,13 +410,13 @@ function createProjectLogsStore(opts) {
|
|
|
408
410
|
function list(args) {
|
|
409
411
|
var options2 = args || {};
|
|
410
412
|
var result = logsQuery.listEntries(entries(options2.includeDeleted === true), options2, cleanLine, validateKind);
|
|
411
|
-
result.categories = categories();
|
|
413
|
+
result.categories = categories(options2);
|
|
412
414
|
return result;
|
|
413
415
|
}
|
|
414
416
|
|
|
415
417
|
function search(args) {
|
|
416
418
|
var result = logsQuery.searchEntries(entries(false), args || {}, cleanLine, validateKind);
|
|
417
|
-
result.categories = categories();
|
|
419
|
+
result.categories = categories(args || {});
|
|
418
420
|
return result;
|
|
419
421
|
}
|
|
420
422
|
|
|
@@ -14,6 +14,10 @@
|
|
|
14
14
|
// into, and inventing a destructive one would lose work.
|
|
15
15
|
|
|
16
16
|
var eligibility = require("./session-driver-eligibility");
|
|
17
|
+
var replacementState = require("./project-pair-replacement-state");
|
|
18
|
+
var pairUsage = require("./project-pair-usage");
|
|
19
|
+
var contextStatus = pairUsage.contextStatus;
|
|
20
|
+
var firstNumber = pairUsage.firstNumber;
|
|
17
21
|
|
|
18
22
|
var MAX_GENERATIONS = 5;
|
|
19
23
|
var MAX_NOTE_CHARS = 400;
|
|
@@ -38,58 +42,10 @@ function clampText(value, max) {
|
|
|
38
42
|
return text;
|
|
39
43
|
}
|
|
40
44
|
|
|
41
|
-
function
|
|
42
|
-
|
|
43
|
-
|
|
44
|
-
|
|
45
|
-
}
|
|
46
|
-
return null;
|
|
47
|
-
}
|
|
48
|
-
|
|
49
|
-
// The Worker's context position, from authoritative session accounting only:
|
|
50
|
-
// the SDK's own getContextUsage snapshot when the adapter provides one, else
|
|
51
|
-
// the last result message's usage totals. Field names differ across adapters,
|
|
52
|
-
// so each candidate is probed and `source` states which reading was used.
|
|
53
|
-
// Never estimated from history text.
|
|
54
|
-
function contextStatus(session) {
|
|
55
|
-
var snapshot = session.lastContextUsage || null;
|
|
56
|
-
var used = null;
|
|
57
|
-
var window = null;
|
|
58
|
-
var source = "unavailable";
|
|
59
|
-
|
|
60
|
-
if (snapshot && typeof snapshot === "object") {
|
|
61
|
-
used = firstNumber(snapshot.totalTokens, snapshot.usedTokens, snapshot.tokens, snapshot.inputTokens);
|
|
62
|
-
window = firstNumber(snapshot.contextWindow, snapshot.maxTokens, snapshot.windowSize, snapshot.limit);
|
|
63
|
-
if (used !== null || window !== null) source = "sdk_context_usage";
|
|
64
|
-
}
|
|
65
|
-
|
|
66
|
-
if (used === null) {
|
|
67
|
-
var history = session.history || [];
|
|
68
|
-
for (var i = history.length - 1; i >= 0; i--) {
|
|
69
|
-
if (!history[i] || history[i].type !== "result") continue;
|
|
70
|
-
var usage = history[i].usage || null;
|
|
71
|
-
if (!usage) break;
|
|
72
|
-
var input = firstNumber(usage.input_tokens, usage.inputTokens) || 0;
|
|
73
|
-
var cacheRead = firstNumber(usage.cache_read_input_tokens, usage.cacheReadInputTokens) || 0;
|
|
74
|
-
var cacheWrite = firstNumber(usage.cache_creation_input_tokens, usage.cacheCreationInputTokens) || 0;
|
|
75
|
-
var output = firstNumber(usage.output_tokens, usage.outputTokens) || 0;
|
|
76
|
-
used = input + cacheRead + cacheWrite + output;
|
|
77
|
-
source = "last_result_usage";
|
|
78
|
-
break;
|
|
79
|
-
}
|
|
80
|
-
}
|
|
81
|
-
|
|
82
|
-
var ratio = null;
|
|
83
|
-
if (used !== null && window !== null && window > 0) {
|
|
84
|
-
ratio = Math.round((used / window) * 1000) / 1000;
|
|
85
|
-
if (ratio > 1) ratio = 1;
|
|
86
|
-
}
|
|
87
|
-
return {
|
|
88
|
-
source: source,
|
|
89
|
-
usedTokens: used,
|
|
90
|
-
contextWindow: window,
|
|
91
|
-
usedRatio: ratio,
|
|
92
|
-
};
|
|
45
|
+
function replacementFingerprint(args, worker) {
|
|
46
|
+
return JSON.stringify({ sourceWorkerId: worker.localId, interrupt: args.interrupt === true,
|
|
47
|
+
vendor: args.workerVendor || "", model: args.workerModel || "", effort: args.workerEffort || "",
|
|
48
|
+
message: args.message || "", evaluation: args.evaluation || null });
|
|
93
49
|
}
|
|
94
50
|
|
|
95
51
|
function continuityStatus(session) {
|
|
@@ -119,6 +75,8 @@ function activityStatus(session) {
|
|
|
119
75
|
isProcessing: !!(session.isProcessing || session._queryStarting),
|
|
120
76
|
delegated: !!token,
|
|
121
77
|
currentTask: token ? clampText(token.message, MAX_TASK_PREVIEW_CHARS) : "",
|
|
78
|
+
currentTaskId: token && token.taskId || null,
|
|
79
|
+
interruption: session._pairInterruption || null,
|
|
122
80
|
lastTurnInterrupted: !!session._lastTurnInterrupted,
|
|
123
81
|
};
|
|
124
82
|
}
|
|
@@ -177,8 +135,8 @@ function attachPairLifecycle(ctx) {
|
|
|
177
135
|
userTurns: continuity.userTurns,
|
|
178
136
|
errorEntries: continuity.errorEntries,
|
|
179
137
|
interrupted: !!worker._lastTurnInterrupted,
|
|
180
|
-
usedTokens: context.usedTokens,
|
|
181
|
-
usedRatio: context.usedRatio,
|
|
138
|
+
usedTokens: context.current.usedTokens,
|
|
139
|
+
usedRatio: context.current.usedRatio,
|
|
182
140
|
};
|
|
183
141
|
return record;
|
|
184
142
|
}
|
|
@@ -217,6 +175,7 @@ function attachPairLifecycle(ctx) {
|
|
|
217
175
|
var resolved = resolveDriverPair(caller);
|
|
218
176
|
var worker = resolved.worker;
|
|
219
177
|
var blocked = turnControl.blockedReason(caller) || replaceBlockedReason(worker);
|
|
178
|
+
var resumeBlocked = turnControl.blockedReason(caller) || (worker.isProcessing || worker._queryStarting ? "the Split Worker is mid-turn" : (!worker._pairInterruption ? "the Split Worker has no interrupted task" : null));
|
|
220
179
|
var ledger = ledgerFor(caller).map(function (record) {
|
|
221
180
|
return {
|
|
222
181
|
generation: record.generation,
|
|
@@ -228,6 +187,23 @@ function attachPairLifecycle(ctx) {
|
|
|
228
187
|
};
|
|
229
188
|
});
|
|
230
189
|
return {
|
|
190
|
+
identity: {
|
|
191
|
+
sessionId: worker.localId,
|
|
192
|
+
originId: worker.sessionOriginId || null,
|
|
193
|
+
kind: worker.sessionProvenance && worker.sessionProvenance.kind || "worker",
|
|
194
|
+
generation: worker._pairGeneration || worker.sessionProvenance && worker.sessionProvenance.generation || null,
|
|
195
|
+
},
|
|
196
|
+
configuration: {
|
|
197
|
+
vendor: worker.vendor || null,
|
|
198
|
+
model: worker.model || null,
|
|
199
|
+
effort: worker.effort || null,
|
|
200
|
+
permissionMode: worker.permissionMode || null,
|
|
201
|
+
},
|
|
202
|
+
time: {
|
|
203
|
+
observedAt: Date.now(),
|
|
204
|
+
createdAt: worker.sessionProvenance && worker.sessionProvenance.createdAt || null,
|
|
205
|
+
lastActivityAt: firstNumber(worker.lastActivityAt, worker.lastActivity),
|
|
206
|
+
},
|
|
231
207
|
worker: {
|
|
232
208
|
sessionId: worker.localId,
|
|
233
209
|
title: worker.title || "New Session",
|
|
@@ -242,6 +218,13 @@ function attachPairLifecycle(ctx) {
|
|
|
242
218
|
orchestration: turnControl.status(caller),
|
|
243
219
|
replaceSafe: !blocked,
|
|
244
220
|
replaceBlockedReason: blocked,
|
|
221
|
+
canReplace: !blocked,
|
|
222
|
+
canReplaceBlockedReason: blocked,
|
|
223
|
+
canResume: !resumeBlocked,
|
|
224
|
+
resumeBlockedReason: resumeBlocked,
|
|
225
|
+
tasks: ctx.taskStatus ? ctx.taskStatus(worker) : null,
|
|
226
|
+
replacement: caller._lastPairReplacement || null,
|
|
227
|
+
proposal: ctx.proposalStatus ? ctx.proposalStatus(caller) : null,
|
|
245
228
|
generations: ledger,
|
|
246
229
|
};
|
|
247
230
|
}
|
|
@@ -256,45 +239,67 @@ function attachPairLifecycle(ctx) {
|
|
|
256
239
|
var group = resolved.group;
|
|
257
240
|
var oldWorker = resolved.worker;
|
|
258
241
|
turnControl.assertWorkerAction(caller);
|
|
242
|
+
var replacementEntry = replacementState.begin(caller, args.transactionId, oldWorker, replacementFingerprint(args, oldWorker));
|
|
243
|
+
var replay = replacementState.replayValue(replacementEntry);
|
|
244
|
+
if (replay) return Promise.resolve(replay);
|
|
259
245
|
|
|
260
246
|
// Validate the replacement while the old pair is still fully intact. An
|
|
261
247
|
// uninstalled vendor, an unavailable model or an unsupported effort must
|
|
262
248
|
// never cost the user their running Worker, so this precedes the interrupt,
|
|
263
249
|
// the permission cancellation and the dissolve.
|
|
264
|
-
|
|
265
|
-
|
|
266
|
-
|
|
267
|
-
|
|
268
|
-
|
|
250
|
+
try {
|
|
251
|
+
ctx.preflightWorkerForDriver(caller, {
|
|
252
|
+
workerVendor: args.workerVendor,
|
|
253
|
+
workerModel: args.workerModel,
|
|
254
|
+
workerEffort: args.workerEffort,
|
|
255
|
+
});
|
|
256
|
+
} catch (preflightError) {
|
|
257
|
+
replacementState.fail(replacementEntry.transaction, "preflight_failed", preflightError, "not_required");
|
|
258
|
+
throw preflightError;
|
|
259
|
+
}
|
|
269
260
|
|
|
270
261
|
var blocked = replaceBlockedReason(oldWorker);
|
|
271
262
|
if (blocked && args.interrupt !== true) {
|
|
263
|
+
replacementState.fail(replacementEntry.transaction, "preflight_failed", new Error(blocked), "not_required");
|
|
272
264
|
throw new Error("cannot replace the Split Worker because " + blocked +
|
|
273
265
|
"; call again with interrupt set to true to stop it first");
|
|
274
266
|
}
|
|
275
267
|
|
|
276
|
-
|
|
277
|
-
|
|
268
|
+
var creationTicket;
|
|
269
|
+
try {
|
|
270
|
+
if (args.evaluation) validateEvaluation(args.evaluation);
|
|
271
|
+
creationTicket = turnControl.reserveCreation(caller, "replace");
|
|
272
|
+
} catch (validationError) {
|
|
273
|
+
replacementState.fail(replacementEntry.transaction, "preflight_failed", validationError, "not_required");
|
|
274
|
+
throw validationError;
|
|
275
|
+
}
|
|
278
276
|
|
|
279
277
|
if (blocked) {
|
|
278
|
+
replacementState.stage(replacementEntry.transaction, "stopping_source");
|
|
279
|
+
replacementEntry.transaction.sourceStopped = true;
|
|
280
|
+
if (ctx.markInterruption) ctx.markInterruption(oldWorker, "driver", "The Driver accepted replacement of this Worker generation.");
|
|
280
281
|
oldWorker.taskStopRequested = true;
|
|
281
282
|
if (oldWorker.abortController) {
|
|
282
283
|
try { oldWorker.abortController.abort(); } catch (e) {}
|
|
283
284
|
}
|
|
284
285
|
if (oldWorker._pairDelegation && typeof ctx.finishDelegation === "function") {
|
|
286
|
+
if (typeof ctx.completeInterruptedTask === "function") ctx.completeInterruptedTask(oldWorker, caller, oldWorker._pairDelegation);
|
|
285
287
|
ctx.finishDelegation(group, caller, oldWorker, oldWorker._pairDelegation);
|
|
286
288
|
}
|
|
287
289
|
}
|
|
288
290
|
|
|
289
291
|
// Any permission decision the old Worker was waiting on dies with the pair.
|
|
292
|
+
replacementState.stage(replacementEntry.transaction, "cancelling_source_waits");
|
|
290
293
|
if (typeof ctx.cancelWorkerPermissions === "function") {
|
|
291
294
|
ctx.cancelWorkerPermissions(oldWorker, "The Driver replaced this Split Worker.");
|
|
292
295
|
}
|
|
293
296
|
|
|
294
297
|
var ws = { _clayUser: caller.ownerId ? { id: caller.ownerId } : null };
|
|
298
|
+
replacementState.stage(replacementEntry.transaction, "dissolving_source_pair");
|
|
295
299
|
var dissolved = store.dissolve(ws, { id: group.id });
|
|
296
300
|
if (!dissolved.ok) {
|
|
297
301
|
turnControl.releaseCreation(creationTicket);
|
|
302
|
+
replacementState.fail(replacementEntry.transaction, "dissolve_failed", new Error(dissolved.error || "could not dissolve"), "not_required");
|
|
298
303
|
throw new Error(dissolved.error || "could not dissolve the existing pair");
|
|
299
304
|
}
|
|
300
305
|
|
|
@@ -316,6 +321,7 @@ function attachPairLifecycle(ctx) {
|
|
|
316
321
|
// come back. The error says so rather than implying a clean restore.
|
|
317
322
|
var created;
|
|
318
323
|
try {
|
|
324
|
+
replacementState.stage(replacementEntry.transaction, "creating_target");
|
|
319
325
|
created = ctx.createWorkerForDriver(caller, {
|
|
320
326
|
workerVendor: typeof args.workerVendor === "string" ? args.workerVendor : "",
|
|
321
327
|
workerModel: typeof args.workerModel === "string" ? args.workerModel : "",
|
|
@@ -333,6 +339,7 @@ function attachPairLifecycle(ctx) {
|
|
|
333
339
|
? " Its interrupted turn cannot be resumed, because stopping it was explicitly requested."
|
|
334
340
|
: "";
|
|
335
341
|
turnControl.releaseCreation(creationTicket);
|
|
342
|
+
replacementState.fail(replacementEntry.transaction, "creation_failed", e, restored && restored.ok ? "source_pair_restored" : "source_pair_restore_failed");
|
|
336
343
|
throw new Error("could not create the replacement Split Worker: " + (e.message || String(e)) +
|
|
337
344
|
". " + restoreNote + interruptNote);
|
|
338
345
|
}
|
|
@@ -344,6 +351,7 @@ function attachPairLifecycle(ctx) {
|
|
|
344
351
|
status: "replaced",
|
|
345
352
|
previousWorkerSessionId: oldWorker.localId,
|
|
346
353
|
previousWorkerHistoryPreserved: true,
|
|
354
|
+
previousWorkerFilesPreserved: true,
|
|
347
355
|
previousGeneration: closed ? closed.generation : null,
|
|
348
356
|
interrupted: !!blocked,
|
|
349
357
|
workerSessionId: created.worker.localId,
|
|
@@ -351,7 +359,9 @@ function attachPairLifecycle(ctx) {
|
|
|
351
359
|
vendor: created.worker.vendor || null,
|
|
352
360
|
model: created.worker.model || null,
|
|
353
361
|
effort: created.worker.effort || null,
|
|
362
|
+
transactionId: replacementEntry.transaction.transactionId,
|
|
354
363
|
};
|
|
364
|
+
replacementState.complete(replacementEntry.transaction, result);
|
|
355
365
|
|
|
356
366
|
var message = typeof args.message === "string" ? args.message.trim() : "";
|
|
357
367
|
if (!message) return Promise.resolve(result);
|
|
@@ -0,0 +1,50 @@
|
|
|
1
|
+
var crypto = require("crypto");
|
|
2
|
+
|
|
3
|
+
function toolResult(value) {
|
|
4
|
+
return Promise.resolve({ content: [{ type: "text", text: JSON.stringify(value) }] });
|
|
5
|
+
}
|
|
6
|
+
|
|
7
|
+
function requestId(value) {
|
|
8
|
+
if (typeof value !== "string") return "pmsg_" + crypto.randomUUID();
|
|
9
|
+
var clean = value.trim();
|
|
10
|
+
if (!clean || clean.length > 120 || !/^[A-Za-z0-9._:-]+$/.test(clean)) return null;
|
|
11
|
+
return clean;
|
|
12
|
+
}
|
|
13
|
+
|
|
14
|
+
function attachPairMessage(ctx) {
|
|
15
|
+
function send(args, caller) {
|
|
16
|
+
var id = requestId(args.requestId);
|
|
17
|
+
if (!id) return toolResult({ status: "rejected", requestId: null, reason: "requestId is invalid" });
|
|
18
|
+
try {
|
|
19
|
+
ctx.turnControl.assertWorkerAction(caller);
|
|
20
|
+
var resolved = ctx.resolvePair(caller);
|
|
21
|
+
var worker = resolved.partner;
|
|
22
|
+
var message = typeof args.message === "string" ? args.message.trim() : "";
|
|
23
|
+
if (!message) return toolResult({ status: "rejected", requestId: id, partnerId: worker.localId, reason: "message is required" });
|
|
24
|
+
if (!worker.isProcessing && !worker._queryStarting) {
|
|
25
|
+
return toolResult({ status: "rejected", requestId: id, partnerId: worker.localId, reason: "the Split Worker has no active turn" });
|
|
26
|
+
}
|
|
27
|
+
var sdk = ctx.getSdk();
|
|
28
|
+
if (!sdk || !sdk.pushMessage(worker, message)) {
|
|
29
|
+
return toolResult({ status: "rejected", requestId: id, partnerId: worker.localId, reason: "the active Worker runtime did not accept the message" });
|
|
30
|
+
}
|
|
31
|
+
ctx.sm.sendAndRecord(worker, {
|
|
32
|
+
type: "partner_message",
|
|
33
|
+
text: message,
|
|
34
|
+
requestId: id,
|
|
35
|
+
delegatedBy: caller.localId,
|
|
36
|
+
});
|
|
37
|
+
worker.lastActivity = Date.now();
|
|
38
|
+
return toolResult({ status: "queued", requestId: id, partnerId: worker.localId, acknowledgement: "input_queue", runtimeState: worker._queryStarting ? "starting" : "active" });
|
|
39
|
+
} catch (err) {
|
|
40
|
+
return toolResult({ status: "rejected", requestId: id, reason: err.message || String(err) });
|
|
41
|
+
}
|
|
42
|
+
}
|
|
43
|
+
|
|
44
|
+
function fingerprint(args) {
|
|
45
|
+
return typeof args.message === "string" ? args.message.trim() : "";
|
|
46
|
+
}
|
|
47
|
+
return { fingerprint: fingerprint, send: send };
|
|
48
|
+
}
|
|
49
|
+
|
|
50
|
+
module.exports = { attachPairMessage: attachPairMessage };
|
|
@@ -0,0 +1,72 @@
|
|
|
1
|
+
var crypto = require("crypto");
|
|
2
|
+
|
|
3
|
+
function storeFor(driver) {
|
|
4
|
+
if (!driver._pairReplacementTransactions) driver._pairReplacementTransactions = Object.create(null);
|
|
5
|
+
return driver._pairReplacementTransactions;
|
|
6
|
+
}
|
|
7
|
+
|
|
8
|
+
function begin(driver, requestedId, worker, fingerprint) {
|
|
9
|
+
var id = typeof requestedId === "string" && requestedId.trim() ? requestedId.trim() : "replace_" + crypto.randomUUID();
|
|
10
|
+
var existing = storeFor(driver)[id];
|
|
11
|
+
if (existing) {
|
|
12
|
+
if (existing.fingerprint !== (fingerprint || "")) throw new Error("replacement transaction id was already used with different input");
|
|
13
|
+
return { transaction: existing, replay: true };
|
|
14
|
+
}
|
|
15
|
+
var transaction = {
|
|
16
|
+
transactionId: id,
|
|
17
|
+
status: "active",
|
|
18
|
+
stage: "preflight",
|
|
19
|
+
startedAt: Date.now(),
|
|
20
|
+
updatedAt: Date.now(),
|
|
21
|
+
sourceWorkerId: worker.localId,
|
|
22
|
+
sourceGeneration: worker._pairGeneration || worker.sessionProvenance && worker.sessionProvenance.generation || null,
|
|
23
|
+
sourceTurnState: worker.isProcessing || worker._queryStarting ? "running" : "idle",
|
|
24
|
+
sourceStopped: false,
|
|
25
|
+
sourcePairRestored: null,
|
|
26
|
+
filesPreserved: true,
|
|
27
|
+
fingerprint: fingerprint || "",
|
|
28
|
+
targetWorkerId: null,
|
|
29
|
+
targetGeneration: null,
|
|
30
|
+
failure: null,
|
|
31
|
+
result: null,
|
|
32
|
+
};
|
|
33
|
+
storeFor(driver)[id] = transaction;
|
|
34
|
+
driver._lastPairReplacement = transaction;
|
|
35
|
+
return { transaction: transaction, replay: false };
|
|
36
|
+
}
|
|
37
|
+
|
|
38
|
+
function stage(transaction, value) {
|
|
39
|
+
transaction.stage = value;
|
|
40
|
+
transaction.updatedAt = Date.now();
|
|
41
|
+
return transaction;
|
|
42
|
+
}
|
|
43
|
+
|
|
44
|
+
function fail(transaction, stageName, error, rollback) {
|
|
45
|
+
transaction.status = "failed";
|
|
46
|
+
transaction.stage = stageName;
|
|
47
|
+
transaction.updatedAt = Date.now();
|
|
48
|
+
transaction.completedAt = transaction.updatedAt;
|
|
49
|
+
transaction.failure = { message: error && (error.message || String(error)) || "Unknown replacement failure", rollback: rollback || "not_required" };
|
|
50
|
+
transaction.sourcePairRestored = rollback === "source_pair_restored" ? true : (rollback === "source_pair_restore_failed" ? false : null);
|
|
51
|
+
return transaction;
|
|
52
|
+
}
|
|
53
|
+
|
|
54
|
+
function complete(transaction, result) {
|
|
55
|
+
transaction.status = "completed";
|
|
56
|
+
transaction.stage = "committed";
|
|
57
|
+
transaction.updatedAt = Date.now();
|
|
58
|
+
transaction.completedAt = transaction.updatedAt;
|
|
59
|
+
transaction.targetWorkerId = result.workerSessionId;
|
|
60
|
+
transaction.targetGeneration = result.generation;
|
|
61
|
+
transaction.result = result;
|
|
62
|
+
return transaction;
|
|
63
|
+
}
|
|
64
|
+
|
|
65
|
+
function replayValue(entry) {
|
|
66
|
+
if (!entry.replay) return null;
|
|
67
|
+
if (entry.transaction.status === "completed") return entry.transaction.result;
|
|
68
|
+
if (entry.transaction.status === "failed") throw new Error(entry.transaction.failure.message);
|
|
69
|
+
throw new Error("replacement transaction is already active at stage " + entry.transaction.stage);
|
|
70
|
+
}
|
|
71
|
+
|
|
72
|
+
module.exports = { begin: begin, complete: complete, fail: fail, replayValue: replayValue, stage: stage };
|
|
@@ -0,0 +1,264 @@
|
|
|
1
|
+
var crypto = require("crypto");
|
|
2
|
+
var buildShape = require("./session-spawn-mcp-server").buildShape;
|
|
3
|
+
|
|
4
|
+
var OUTCOMES = ["completed", "partial", "failed", "interrupted"];
|
|
5
|
+
var MAX_QUEUE = 20;
|
|
6
|
+
var MAX_ITEMS = 50;
|
|
7
|
+
var MAX_TEXT = 1000;
|
|
8
|
+
|
|
9
|
+
function result(value) { return Promise.resolve({ content: [{ type: "text", text: JSON.stringify(value) }] }); }
|
|
10
|
+
function cleanText(value, max) {
|
|
11
|
+
var text = typeof value === "string" ? value.trim() : "";
|
|
12
|
+
return text.length > max ? text.slice(0, max) : text;
|
|
13
|
+
}
|
|
14
|
+
function cleanId(value, prefix) {
|
|
15
|
+
if (value === undefined || value === null || value === "") return prefix + crypto.randomUUID();
|
|
16
|
+
if (typeof value !== "string") return null;
|
|
17
|
+
var id = value.trim();
|
|
18
|
+
return id && id.length <= 120 && /^[A-Za-z0-9._:-]+$/.test(id) ? id : null;
|
|
19
|
+
}
|
|
20
|
+
function cleanStrings(values) {
|
|
21
|
+
if (!Array.isArray(values)) return null;
|
|
22
|
+
return values.slice(0, MAX_ITEMS).map(function (value) { return cleanText(value, MAX_TEXT); }).filter(Boolean);
|
|
23
|
+
}
|
|
24
|
+
function cleanVerifications(values) {
|
|
25
|
+
if (!Array.isArray(values)) return null;
|
|
26
|
+
return values.slice(0, MAX_ITEMS).map(function (value) {
|
|
27
|
+
if (typeof value === "string") return { command: cleanText(value, MAX_TEXT), result: "unknown" };
|
|
28
|
+
if (!value || typeof value !== "object") return null;
|
|
29
|
+
return { command: cleanText(value.command, MAX_TEXT), result: cleanText(value.result, MAX_TEXT) || "unknown" };
|
|
30
|
+
}).filter(Boolean);
|
|
31
|
+
}
|
|
32
|
+
|
|
33
|
+
function attachPairTaskControl(ctx) {
|
|
34
|
+
function queueFor(worker) {
|
|
35
|
+
if (!Array.isArray(worker._pairFollowups)) worker._pairFollowups = [];
|
|
36
|
+
return worker._pairFollowups;
|
|
37
|
+
}
|
|
38
|
+
function registryFor(worker) {
|
|
39
|
+
if (!worker._pairTaskIds) worker._pairTaskIds = Object.create(null);
|
|
40
|
+
return worker._pairTaskIds;
|
|
41
|
+
}
|
|
42
|
+
function current(worker) { return worker && worker._pairDelegation || null; }
|
|
43
|
+
function taskGeneration(worker) {
|
|
44
|
+
return worker._pairGeneration || worker.sessionProvenance && worker.sessionProvenance.generation || null;
|
|
45
|
+
}
|
|
46
|
+
function findTask(worker, taskId) {
|
|
47
|
+
var active = current(worker);
|
|
48
|
+
if (active && active.taskId === taskId) return active;
|
|
49
|
+
var queue = queueFor(worker);
|
|
50
|
+
for (var i = 0; i < queue.length; i++) if (queue[i].taskId === taskId) return queue[i];
|
|
51
|
+
return null;
|
|
52
|
+
}
|
|
53
|
+
function begin(worker, caller, message, requestedId) {
|
|
54
|
+
var taskId = cleanId(requestedId, "ptask_");
|
|
55
|
+
if (!taskId) throw new Error("taskId is invalid");
|
|
56
|
+
var existing = registryFor(worker)[taskId];
|
|
57
|
+
if (existing && existing.status === "starting" && existing.workerSessionId === worker.localId &&
|
|
58
|
+
existing.driverSessionId === caller.localId && existing.message === message) {
|
|
59
|
+
existing.status = "running";
|
|
60
|
+
existing.startedAt = existing.startedAt || Date.now();
|
|
61
|
+
return existing;
|
|
62
|
+
}
|
|
63
|
+
if (findTask(worker, taskId) || existing) throw new Error("taskId is already in use for this Worker generation");
|
|
64
|
+
var task = {
|
|
65
|
+
taskId: taskId,
|
|
66
|
+
generation: taskGeneration(worker),
|
|
67
|
+
workerSessionId: worker.localId,
|
|
68
|
+
driverSessionId: caller.localId,
|
|
69
|
+
message: message,
|
|
70
|
+
createdAt: Date.now(),
|
|
71
|
+
status: "running",
|
|
72
|
+
};
|
|
73
|
+
registryFor(worker)[taskId] = task;
|
|
74
|
+
return task;
|
|
75
|
+
}
|
|
76
|
+
function projectTask(task) {
|
|
77
|
+
return {
|
|
78
|
+
taskId: task.taskId,
|
|
79
|
+
generation: task.generation,
|
|
80
|
+
workerSessionId: task.workerSessionId,
|
|
81
|
+
status: task.status,
|
|
82
|
+
createdAt: task.createdAt,
|
|
83
|
+
startedAt: task.startedAt || null,
|
|
84
|
+
replacesTaskId: task.replacesTaskId || null,
|
|
85
|
+
message: cleanText(task.message, MAX_TEXT),
|
|
86
|
+
};
|
|
87
|
+
}
|
|
88
|
+
function queue(args, caller) {
|
|
89
|
+
try {
|
|
90
|
+
ctx.turnControl.assertWorkerAction(caller);
|
|
91
|
+
var resolved = ctx.resolvePair(caller);
|
|
92
|
+
var worker = resolved.partner;
|
|
93
|
+
var message = cleanText(args.message, 30000);
|
|
94
|
+
if (!message) throw new Error("message is required");
|
|
95
|
+
if (!worker.isProcessing && !worker._queryStarting && !current(worker)) throw new Error("the Split Worker is idle; use send_to_partner for an immediate follow-up");
|
|
96
|
+
var task = begin(worker, caller, message, args.taskId);
|
|
97
|
+
task.status = "queued";
|
|
98
|
+
var items = queueFor(worker);
|
|
99
|
+
if (items.filter(function (item) { return item.status === "queued" || item.status === "waiting_for_interrupt"; }).length >= MAX_QUEUE) throw new Error("the Split Worker follow-up queue is full");
|
|
100
|
+
items.push(task);
|
|
101
|
+
return result({ status: "queued", task: projectTask(task) });
|
|
102
|
+
} catch (err) { return result({ status: "rejected", reason: err.message || String(err) }); }
|
|
103
|
+
}
|
|
104
|
+
function inspect(args, caller) {
|
|
105
|
+
try {
|
|
106
|
+
var worker = ctx.resolvePair(caller).partner;
|
|
107
|
+
return result(status(worker));
|
|
108
|
+
} catch (err) { return result({ status: "rejected", reason: err.message || String(err) }); }
|
|
109
|
+
}
|
|
110
|
+
function cancel(args, caller) {
|
|
111
|
+
try {
|
|
112
|
+
ctx.turnControl.assertWorkerAction(caller);
|
|
113
|
+
var worker = ctx.resolvePair(caller).partner;
|
|
114
|
+
var task = findTask(worker, args.taskId);
|
|
115
|
+
if (!task || (task.status !== "queued" && task.status !== "waiting_for_interrupt")) throw new Error("the exact queued task is not cancellable");
|
|
116
|
+
task.status = "cancelled";
|
|
117
|
+
task.completedAt = Date.now();
|
|
118
|
+
return result({ status: "cancelled", taskId: task.taskId });
|
|
119
|
+
} catch (err) { return result({ status: "rejected", taskId: args.taskId || null, reason: err.message || String(err) }); }
|
|
120
|
+
}
|
|
121
|
+
function markInterruption(worker, by, reason, targetTaskId) {
|
|
122
|
+
var active = current(worker);
|
|
123
|
+
worker._pairInterruption = {
|
|
124
|
+
interruptedBy: by || "system",
|
|
125
|
+
reason: cleanText(reason, MAX_TEXT) || "No reason supplied.",
|
|
126
|
+
targetTaskId: targetTaskId || active && active.taskId || null,
|
|
127
|
+
requestedAt: Date.now(),
|
|
128
|
+
completedAt: null,
|
|
129
|
+
};
|
|
130
|
+
return worker._pairInterruption;
|
|
131
|
+
}
|
|
132
|
+
function ensureSystemInterruption(worker, token) {
|
|
133
|
+
if (worker._lastTurnInterrupted && (!worker._pairInterruption || worker._pairInterruption.targetTaskId !== token.taskId)) {
|
|
134
|
+
markInterruption(worker, "system", "The Worker runtime ended this task as interrupted.", token.taskId);
|
|
135
|
+
}
|
|
136
|
+
}
|
|
137
|
+
function replaceTask(args, caller) {
|
|
138
|
+
try {
|
|
139
|
+
ctx.turnControl.assertWorkerAction(caller);
|
|
140
|
+
var resolved = ctx.resolvePair(caller);
|
|
141
|
+
var worker = resolved.partner;
|
|
142
|
+
var active = current(worker);
|
|
143
|
+
if (!active || !worker.isProcessing) throw new Error("there is no active delegated task to replace");
|
|
144
|
+
if (!args.targetTaskId || args.targetTaskId !== active.taskId) throw new Error("targetTaskId must match the exact active task");
|
|
145
|
+
var message = cleanText(args.message, 30000);
|
|
146
|
+
if (!message) throw new Error("message is required");
|
|
147
|
+
var replacement = begin(worker, caller, message, args.taskId);
|
|
148
|
+
replacement.status = "waiting_for_interrupt";
|
|
149
|
+
replacement.replacesTaskId = active.taskId;
|
|
150
|
+
queueFor(worker).push(replacement);
|
|
151
|
+
markInterruption(worker, "driver", args.reason || "The Driver replaced the active task.", active.taskId);
|
|
152
|
+
worker.taskStopRequested = true;
|
|
153
|
+
if (worker.abortController) worker.abortController.abort();
|
|
154
|
+
return result({ status: "interrupting", targetTaskId: active.taskId, replacement: projectTask(replacement) });
|
|
155
|
+
} catch (err) { return result({ status: "rejected", reason: err.message || String(err) }); }
|
|
156
|
+
}
|
|
157
|
+
function resumeTask(args, caller) {
|
|
158
|
+
try {
|
|
159
|
+
ctx.turnControl.assertWorkerAction(caller);
|
|
160
|
+
var worker = ctx.resolvePair(caller).partner;
|
|
161
|
+
if (worker.isProcessing || worker._queryStarting || current(worker)) throw new Error("the Split Worker is not idle");
|
|
162
|
+
var prior = worker._lastInterruptedPairTask;
|
|
163
|
+
if (!prior) throw new Error("the Split Worker has no resumable interrupted task");
|
|
164
|
+
var message = cleanText(args.message, 30000) || prior.message;
|
|
165
|
+
return ctx.sendFollowup({ message: message, wait: args.wait, timeoutSeconds: args.timeoutSeconds, taskId: args.taskId }, caller);
|
|
166
|
+
} catch (err) { return result({ status: "rejected", reason: err.message || String(err) }); }
|
|
167
|
+
}
|
|
168
|
+
function report(args, worker) {
|
|
169
|
+
try {
|
|
170
|
+
if (!worker || ctx.sm.sessions.get(worker.localId) !== worker) throw new Error("the Worker session is no longer live");
|
|
171
|
+
var resolved = ctx.rolesFor(worker);
|
|
172
|
+
if (!resolved || resolved.worker !== worker) throw new Error("only the exact configured Split Worker can report an outcome");
|
|
173
|
+
var active = current(worker);
|
|
174
|
+
if (!active || args.taskId !== active.taskId) throw new Error("taskId must match the exact active delegated task");
|
|
175
|
+
var outcome = typeof args.outcome === "string" ? args.outcome.trim().toLowerCase() : "";
|
|
176
|
+
if (OUTCOMES.indexOf(outcome) === -1) throw new Error("outcome must be completed, partial, failed, or interrupted");
|
|
177
|
+
active.reportedOutcome = {
|
|
178
|
+
outcome: outcome,
|
|
179
|
+
summary: cleanText(args.summary, MAX_TEXT) || null,
|
|
180
|
+
changedFiles: cleanStrings(args.changedFiles),
|
|
181
|
+
verifications: cleanVerifications(args.verifications),
|
|
182
|
+
unverified: cleanStrings(args.unverified),
|
|
183
|
+
processes: cleanStrings(args.processes),
|
|
184
|
+
nextAction: cleanText(args.nextAction, MAX_TEXT) || null,
|
|
185
|
+
reportedAt: Date.now(),
|
|
186
|
+
};
|
|
187
|
+
return result({ status: "recorded", taskId: active.taskId });
|
|
188
|
+
} catch (err) { return result({ status: "rejected", reason: err.message || String(err) }); }
|
|
189
|
+
}
|
|
190
|
+
function complete(worker, caller, token, observedStatus, response, error) {
|
|
191
|
+
if (!token) return null;
|
|
192
|
+
if (token.outcomeEnvelope) return token.outcomeEnvelope;
|
|
193
|
+
var interruption = worker._pairInterruption && worker._pairInterruption.targetTaskId === token.taskId ? worker._pairInterruption : null;
|
|
194
|
+
if (interruption) interruption.completedAt = Date.now();
|
|
195
|
+
var report = token.reportedOutcome || null;
|
|
196
|
+
var finalStatus = error ? "failed" : (interruption ? "interrupted" : (report ? report.outcome : observedStatus));
|
|
197
|
+
var envelope = {
|
|
198
|
+
taskId: token.taskId,
|
|
199
|
+
generation: token.generation,
|
|
200
|
+
workerSessionId: worker.localId,
|
|
201
|
+
status: finalStatus,
|
|
202
|
+
completedAt: Date.now(),
|
|
203
|
+
interruption: interruption,
|
|
204
|
+
workerReport: report,
|
|
205
|
+
changedFiles: report ? report.changedFiles : null,
|
|
206
|
+
verifications: report ? report.verifications : null,
|
|
207
|
+
unverified: report ? report.unverified : null,
|
|
208
|
+
processes: report ? report.processes : null,
|
|
209
|
+
nextAction: report ? report.nextAction : null,
|
|
210
|
+
verificationAuthority: report ? "worker_reported" : "unknown",
|
|
211
|
+
response: response || "",
|
|
212
|
+
error: error || null,
|
|
213
|
+
};
|
|
214
|
+
token.status = finalStatus;
|
|
215
|
+
token.outcomeEnvelope = envelope;
|
|
216
|
+
worker._lastPairOutcome = envelope;
|
|
217
|
+
if (finalStatus === "interrupted" || finalStatus === "partial") worker._lastInterruptedPairTask = { taskId: token.taskId, message: token.message, generation: token.generation };
|
|
218
|
+
if (ctx.sm.sendToSession) ctx.sm.sendToSession(caller, { type: "partner_task_completed", outcome: envelope });
|
|
219
|
+
return envelope;
|
|
220
|
+
}
|
|
221
|
+
function status(worker) {
|
|
222
|
+
var active = current(worker);
|
|
223
|
+
var registry = registryFor(worker);
|
|
224
|
+
var ids = Object.keys(registry);
|
|
225
|
+
return {
|
|
226
|
+
status: "ok",
|
|
227
|
+
current: active ? projectTask(active) : null,
|
|
228
|
+
queued: queueFor(worker).filter(function (item) { return item.status === "queued" || item.status === "waiting_for_interrupt"; }).map(projectTask),
|
|
229
|
+
interruption: worker._pairInterruption || null,
|
|
230
|
+
lastOutcome: worker._lastPairOutcome || null,
|
|
231
|
+
recent: ids.slice(-20).map(function (id) { return projectTask(registry[id]); }),
|
|
232
|
+
};
|
|
233
|
+
}
|
|
234
|
+
function drain(caller, worker) {
|
|
235
|
+
if (ctx.turnControl.blockedReason(caller) || worker.isProcessing || worker._queryStarting || current(worker)) return false;
|
|
236
|
+
var queue = queueFor(worker);
|
|
237
|
+
var next = null;
|
|
238
|
+
for (var i = 0; i < queue.length; i++) {
|
|
239
|
+
if (queue[i].status === "queued" || queue[i].status === "waiting_for_interrupt") { next = queue[i]; break; }
|
|
240
|
+
}
|
|
241
|
+
if (!next) return false;
|
|
242
|
+
next.status = "starting";
|
|
243
|
+
next.startedAt = Date.now();
|
|
244
|
+
queue.splice(queue.indexOf(next), 1);
|
|
245
|
+
Promise.resolve(ctx.sendFollowup({ message: next.message, wait: false, taskId: next.taskId }, caller)).then(function (delivery) {
|
|
246
|
+
next.status = delivery && delivery.isError ? "failed" : "running";
|
|
247
|
+
if (delivery && delivery.isError) next.error = delivery.content && delivery.content[0] && delivery.content[0].text || "Follow-up rejected";
|
|
248
|
+
}, function (err) { next.status = "failed"; next.error = err.message || String(err); });
|
|
249
|
+
return true;
|
|
250
|
+
}
|
|
251
|
+
function driverToolDefs(handlers) {
|
|
252
|
+
return [{ name: "queue_partner_followup", description: "Queue a distinct follow-up task behind the active Split Worker task.", inputSchema: buildShape({ message: { type: "string" }, taskId: { type: "string" } }, ["message"]), handler: handlers.queue },
|
|
253
|
+
{ name: "inspect_partner_followups", description: "Inspect current, queued, interrupted, and last completed Split Worker task state.", inputSchema: buildShape({}), handler: handlers.inspect },
|
|
254
|
+
{ name: "cancel_partner_followup", description: "Cancel one exact queued follow-up without interrupting active work.", inputSchema: buildShape({ taskId: { type: "string" } }, ["taskId"]), handler: handlers.cancel },
|
|
255
|
+
{ name: "replace_partner_task", description: "Interrupt one exact active delegated task and queue a replacement task in the same Worker generation.", inputSchema: buildShape({ targetTaskId: { type: "string" }, message: { type: "string" }, taskId: { type: "string" }, reason: { type: "string" } }, ["targetTaskId", "message"]), handler: handlers.replace },
|
|
256
|
+
{ name: "resume_partner_task", description: "Resume the last interrupted task as a new correlated task. A human Stop blocks this until a new user turn.", inputSchema: buildShape({ message: { type: "string" }, taskId: { type: "string" }, wait: { type: "boolean" }, timeoutSeconds: { type: "number" } }), handler: handlers.resume }];
|
|
257
|
+
}
|
|
258
|
+
function workerToolDefs(worker) {
|
|
259
|
+
return [{ name: "report_partner_outcome", description: "Report structured, Worker-observed task outcome data. This does not claim Driver verification.", inputSchema: buildShape({ taskId: { type: "string" }, outcome: { type: "string" }, summary: { type: "string" }, changedFiles: { type: "array", items: { type: "string" } }, verifications: { type: "array", items: { type: "object" } }, unverified: { type: "array", items: { type: "string" } }, processes: { type: "array", items: { type: "string" } }, nextAction: { type: "string" } }, ["taskId", "outcome"]), handler: function (args) { return report(args || {}, worker); } }];
|
|
260
|
+
}
|
|
261
|
+
return { begin: begin, cancel: cancel, complete: complete, drain: drain, driverToolDefs: driverToolDefs, ensureSystemInterruption: ensureSystemInterruption, inspect: inspect, markInterruption: markInterruption, queue: queue, replaceTask: replaceTask, resumeTask: resumeTask, status: status, workerToolDefs: workerToolDefs };
|
|
262
|
+
}
|
|
263
|
+
|
|
264
|
+
module.exports = { attachPairTaskControl: attachPairTaskControl };
|