clay-server 4.3.0-beta.3 → 4.3.0-beta.5
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/project-connection.js +2 -0
- package/lib/project-pair-result-capture.js +86 -0
- package/lib/project-pair-result-delivery.js +139 -0
- package/lib/project-pair-result-outbox.js +331 -0
- package/lib/project-pair-result-pipeline.js +16 -0
- package/lib/project-pair-result-recovery.js +225 -0
- package/lib/project-pair-task-control.js +6 -0
- package/lib/project-session-pair.js +37 -33
- package/lib/project.js +23 -1
- package/lib/public/app.js +1 -0
- package/lib/public/css/messages.css +12 -0
- package/lib/public/css/overlays.css +22 -21
- package/lib/public/css/pane.css +16 -12
- package/lib/public/index.html +3 -3
- package/lib/public/modules/app-messages.js +7 -0
- package/lib/public/modules/pair-result-status.js +87 -0
- package/lib/public/modules/permission-control.js +5 -5
- package/lib/session-pair-mcp-server.js +2 -2
- package/lib/session-pair-prompts.js +7 -1
- package/lib/sessions.js +33 -2
- package/package.json +1 -1
|
@@ -99,6 +99,7 @@ function attachConnection(ctx) {
|
|
|
99
99
|
var hydrateSkillCatalog = ctx.hydrateSkillCatalog || function () { return []; };
|
|
100
100
|
var warmup = ctx.warmup;
|
|
101
101
|
var scheduledMessages = ctx.scheduledMessages;
|
|
102
|
+
var pairResultRecovery = ctx.pairResultRecovery;
|
|
102
103
|
var sendCursorSharingState = ctx.sendCursorSharingState;
|
|
103
104
|
|
|
104
105
|
// Adapters are initialized lazily: the first websocket connection into
|
|
@@ -301,6 +302,7 @@ function attachConnection(ctx) {
|
|
|
301
302
|
}
|
|
302
303
|
sendTo(ws, { type: "history_done", lastUsage: _lastUsage, lastModelUsage: _lastModelUsage, lastCost: _lastCost, lastStreamInputTokens: _lastStreamInputTokens, contextUsage: active.lastContextUsage || null });
|
|
303
304
|
if (scheduledMessages) scheduledMessages.sendState(active, ws);
|
|
305
|
+
if (pairResultRecovery) pairResultRecovery.sendState(active, ws);
|
|
304
306
|
|
|
305
307
|
if (active.isProcessing) {
|
|
306
308
|
sendTo(ws, { type: "status", status: "processing" });
|
|
@@ -0,0 +1,86 @@
|
|
|
1
|
+
function attachPairResultCapture(ctx) {
|
|
2
|
+
function begin(caller, partner, token, message, projectSlug) {
|
|
3
|
+
if (!ctx.resultOutbox) return { ok: true };
|
|
4
|
+
var driverOriginId = ctx.resultOutbox.stableOrigin(caller), workerOriginId = ctx.resultOutbox.stableOrigin(partner);
|
|
5
|
+
if (!driverOriginId || !workerOriginId) return { ok: false, error: "durable session origins are required before dispatch" };
|
|
6
|
+
var started = ctx.resultOutbox.begin({ ownerId: caller.ownerId, projectSlug: projectSlug,
|
|
7
|
+
driverOriginId: driverOriginId, workerOriginId: workerOriginId,
|
|
8
|
+
taskId: token.taskId, generation: token.generation, driverSessionId: caller.localId,
|
|
9
|
+
workerSessionId: partner.localId, historyStartIndex: token.startIndex, message: message,
|
|
10
|
+
deliveryRoute: token.wait === true ? "blocking" : "callback" });
|
|
11
|
+
if (started.ok) token.outboxKey = started.key;
|
|
12
|
+
return started;
|
|
13
|
+
}
|
|
14
|
+
function finish(caller, partner, token, delivered) {
|
|
15
|
+
var group = ctx.store.groupForMember(partner.localId);
|
|
16
|
+
if (token._captureFinished || partner._pairDelegation !== token || !group || group.id !== token.groupId || group.members.indexOf(caller.localId) === -1 || group.members.indexOf(partner.localId) === -1 || (group.pair && (group.pair.driverId !== caller.localId || group.pair.workerId !== partner.localId)) || ctx.sm.sessions.get(caller.localId) !== caller || ctx.sm.sessions.get(partner.localId) !== partner || caller.ownerId !== partner.ownerId) return false;
|
|
17
|
+
token._captureFinished = true;
|
|
18
|
+
try {
|
|
19
|
+
if (typeof ctx.onPartnerResult === "function") ctx.onPartnerResult(caller, partner, token);
|
|
20
|
+
if (token.outboxKey && ctx.resultOutbox) {
|
|
21
|
+
var finalized = ctx.resultOutbox.markFinalized(token.outboxKey);
|
|
22
|
+
if (!finalized.ok) throw new Error(finalized.error || "completed result finalization could not be saved");
|
|
23
|
+
}
|
|
24
|
+
ctx.finishDelegation(group, caller, partner, token);
|
|
25
|
+
if (typeof ctx.onStateChange === "function" && token.outboxKey) ctx.onStateChange(caller, ctx.resultOutbox.get(token.outboxKey));
|
|
26
|
+
} catch (error) {
|
|
27
|
+
token._captureFinished = false;
|
|
28
|
+
token._capturePending = true;
|
|
29
|
+
token._captureHookError = error.message || String(error);
|
|
30
|
+
if (token.outboxKey && ctx.resultOutbox) {
|
|
31
|
+
var failed = ctx.resultOutbox.markFinalizationFailure(token.outboxKey, token._captureHookError);
|
|
32
|
+
if (typeof ctx.onStateChange === "function") ctx.onStateChange(caller, Object.assign({}, failed.record || ctx.resultOutbox.get(token.outboxKey), {
|
|
33
|
+
finalizationState: "failed", finalizationError: token._captureHookError, persistenceError: failed.ok ? null : failed.error,
|
|
34
|
+
}));
|
|
35
|
+
}
|
|
36
|
+
return false;
|
|
37
|
+
}
|
|
38
|
+
delete token._capturePending;
|
|
39
|
+
var resumed = delivered ? true : ctx.resumeDriverWithResult(caller, partner, token);
|
|
40
|
+
ctx.drain(caller, partner);
|
|
41
|
+
return resumed;
|
|
42
|
+
}
|
|
43
|
+
function capture(caller, partner, token, outcome) {
|
|
44
|
+
if (!ctx.resultOutbox || !token.outboxKey) return finish(caller, partner, token);
|
|
45
|
+
if (token._captureInProgress || token._captureComplete) return false;
|
|
46
|
+
token._captureInProgress = true;
|
|
47
|
+
var saved;
|
|
48
|
+
try {
|
|
49
|
+
saved = ctx.resultOutbox.capture(token.outboxKey, outcome, function (retryResult) {
|
|
50
|
+
token._captureInProgress = false;
|
|
51
|
+
if (retryResult && retryResult.ok) {
|
|
52
|
+
token._captureComplete = true;
|
|
53
|
+
var record = ctx.resultOutbox.get(token.outboxKey);
|
|
54
|
+
if (typeof ctx.onStateChange === "function") ctx.onStateChange(caller, record);
|
|
55
|
+
if (record && record.deliveryRoute === "callback") deliver(caller, partner, token);
|
|
56
|
+
else finish(caller, partner, token);
|
|
57
|
+
}
|
|
58
|
+
});
|
|
59
|
+
} catch (error) {
|
|
60
|
+
token._captureInProgress = false;
|
|
61
|
+
token._capturePending = true;
|
|
62
|
+
token._captureHookError = error.message || String(error);
|
|
63
|
+
return false;
|
|
64
|
+
}
|
|
65
|
+
if (saved.ok) { token._captureInProgress = false; token._captureComplete = true; }
|
|
66
|
+
if (saved.ok) {
|
|
67
|
+
var current = ctx.resultOutbox.get(token.outboxKey);
|
|
68
|
+
if (typeof ctx.onStateChange === "function") ctx.onStateChange(caller, current);
|
|
69
|
+
var delivered = current && current.deliveryRoute === "callback" ? deliver(caller, partner, token) : finish(caller, partner, token);
|
|
70
|
+
return delivered === false && token._capturePending ? false : delivered;
|
|
71
|
+
}
|
|
72
|
+
token._capturePending = true;
|
|
73
|
+
if (!token._captureFailureReported) {
|
|
74
|
+
token._captureFailureReported = true;
|
|
75
|
+
try { ctx.sm.sendAndRecord(caller, { type: "error", text: "The Split Worker completed, but its result could not be durably captured yet. The task remains retained for bounded server retry." }); } catch (error) { token._captureNoticeError = error.message || String(error); }
|
|
76
|
+
}
|
|
77
|
+
return false;
|
|
78
|
+
}
|
|
79
|
+
function deliver(caller, partner, token) {
|
|
80
|
+
if (!ctx.delivery) return finish(caller, partner, token);
|
|
81
|
+
return ctx.delivery.wake(caller, partner, token);
|
|
82
|
+
}
|
|
83
|
+
return { begin: begin, capture: capture, deliver: deliver, finish: finish };
|
|
84
|
+
}
|
|
85
|
+
|
|
86
|
+
module.exports = { attachPairResultCapture: attachPairResultCapture };
|
|
@@ -0,0 +1,139 @@
|
|
|
1
|
+
var RETRY_DELAY_MS = 50;
|
|
2
|
+
var DEFAULT_ACCEPTANCE_TIMEOUT_MS = 15000;
|
|
3
|
+
|
|
4
|
+
function attachPairResultDelivery(ctx) {
|
|
5
|
+
function recordMatches(caller, partner, token, record) {
|
|
6
|
+
return !!(record && record.key === token.outboxKey && record.ownerId === (caller.ownerId || null) &&
|
|
7
|
+
record.driverOriginId === ctx.outbox.stableOrigin(caller) && record.workerOriginId === ctx.outbox.stableOrigin(partner) &&
|
|
8
|
+
record.taskId === token.taskId && record.generation === token.generation && record.deliveryRoute === "callback");
|
|
9
|
+
}
|
|
10
|
+
function valid(caller, partner, token) {
|
|
11
|
+
var group = ctx.store.groupForMember(partner.localId), record = ctx.outbox.get(token.outboxKey);
|
|
12
|
+
return !!(group && group.id === token.groupId && group.members.indexOf(caller.localId) !== -1 &&
|
|
13
|
+
group.members.indexOf(partner.localId) !== -1 && group.pair && group.pair.driverId === caller.localId &&
|
|
14
|
+
group.pair.workerId === partner.localId && ctx.sm.sessions.get(caller.localId) === caller &&
|
|
15
|
+
ctx.sm.sessions.get(partner.localId) === partner && caller.ownerId === partner.ownerId &&
|
|
16
|
+
!caller.destroying && !partner.destroying && !ctx.blockedReason(caller) && partner._pairDelegation === token &&
|
|
17
|
+
recordMatches(caller, partner, token, record));
|
|
18
|
+
}
|
|
19
|
+
function currentAttempt(caller, partner, token, attemptId) {
|
|
20
|
+
var record = ctx.outbox.get(token.outboxKey);
|
|
21
|
+
return valid(caller, partner, token) && record && record.deliveryState === "attempting" && record.attemptId === attemptId;
|
|
22
|
+
}
|
|
23
|
+
function textFor(partner, token) {
|
|
24
|
+
var failure = token.failure || null, response = token.response || "";
|
|
25
|
+
if (token.interrupted) return "[Split Worker execution interrupted] The user interrupted the Split Worker mid-turn. Its work is PARTIAL and unverified — do not treat it as finished. Review what was done and decide next steps with the user.";
|
|
26
|
+
return "A Split Worker task delegated through send_to_partner has finished.\n\nOriginal task:\n" + token.message + "\n\n" + (failure ? "Split Worker error:\n" + failure : "Split Worker result:\n" + (response || "(No text response was recorded.)")) + "\n\nReview the result, verify it as needed, and continue the task.";
|
|
27
|
+
}
|
|
28
|
+
function finish(caller, partner, token) { return ctx.finish(caller, partner, token, true); }
|
|
29
|
+
function clearAttemptTimer(token, attemptId) {
|
|
30
|
+
if (!token._deliveryTimer || token._deliveryTimer.attemptId !== attemptId) return;
|
|
31
|
+
clearTimeout(token._deliveryTimer.timer);
|
|
32
|
+
delete token._deliveryTimer;
|
|
33
|
+
}
|
|
34
|
+
function retry(caller, partner, token, attemptId, reason) {
|
|
35
|
+
clearAttemptTimer(token, attemptId);
|
|
36
|
+
if (!currentAttempt(caller, partner, token, attemptId)) return { ok: false, stale: true };
|
|
37
|
+
var pending = ctx.outbox.markDelivery(token.outboxKey, attemptId, "pending", { deliveryError: reason || null });
|
|
38
|
+
if (!pending.ok) {
|
|
39
|
+
if (typeof ctx.onStateChange === "function") ctx.onStateChange(caller, ctx.outbox.get(token.outboxKey));
|
|
40
|
+
return { ok: false, error: pending.error, stale: pending.stale };
|
|
41
|
+
}
|
|
42
|
+
if (typeof ctx.onStateChange === "function") ctx.onStateChange(caller, pending.record);
|
|
43
|
+
if (pending.record.deliveryAttemptCount < ctx.outbox.maxDeliveryAttempts) {
|
|
44
|
+
setTimeout(function () { wake(caller, partner, token); }, RETRY_DELAY_MS * pending.record.deliveryAttemptCount);
|
|
45
|
+
}
|
|
46
|
+
return { ok: false, pending: true };
|
|
47
|
+
}
|
|
48
|
+
function uncertain(caller, partner, token, attemptId, reason) {
|
|
49
|
+
clearAttemptTimer(token, attemptId);
|
|
50
|
+
if (!currentAttempt(caller, partner, token, attemptId)) return false;
|
|
51
|
+
token._deliveryUncertain = true;
|
|
52
|
+
var saved = ctx.outbox.markDelivery(token.outboxKey, attemptId, "uncertain", { deliveryError: reason || "delivery acceptance is uncertain" });
|
|
53
|
+
if (saved.ok && typeof ctx.onStateChange === "function") ctx.onStateChange(caller, saved.record);
|
|
54
|
+
return false;
|
|
55
|
+
}
|
|
56
|
+
function accepted(caller, partner, token, attemptId) {
|
|
57
|
+
clearAttemptTimer(token, attemptId);
|
|
58
|
+
if (!currentAttempt(caller, partner, token, attemptId)) return { ok: false, stale: true };
|
|
59
|
+
var saved = ctx.outbox.markDelivery(token.outboxKey, attemptId, "accepted", { acceptedAt: Date.now() });
|
|
60
|
+
if (!saved.ok) {
|
|
61
|
+
token._deliveryUncertain = true;
|
|
62
|
+
if (typeof ctx.onStateChange === "function") ctx.onStateChange(caller, ctx.outbox.get(token.outboxKey));
|
|
63
|
+
return { ok: false, uncertain: true, error: saved.error };
|
|
64
|
+
}
|
|
65
|
+
token._deliveryAccepted = true;
|
|
66
|
+
if (typeof ctx.onStateChange === "function") ctx.onStateChange(caller, saved.record);
|
|
67
|
+
return { ok: true, accepted: true, finished: finish(caller, partner, token) !== false };
|
|
68
|
+
}
|
|
69
|
+
function recordTranscript(caller, token, message, attemptId) {
|
|
70
|
+
var outboxRecord = ctx.outbox.get(token.outboxKey);
|
|
71
|
+
if (outboxRecord && outboxRecord.transcriptPersisted) return { ok: true };
|
|
72
|
+
var matches = function (entry) { return !!(entry && entry.pairResultOutboxKey === token.outboxKey); };
|
|
73
|
+
var existing = caller.history && caller.history.some(matches);
|
|
74
|
+
var durable = existing && typeof ctx.sm.hasDurableSessionRecord === "function" && ctx.sm.hasDurableSessionRecord(caller, matches);
|
|
75
|
+
if (!durable) {
|
|
76
|
+
var record = { type: "user_message", text: message, _internal: true, partnerResult: true,
|
|
77
|
+
pairResultOutboxKey: token.outboxKey, pairResultAttemptId: attemptId };
|
|
78
|
+
var saved;
|
|
79
|
+
try {
|
|
80
|
+
if (typeof ctx.sm.sendAndRecordDurably !== "function") return { ok: false, error: "Durable Driver transcript recording is unavailable" };
|
|
81
|
+
saved = ctx.sm.sendAndRecordDurably(caller, record);
|
|
82
|
+
} catch (error) { return { ok: false, error: error.message || String(error) }; }
|
|
83
|
+
if (saved !== true) {
|
|
84
|
+
if (caller.history && caller.history[caller.history.length - 1] === record) caller.history.pop();
|
|
85
|
+
return { ok: false, error: "Driver transcript persistence failed" };
|
|
86
|
+
}
|
|
87
|
+
}
|
|
88
|
+
var marked = ctx.outbox.markTranscript(token.outboxKey, attemptId);
|
|
89
|
+
return marked.ok ? { ok: true } : { ok: false, error: marked.error, stale: marked.stale };
|
|
90
|
+
}
|
|
91
|
+
function wake(caller, partner, token) {
|
|
92
|
+
if (!token || token._deliveryUncertain || token._deliveryAccepted) return { ok: false, deferred: true };
|
|
93
|
+
if (ctx.blockedReason(caller)) {
|
|
94
|
+
var blocked = ctx.outbox.markBlocked(token.outboxKey, ctx.blockedReason(caller), "human_stop");
|
|
95
|
+
if (blocked.ok && typeof ctx.onStateChange === "function") ctx.onStateChange(caller, blocked.record);
|
|
96
|
+
return { ok: false, blocked: true };
|
|
97
|
+
}
|
|
98
|
+
if (!valid(caller, partner, token)) return { ok: false, deferred: true };
|
|
99
|
+
if (caller._queryStarting) return { ok: false, deferred: true, reason: "query_starting" };
|
|
100
|
+
var started = ctx.outbox.beginDelivery(token.outboxKey);
|
|
101
|
+
if (!started.ok) return { ok: false, pending: true, error: started.error };
|
|
102
|
+
if (started.terminal || started.inFlight || started.blocked || started.exhausted) return { ok: true, terminal: !!started.terminal, exhausted: !!started.exhausted };
|
|
103
|
+
var attemptId = started.attemptId, message = textFor(partner, token);
|
|
104
|
+
var transcript = recordTranscript(caller, token, message, attemptId);
|
|
105
|
+
if (!transcript.ok) return retry(caller, partner, token, attemptId, transcript.error);
|
|
106
|
+
var sdk = ctx.getSdk();
|
|
107
|
+
if (!sdk || typeof sdk.pushMessage !== "function" || typeof sdk.startQuery !== "function") {
|
|
108
|
+
return retry(caller, partner, token, attemptId, "SDK bridge is unavailable");
|
|
109
|
+
}
|
|
110
|
+
try {
|
|
111
|
+
if (caller.queryInstance) {
|
|
112
|
+
if (sdk.pushMessage(caller, message) === true) {
|
|
113
|
+
return accepted(caller, partner, token, attemptId);
|
|
114
|
+
}
|
|
115
|
+
return retry(caller, partner, token, attemptId, "Driver query rejected the result");
|
|
116
|
+
}
|
|
117
|
+
token._deliveryStartupAttemptId = attemptId;
|
|
118
|
+
var beforePush = function () { return token._deliveryStartupAttemptId === attemptId && currentAttempt(caller, partner, token, attemptId); };
|
|
119
|
+
var onAccepted = function () { accepted(caller, partner, token, attemptId); };
|
|
120
|
+
var timeoutMs = Number(ctx.acceptanceTimeoutMs || DEFAULT_ACCEPTANCE_TIMEOUT_MS);
|
|
121
|
+
token._deliveryTimer = { attemptId: attemptId, timer: setTimeout(function () {
|
|
122
|
+
uncertain(caller, partner, token, attemptId, "delivery acceptance timed out");
|
|
123
|
+
}, timeoutMs) };
|
|
124
|
+
var startup = sdk.startQuery(caller, message, undefined, ctx.getLinuxUserForSession(caller), beforePush, onAccepted);
|
|
125
|
+
Promise.resolve(startup).then(function (result) {
|
|
126
|
+
if (!currentAttempt(caller, partner, token, attemptId)) return;
|
|
127
|
+
if (result === false) retry(caller, partner, token, attemptId, "Driver query rejected the result");
|
|
128
|
+
}, function (error) {
|
|
129
|
+
if (currentAttempt(caller, partner, token, attemptId)) uncertain(caller, partner, token, attemptId, error.message || String(error));
|
|
130
|
+
});
|
|
131
|
+
return { ok: false, pending: true };
|
|
132
|
+
} catch (error) {
|
|
133
|
+
return uncertain(caller, partner, token, attemptId, error.message || String(error));
|
|
134
|
+
}
|
|
135
|
+
}
|
|
136
|
+
return { wake: wake };
|
|
137
|
+
}
|
|
138
|
+
|
|
139
|
+
module.exports = { attachPairResultDelivery: attachPairResultDelivery };
|
|
@@ -0,0 +1,331 @@
|
|
|
1
|
+
var fs = require("fs");
|
|
2
|
+
var path = require("path");
|
|
3
|
+
|
|
4
|
+
var MAX_CAPTURE_ATTEMPTS = 3;
|
|
5
|
+
var MAX_DELIVERY_ATTEMPTS = 3;
|
|
6
|
+
var RETRY_DELAY_MS = 25;
|
|
7
|
+
|
|
8
|
+
function stableOrigin(session) {
|
|
9
|
+
if (session && typeof session.sessionOriginId === "string" && session.sessionOriginId) return session.sessionOriginId;
|
|
10
|
+
return null;
|
|
11
|
+
}
|
|
12
|
+
|
|
13
|
+
function resultKey(args) {
|
|
14
|
+
return [args.ownerId || "_default", args.driverOriginId, args.workerOriginId, args.taskId, args.generation == null ? "unknown" : args.generation].join("/");
|
|
15
|
+
}
|
|
16
|
+
|
|
17
|
+
function copy(value) {
|
|
18
|
+
return value == null ? value : JSON.parse(JSON.stringify(value));
|
|
19
|
+
}
|
|
20
|
+
|
|
21
|
+
function attachPairResultOutbox(options) {
|
|
22
|
+
options = options || {};
|
|
23
|
+
var filePath = options.filePath;
|
|
24
|
+
if (!filePath && options.storageDir) filePath = path.join(options.storageDir, "pair-result-outbox.json");
|
|
25
|
+
if (!filePath) throw new Error("pair result outbox requires a file path");
|
|
26
|
+
var records = Object.create(null);
|
|
27
|
+
var captureOperations = Object.create(null);
|
|
28
|
+
var lastPersistenceError = null;
|
|
29
|
+
var storageCorrupt = false;
|
|
30
|
+
|
|
31
|
+
function read() {
|
|
32
|
+
try {
|
|
33
|
+
var parsed = JSON.parse(fs.readFileSync(filePath, "utf8"));
|
|
34
|
+
if (!parsed || typeof parsed !== "object" || !parsed.records || typeof parsed.records !== "object") throw new Error("pair result outbox has an invalid format");
|
|
35
|
+
records = parsed.records;
|
|
36
|
+
} catch (error) {
|
|
37
|
+
if (error.code !== "ENOENT") { storageCorrupt = true; lastPersistenceError = error.message || String(error); }
|
|
38
|
+
}
|
|
39
|
+
}
|
|
40
|
+
function persist(nextRecords) {
|
|
41
|
+
if (storageCorrupt) return { ok: false, error: lastPersistenceError || "pair result outbox storage is unreadable" };
|
|
42
|
+
var serialized = JSON.stringify({ version: 1, records: nextRecords || records }, null, 2) + "\n";
|
|
43
|
+
try {
|
|
44
|
+
if (typeof options.persist === "function") {
|
|
45
|
+
if (options.persist(filePath, serialized) === false) throw new Error("pair result outbox persistence returned false");
|
|
46
|
+
lastPersistenceError = null;
|
|
47
|
+
return { ok: true };
|
|
48
|
+
}
|
|
49
|
+
fs.mkdirSync(path.dirname(filePath), { recursive: true });
|
|
50
|
+
var tempPath = filePath + ".tmp." + process.pid;
|
|
51
|
+
fs.writeFileSync(tempPath, serialized, "utf8");
|
|
52
|
+
if (process.platform !== "win32") {
|
|
53
|
+
try { fs.chmodSync(tempPath, 0o600); } catch (chmodError) {}
|
|
54
|
+
}
|
|
55
|
+
fs.renameSync(tempPath, filePath);
|
|
56
|
+
lastPersistenceError = null;
|
|
57
|
+
return { ok: true };
|
|
58
|
+
} catch (error) {
|
|
59
|
+
lastPersistenceError = error.message || String(error);
|
|
60
|
+
return { ok: false, error: lastPersistenceError };
|
|
61
|
+
}
|
|
62
|
+
}
|
|
63
|
+
function sameOutcome(left, right) { return JSON.stringify(left) === JSON.stringify(right); }
|
|
64
|
+
function captureRecord(key, outcome) {
|
|
65
|
+
var record = records[key];
|
|
66
|
+
if (!record) return { ok: false, error: "pair result outbox record was not found" };
|
|
67
|
+
if (record.outcome) {
|
|
68
|
+
if (!sameOutcome(record.outcome, outcome)) return { ok: false, conflict: true, error: "pair result outcome is immutable", record: copy(record) };
|
|
69
|
+
return { ok: true, record: copy(record), duplicate: true };
|
|
70
|
+
}
|
|
71
|
+
var nextRecords = Object.assign({}, records), next = Object.assign({}, record, {
|
|
72
|
+
outcome: copy(outcome), state: record.state === "blocked" ? "blocked" : "captured", capturedAt: Date.now(),
|
|
73
|
+
});
|
|
74
|
+
nextRecords[record.key] = next;
|
|
75
|
+
var saved = persist(nextRecords);
|
|
76
|
+
if (!saved.ok) return { ok: false, error: saved.error, record: copy(record) };
|
|
77
|
+
records = nextRecords;
|
|
78
|
+
return { ok: true, record: copy(next), duplicate: false };
|
|
79
|
+
}
|
|
80
|
+
function addCaptureCallback(operation, callback) {
|
|
81
|
+
if (typeof callback === "function" && operation.callbacks.indexOf(callback) === -1) operation.callbacks.push(callback);
|
|
82
|
+
}
|
|
83
|
+
function notifyCapture(operation, result) {
|
|
84
|
+
var callbacks = operation.callbacks.slice(); operation.callbacks = [];
|
|
85
|
+
for (var i = 0; i < callbacks.length; i++) callbacks[i](result);
|
|
86
|
+
}
|
|
87
|
+
function retryCapture(key, operation, attempt) {
|
|
88
|
+
attempt = attempt || 1;
|
|
89
|
+
if (captureOperations[key] !== operation || !operation.active) return { ok: false, stale: true };
|
|
90
|
+
var result = captureRecord(key, operation.outcome);
|
|
91
|
+
operation.lastResult = result;
|
|
92
|
+
if (result.ok) {
|
|
93
|
+
operation.active = false; operation.complete = true;
|
|
94
|
+
if (attempt > 1) notifyCapture(operation, result);
|
|
95
|
+
else operation.callbacks = [];
|
|
96
|
+
return result;
|
|
97
|
+
}
|
|
98
|
+
if (attempt >= MAX_CAPTURE_ATTEMPTS) {
|
|
99
|
+
operation.active = false;
|
|
100
|
+
notifyCapture(operation, result);
|
|
101
|
+
return result;
|
|
102
|
+
}
|
|
103
|
+
operation.timer = (options.setTimeout || setTimeout)(function () { retryCapture(key, operation, attempt + 1); }, RETRY_DELAY_MS * attempt);
|
|
104
|
+
return result;
|
|
105
|
+
}
|
|
106
|
+
function begin(args) {
|
|
107
|
+
if (!args.driverOriginId || !args.workerOriginId) return { ok: false, error: "durable session origins are required before dispatch" };
|
|
108
|
+
var key = resultKey(args);
|
|
109
|
+
var existing = records[key];
|
|
110
|
+
if (existing) return { ok: true, key: key, record: copy(existing), duplicate: true };
|
|
111
|
+
var record = {
|
|
112
|
+
key: key,
|
|
113
|
+
ownerId: args.ownerId || null,
|
|
114
|
+
projectSlug: args.projectSlug || null,
|
|
115
|
+
driverOriginId: args.driverOriginId,
|
|
116
|
+
workerOriginId: args.workerOriginId,
|
|
117
|
+
taskId: args.taskId,
|
|
118
|
+
generation: args.generation == null ? null : args.generation,
|
|
119
|
+
driverSessionId: args.driverSessionId == null ? null : args.driverSessionId,
|
|
120
|
+
workerSessionId: args.workerSessionId == null ? null : args.workerSessionId,
|
|
121
|
+
workerSessionOriginId: args.workerOriginId,
|
|
122
|
+
historyStartIndex: args.historyStartIndex == null ? null : args.historyStartIndex,
|
|
123
|
+
message: args.message || "",
|
|
124
|
+
state: "dispatching",
|
|
125
|
+
deliveryState: "pending",
|
|
126
|
+
deliveryRoute: args.deliveryRoute === "blocking" ? "blocking" : "callback",
|
|
127
|
+
deliveryAttemptCount: 0,
|
|
128
|
+
deliveryAttemptSequence: 0,
|
|
129
|
+
transcriptPersisted: false,
|
|
130
|
+
blockedReason: null,
|
|
131
|
+
blockedCode: null,
|
|
132
|
+
finalized: false,
|
|
133
|
+
manualRetryCount: 0,
|
|
134
|
+
manualRetryEpoch: 0,
|
|
135
|
+
createdAt: Date.now(),
|
|
136
|
+
capturedAt: null,
|
|
137
|
+
outcome: null,
|
|
138
|
+
};
|
|
139
|
+
var nextRecords = Object.assign({}, records);
|
|
140
|
+
nextRecords[key] = record;
|
|
141
|
+
var saved = persist(nextRecords);
|
|
142
|
+
if (!saved.ok) return { ok: false, key: key, error: saved.error, record: copy(record) };
|
|
143
|
+
records = nextRecords;
|
|
144
|
+
return { ok: true, key: key, record: copy(record), duplicate: false };
|
|
145
|
+
}
|
|
146
|
+
function capture(args, outcome, callback) {
|
|
147
|
+
var key = typeof args === "string" ? args : resultKey(args);
|
|
148
|
+
var record = records[key];
|
|
149
|
+
if (!record) return { ok: false, error: "pair result outbox record was not found" };
|
|
150
|
+
if (record.outcome) return captureRecord(key, outcome);
|
|
151
|
+
var operation = captureOperations[key];
|
|
152
|
+
if (operation) {
|
|
153
|
+
if (!sameOutcome(operation.outcome, outcome)) return { ok: false, conflict: true, error: "pair result outcome is immutable" };
|
|
154
|
+
addCaptureCallback(operation, callback);
|
|
155
|
+
if (operation.active) return Object.assign({ inFlight: true }, operation.lastResult || { ok: false });
|
|
156
|
+
if (operation.complete) return captureRecord(key, outcome);
|
|
157
|
+
operation.active = true; operation.callbacks = [];
|
|
158
|
+
addCaptureCallback(operation, callback);
|
|
159
|
+
return retryCapture(key, operation, 1);
|
|
160
|
+
}
|
|
161
|
+
operation = { outcome: copy(outcome), callbacks: [], active: true, complete: false, lastResult: null, timer: null };
|
|
162
|
+
captureOperations[key] = operation;
|
|
163
|
+
addCaptureCallback(operation, callback);
|
|
164
|
+
return retryCapture(key, operation, 1);
|
|
165
|
+
}
|
|
166
|
+
function transition(key, changes) {
|
|
167
|
+
var record = records[key];
|
|
168
|
+
if (!record) return { ok: false, error: "pair result outbox record was not found" };
|
|
169
|
+
var nextRecords = Object.assign({}, records), next = Object.assign({}, record, changes);
|
|
170
|
+
nextRecords[key] = next;
|
|
171
|
+
var saved = persist(nextRecords);
|
|
172
|
+
if (!saved.ok) return { ok: false, error: saved.error, record: copy(record) };
|
|
173
|
+
records = nextRecords;
|
|
174
|
+
return { ok: true, record: copy(next) };
|
|
175
|
+
}
|
|
176
|
+
function lifetimeAttemptSequence(record) {
|
|
177
|
+
var sequence = Math.max(Number(record.deliveryAttemptSequence || 0), Number(record.deliveryAttemptCount || 0));
|
|
178
|
+
var match = typeof record.attemptId === "string" && record.attemptId.match(/:(\d+)$/);
|
|
179
|
+
if (match) sequence = Math.max(sequence, Number(match[1]));
|
|
180
|
+
return sequence;
|
|
181
|
+
}
|
|
182
|
+
function beginDelivery(key) {
|
|
183
|
+
var record = records[key];
|
|
184
|
+
if (!record || !record.outcome) return { ok: false, error: "pair result is not captured" };
|
|
185
|
+
if (record.deliveryRoute !== "callback") return { ok: true, record: copy(record), blocked: true };
|
|
186
|
+
if (record.deliveryState === "accepted" || record.deliveryState === "uncertain") return { ok: true, record: copy(record), terminal: true };
|
|
187
|
+
if (record.deliveryState === "attempting") return { ok: true, record: copy(record), inFlight: true };
|
|
188
|
+
var count = Number(record.deliveryAttemptCount || 0);
|
|
189
|
+
if (count >= MAX_DELIVERY_ATTEMPTS) return { ok: true, record: copy(record), exhausted: true };
|
|
190
|
+
var sequence = lifetimeAttemptSequence(record) + 1;
|
|
191
|
+
var attemptId = key + ":" + sequence;
|
|
192
|
+
var started = transition(key, { deliveryState: "attempting", deliveryAttemptCount: count + 1,
|
|
193
|
+
deliveryAttemptSequence: sequence, attemptId: attemptId, attemptStartedAt: Date.now(), deliveryError: null });
|
|
194
|
+
if (started.ok) started.attemptId = attemptId;
|
|
195
|
+
return started;
|
|
196
|
+
}
|
|
197
|
+
function transitionAttempt(key, attemptId, allowedStates, changes) {
|
|
198
|
+
var record = records[key];
|
|
199
|
+
if (!record) return { ok: false, error: "pair result outbox record was not found" };
|
|
200
|
+
if (record.attemptId !== attemptId || allowedStates.indexOf(record.deliveryState) === -1) {
|
|
201
|
+
return { ok: false, stale: true, record: copy(record), error: "delivery attempt is no longer current" };
|
|
202
|
+
}
|
|
203
|
+
return transition(key, changes);
|
|
204
|
+
}
|
|
205
|
+
function markTranscript(key, attemptId) {
|
|
206
|
+
return transitionAttempt(key, attemptId, ["attempting"], { transcriptPersisted: true, transcriptPersistedAt: Date.now() });
|
|
207
|
+
}
|
|
208
|
+
function markDelivery(key, attemptId, state, changes) {
|
|
209
|
+
return transitionAttempt(key, attemptId, ["attempting"], Object.assign({ deliveryState: state }, changes || {}));
|
|
210
|
+
}
|
|
211
|
+
function setRoute(key, route) {
|
|
212
|
+
var record = records[key];
|
|
213
|
+
if (!record) return { ok: false, error: "pair result outbox record was not found" };
|
|
214
|
+
if (route !== "blocking" && route !== "callback") return { ok: false, error: "delivery route is invalid" };
|
|
215
|
+
if (record.deliveryState === "accepted" || record.deliveryState === "uncertain") return { ok: false, error: "terminal delivery route cannot change" };
|
|
216
|
+
return transition(key, { deliveryRoute: route });
|
|
217
|
+
}
|
|
218
|
+
function recover(key, session, callback) {
|
|
219
|
+
var record = records[key];
|
|
220
|
+
var history = session && Array.isArray(session.history) ? session.history : [];
|
|
221
|
+
var origin = stableOrigin(session);
|
|
222
|
+
var boundary = record && Number.isInteger(record.historyStartIndex) ? record.historyStartIndex : 0;
|
|
223
|
+
var terminal = record && origin === record.workerOriginId && session.ownerId === record.ownerId && history.slice(boundary).filter(function (entry) {
|
|
224
|
+
return entry && entry.type === "pair_task_completed" && entry.ownerId === record.ownerId && entry.driverOriginId === record.driverOriginId && entry.workerOriginId === record.workerOriginId && entry.taskId === record.taskId && entry.generation === record.generation;
|
|
225
|
+
}).pop();
|
|
226
|
+
if (!terminal || !terminal.outcome || terminal.outcome.taskId !== record.taskId || terminal.outcome.generation !== record.generation) return { ok: false, uncertain: true, error: "stored Worker history does not prove this terminal completion" };
|
|
227
|
+
return capture(key, terminal.outcome, callback);
|
|
228
|
+
}
|
|
229
|
+
function recoverPending(sessionMap) {
|
|
230
|
+
var results = [], sessionsList = sessionMap && typeof sessionMap.values === "function" ? Array.from(sessionMap.values()) : [], keys = Object.keys(records);
|
|
231
|
+
for (var i = 0; i < keys.length; i++) {
|
|
232
|
+
if (records[keys[i]].state !== "dispatching") continue;
|
|
233
|
+
var session = sessionsList.find(function (candidate) { return stableOrigin(candidate) === records[keys[i]].workerOriginId && candidate.ownerId === records[keys[i]].ownerId; });
|
|
234
|
+
if (session) results.push({ key: keys[i], result: recover(keys[i], session) });
|
|
235
|
+
}
|
|
236
|
+
return results;
|
|
237
|
+
}
|
|
238
|
+
function markBlocked(key, reason, code) {
|
|
239
|
+
var record = records[key];
|
|
240
|
+
if (!record) return { ok: false, error: "pair result outbox record was not found" };
|
|
241
|
+
var nextRecords = Object.assign({}, records), next = copy(record);
|
|
242
|
+
next.state = "blocked";
|
|
243
|
+
next.blockedReason = String(reason || "Delivery is blocked");
|
|
244
|
+
next.blockedCode = code || null;
|
|
245
|
+
if (code === "human_stop" && record.blockedCode !== "human_stop") {
|
|
246
|
+
next.blockedDeliveryState = record.deliveryState;
|
|
247
|
+
next.blockedAttemptId = record.attemptId || null;
|
|
248
|
+
}
|
|
249
|
+
nextRecords[key] = next;
|
|
250
|
+
var saved = persist(nextRecords);
|
|
251
|
+
if (!saved.ok) return { ok: false, error: saved.error, record: copy(record) };
|
|
252
|
+
records = nextRecords;
|
|
253
|
+
return { ok: true, record: copy(next) };
|
|
254
|
+
}
|
|
255
|
+
function markFinalized(key) {
|
|
256
|
+
var record = records[key];
|
|
257
|
+
if (!record) return { ok: false, error: "pair result outbox record was not found" };
|
|
258
|
+
if (record.finalized) return { ok: true, duplicate: true, record: copy(record) };
|
|
259
|
+
return transition(key, { finalized: true, finalizedAt: Date.now(), finalizationState: "complete", finalizationError: null });
|
|
260
|
+
}
|
|
261
|
+
function markFinalizationFailure(key, reason) {
|
|
262
|
+
return transition(key, { finalizationState: "failed", finalizationError: String(reason || "Result finalization failed") });
|
|
263
|
+
}
|
|
264
|
+
function markRestartUncertain(key) {
|
|
265
|
+
var record = records[key];
|
|
266
|
+
if (!record || record.deliveryState !== "attempting") return { ok: true, stale: true, record: copy(record) };
|
|
267
|
+
return transitionAttempt(key, record.attemptId, ["attempting"], {
|
|
268
|
+
deliveryState: "uncertain", deliveryError: "Delivery was interrupted before acceptance could be confirmed",
|
|
269
|
+
});
|
|
270
|
+
}
|
|
271
|
+
function authorizeHumanTurn(key) {
|
|
272
|
+
var record = records[key];
|
|
273
|
+
if (!record || record.blockedCode !== "human_stop") return { ok: false, stale: true, record: copy(record) };
|
|
274
|
+
var deliveryState = record.blockedDeliveryState || record.deliveryState;
|
|
275
|
+
if (deliveryState === "attempting") deliveryState = "uncertain";
|
|
276
|
+
return transition(key, { state: record.outcome ? "captured" : "dispatching", blockedReason: null, blockedCode: null,
|
|
277
|
+
blockedDeliveryState: null, blockedAttemptId: null, deliveryState: deliveryState });
|
|
278
|
+
}
|
|
279
|
+
function grantManualRetry(key) {
|
|
280
|
+
var record = records[key];
|
|
281
|
+
if (!record || !record.outcome || record.deliveryRoute !== "callback") return { ok: false, error: "completed result is unavailable" };
|
|
282
|
+
if (record.blockedCode === "human_stop") return { ok: false, blocked: true, error: record.blockedReason };
|
|
283
|
+
var exhausted = record.deliveryState === "pending" && Number(record.deliveryAttemptCount || 0) >= MAX_DELIVERY_ATTEMPTS;
|
|
284
|
+
if (record.deliveryState !== "uncertain" && !exhausted) return { ok: false, error: "result is not eligible for manual retry" };
|
|
285
|
+
if (Number(record.manualRetryCount || 0) >= 1) return { ok: false, exhausted: true, error: "manual retry was already used" };
|
|
286
|
+
return transition(key, { state: "captured", blockedReason: null, blockedCode: null, deliveryState: "pending",
|
|
287
|
+
deliveryAttemptCount: 0, attemptId: null, deliveryError: null, manualRetryCount: Number(record.manualRetryCount || 0) + 1,
|
|
288
|
+
manualRetryEpoch: Number(record.manualRetryEpoch || 0) + 1 });
|
|
289
|
+
}
|
|
290
|
+
function persistedRecords() {
|
|
291
|
+
var parsed;
|
|
292
|
+
try { parsed = JSON.parse(fs.readFileSync(filePath, "utf8")); } catch (error) { return []; }
|
|
293
|
+
var source = parsed && parsed.records && typeof parsed.records === "object" ? parsed.records : {};
|
|
294
|
+
return Object.keys(source).map(function (key) {
|
|
295
|
+
var record = copy(source[key]);
|
|
296
|
+
record.deliveryState = record.deliveryState || "pending";
|
|
297
|
+
record.recoveryState = record.state === "dispatching" || record.deliveryState === "attempting" || record.deliveryState === "uncertain" ? "uncertain" : "known";
|
|
298
|
+
record.blockedReason = record.blockedReason || null;
|
|
299
|
+
return record;
|
|
300
|
+
});
|
|
301
|
+
}
|
|
302
|
+
function status() {
|
|
303
|
+
return { records: persistedRecords(), persistenceError: lastPersistenceError };
|
|
304
|
+
}
|
|
305
|
+
read();
|
|
306
|
+
return {
|
|
307
|
+
begin: begin,
|
|
308
|
+
capture: capture,
|
|
309
|
+
beginDelivery: beginDelivery,
|
|
310
|
+
markTranscript: markTranscript,
|
|
311
|
+
markDelivery: markDelivery,
|
|
312
|
+
setRoute: setRoute,
|
|
313
|
+
recover: recover,
|
|
314
|
+
recoverPending: recoverPending,
|
|
315
|
+
markBlocked: markBlocked,
|
|
316
|
+
markFinalized: markFinalized,
|
|
317
|
+
markFinalizationFailure: markFinalizationFailure,
|
|
318
|
+
markRestartUncertain: markRestartUncertain,
|
|
319
|
+
authorizeHumanTurn: authorizeHumanTurn,
|
|
320
|
+
grantManualRetry: grantManualRetry,
|
|
321
|
+
status: status,
|
|
322
|
+
list: function () { return Object.keys(records).map(function (key) { return copy(records[key]); }); },
|
|
323
|
+
get: function (key) { return records[key] ? copy(records[key]) : null; },
|
|
324
|
+
key: resultKey,
|
|
325
|
+
stableOrigin: stableOrigin,
|
|
326
|
+
maxCaptureAttempts: MAX_CAPTURE_ATTEMPTS,
|
|
327
|
+
maxDeliveryAttempts: MAX_DELIVERY_ATTEMPTS,
|
|
328
|
+
};
|
|
329
|
+
}
|
|
330
|
+
|
|
331
|
+
module.exports = { attachPairResultOutbox: attachPairResultOutbox, resultKey: resultKey, stableOrigin: stableOrigin };
|
|
@@ -0,0 +1,16 @@
|
|
|
1
|
+
var attachPairResultCapture = require("./project-pair-result-capture").attachPairResultCapture;
|
|
2
|
+
var attachPairResultDelivery = require("./project-pair-result-delivery").attachPairResultDelivery;
|
|
3
|
+
|
|
4
|
+
function attachPairResultPipeline(ctx) {
|
|
5
|
+
var capture;
|
|
6
|
+
var delivery = attachPairResultDelivery({ sm: ctx.sm, store: ctx.store, outbox: ctx.resultOutbox, getSdk: ctx.getSdk,
|
|
7
|
+
getLinuxUserForSession: ctx.getLinuxUserForSession, acceptanceTimeoutMs: ctx.acceptanceTimeoutMs,
|
|
8
|
+
blockedReason: ctx.blockedReason,
|
|
9
|
+
onStateChange: ctx.onStateChange,
|
|
10
|
+
finish: function (caller, partner, token, delivered) { return capture.finish(caller, partner, token, delivered); } });
|
|
11
|
+
capture = attachPairResultCapture({ sm: ctx.sm, store: ctx.store, resultOutbox: ctx.resultOutbox, delivery: delivery, finishDelegation: ctx.finishDelegation, onStateChange: ctx.onStateChange,
|
|
12
|
+
resumeDriverWithResult: ctx.resumeDriverWithResult, onPartnerResult: ctx.onPartnerResult, drain: ctx.drain });
|
|
13
|
+
return { capture: capture, delivery: delivery };
|
|
14
|
+
}
|
|
15
|
+
|
|
16
|
+
module.exports = { attachPairResultPipeline: attachPairResultPipeline };
|