@scotthuang/agent-knock-knock 0.10.1 → 0.10.2
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/CHANGELOG.md +9 -0
- package/dist/src/claude-local-transcript-provider.d.ts +16 -0
- package/dist/src/claude-local-transcript-provider.js +111 -47
- package/dist/src/claude-local-transcript-provider.js.map +1 -1
- package/dist/src/cli.js +2623 -432
- package/dist/src/cli.js.map +1 -1
- package/dist/src/live-lifecycle-smoke.js +7 -2
- package/dist/src/live-lifecycle-smoke.js.map +1 -1
- package/dist/src/openclaw-plugin-helpers.d.ts +1 -0
- package/dist/src/openclaw-plugin-helpers.js +30 -2
- package/dist/src/openclaw-plugin-helpers.js.map +1 -1
- package/dist/src/openclaw-plugin.js +166 -31
- package/dist/src/openclaw-plugin.js.map +1 -1
- package/dist/src/terminal-agent-bridge.d.ts +31 -2
- package/dist/src/terminal-agent-bridge.js +231 -11
- package/dist/src/terminal-agent-bridge.js.map +1 -1
- package/dist/src/terminal-submission-acceptance.d.ts +78 -0
- package/dist/src/terminal-submission-acceptance.js +454 -0
- package/dist/src/terminal-submission-acceptance.js.map +1 -0
- package/package.json +1 -1
package/dist/src/cli.js
CHANGED
|
@@ -7,14 +7,15 @@ import process from "node:process";
|
|
|
7
7
|
import { fileURLToPath } from "node:url";
|
|
8
8
|
import { createCodexTerminalAgentAdapter, detectCodexDurableCompletion } from "./codex-terminal-agent-adapter.js";
|
|
9
9
|
import { createClaudeTerminalAgentAdapter } from "./claude-terminal-agent-adapter.js";
|
|
10
|
-
import { captureClaudeTranscriptAnchor, defaultClaudeHome, detectClaudeTranscriptCompletion, detectClaudeTranscriptPendingApproval, listClaudeThreadLifecycleCandidates, revalidateClaudeThreadLifecycleCandidate } from "./claude-local-transcript-provider.js";
|
|
10
|
+
import { captureClaudeTranscriptAnchor, defaultClaudeHome, detectClaudeTranscriptAcceptance, detectClaudeTranscriptCompletion, detectClaudeTranscriptPendingApproval, listClaudeThreadLifecycleCandidates, revalidateClaudeThreadLifecycleCandidate } from "./claude-local-transcript-provider.js";
|
|
11
|
+
import { captureCodexRolloutAcceptanceAnchor, detectCodexRolloutAcceptance, terminalSubmissionReplayReceipt, validateTerminalSubmissionAcceptanceEvidence } from "./terminal-submission-acceptance.js";
|
|
11
12
|
import { CodexLocalSessionProvider } from "./codex-local-session-provider.js";
|
|
12
13
|
import { CodexStoreAdapter } from "./codex-store-adapter.js";
|
|
13
14
|
import { applyMessageToConversation, budgetAction, createConversation, createMessage, effectiveTurnStatus, executorForConversation, extractStructuredMessage, isTurnPhaseStatus, normalizeLegacyCallbackStatus, parseMessageJson, resolveExecutor, sessionIdForConversation, turnIdForConversation } from "./protocol.js";
|
|
14
15
|
import { EXECUTOR_KINDS, executorDefinitionForKind, isExecutorKind } from "./executors.js";
|
|
15
16
|
import { redactString, writeRuntimeLog } from "./runtime-log.js";
|
|
16
17
|
import { formatTranscript, readNdjsonLog } from "./transcript.js";
|
|
17
|
-
import { appendEvent, assertStoreWriterCompatible, defaultStoreDir, ensureDir, ensureStoreWritable, inspectStoreCompatibility, listConversations, logPathForStatePath, loadConversationById, loadState, messageEvent, pathsForConversation, pathsForConversationDir, saveState, statePathForConversationId, withStoreWriterLeaseAsync } from "./store.js";
|
|
18
|
+
import { appendEvent, assertStoreWriterCompatible, defaultStoreDir, ensureDir, ensureStoreWritable, inspectStoreCompatibility, listConversations, logPathForStatePath, loadConversationById, loadState, messageEvent, pathsForConversation, pathsForConversationDir, saveState, statePathForConversationId, withStoreWriterLease, withStoreWriterLeaseAsync } from "./store.js";
|
|
18
19
|
import { createManagedSessionId, createNativeThreadTransitionId, isExactNativeThreadId, managedSessionBindingToken, nativeThreadCommandFingerprint, terminalBindingFrom, unmanagedTerminalBindingToken } from "./managed-session.js";
|
|
19
20
|
import { classifyCodexLifecyclePostcondition, evaluateResumeCandidateAvailability, hasStrongCodexLifecycleIdentity, isFreshCodexPostProbeScreen } from "./native-thread-lifecycle-policy.js";
|
|
20
21
|
import { listManagedSessions, loadManagedSession, loadNativeThreadTransition, saveManagedSession, saveNativeThreadTransition, tryLoadManagedSession } from "./session-store.js";
|
|
@@ -31,6 +32,8 @@ const DEFAULT_IDLE_TIMEOUT_MINUTES = 10080;
|
|
|
31
32
|
const DEFAULT_AGENT_TIMEOUT_MINUTES = 60;
|
|
32
33
|
const DEFAULT_AGENT_HARD_TIMEOUT_MINUTES = 720;
|
|
33
34
|
const DEFAULT_MONITOR_POLL_INTERVAL_MS = 5000;
|
|
35
|
+
const DEFAULT_TERMINAL_ACCEPTANCE_TIMEOUT_MS = 5000;
|
|
36
|
+
const DEFAULT_TERMINAL_ACCEPTANCE_POLL_INTERVAL_MS = 50;
|
|
34
37
|
const CLAUDE_SCREEN_APPROVAL_TTL_MS = 10 * 60 * 1000;
|
|
35
38
|
const CALLBACK_DELIVERY_TIMEOUT_MS = 30_000;
|
|
36
39
|
const CALLBACK_AGENT_WAIT_TIMEOUT_MS = 20_000;
|
|
@@ -38,6 +41,32 @@ const CALLBACK_AGENT_WAIT_CLI_TIMEOUT_MS = 25_000;
|
|
|
38
41
|
const CALLBACK_AGENT_WAIT_PROCESS_TIMEOUT_MS = 30_000;
|
|
39
42
|
const CALLBACK_ATTEMPT_LEASE_MS = 2 * 60 * 1000;
|
|
40
43
|
const CALLBACK_RETRY_DELAYS_MS = [5000, 15000, 60000, 60000];
|
|
44
|
+
const TERMINAL_LEDGER_RECEIPT_STATUSES = new Set([
|
|
45
|
+
"text_injected",
|
|
46
|
+
"enter_dispatched",
|
|
47
|
+
"submitted",
|
|
48
|
+
"agent_accepted",
|
|
49
|
+
"not_accepted",
|
|
50
|
+
"uncertain",
|
|
51
|
+
"aborted"
|
|
52
|
+
]);
|
|
53
|
+
const TERMINAL_LEDGER_RECEIPT_IMMUTABLE_FIELDS = [
|
|
54
|
+
"binding_id",
|
|
55
|
+
"binding_generation",
|
|
56
|
+
"native_thread_id",
|
|
57
|
+
"store_dir",
|
|
58
|
+
"conversation_id",
|
|
59
|
+
"session_id",
|
|
60
|
+
"turn_id",
|
|
61
|
+
"message_id",
|
|
62
|
+
"message_type",
|
|
63
|
+
"message_body_hash",
|
|
64
|
+
"request_hash",
|
|
65
|
+
"executor_kind",
|
|
66
|
+
"openclaw_session",
|
|
67
|
+
"state_path",
|
|
68
|
+
"event_log_path"
|
|
69
|
+
];
|
|
41
70
|
const TERMINAL_BRIDGE_SUPERSEDE_STATUSES = new Set([
|
|
42
71
|
"created",
|
|
43
72
|
"running",
|
|
@@ -1063,6 +1092,158 @@ function isTerminalControlCapability(value) {
|
|
|
1063
1092
|
"terminal_cancel"
|
|
1064
1093
|
].includes(value);
|
|
1065
1094
|
}
|
|
1095
|
+
function assertSafeAbortedTerminalRetryBinding({ owner, receipt, storeDir, terminalControl, messageId }) {
|
|
1096
|
+
if (!(receipt.status === "aborted" && receipt.safe_to_retry === true)) {
|
|
1097
|
+
return;
|
|
1098
|
+
}
|
|
1099
|
+
const sessionId = sessionIdForConversation(owner);
|
|
1100
|
+
const managedSession = tryLoadManagedSession(storeDir, sessionId);
|
|
1101
|
+
const binding = managedSession?.binding;
|
|
1102
|
+
const receiptBindingId = stringValue(receipt.binding_id);
|
|
1103
|
+
const receiptBindingGeneration = Number(receipt.binding_generation);
|
|
1104
|
+
const receiptNativeThreadId = stringValue(receipt.native_thread_id);
|
|
1105
|
+
const ownerTakeover = isRecord(owner.native_session_takeover)
|
|
1106
|
+
? owner.native_session_takeover
|
|
1107
|
+
: undefined;
|
|
1108
|
+
const ownerControl = terminalControlFromTakeover(ownerTakeover);
|
|
1109
|
+
const ownerNativeThreadId = stringValue(owner.native_thread_id) ??
|
|
1110
|
+
stringValue(ownerTakeover?.terminal_agent_session_id) ??
|
|
1111
|
+
stringValue(ownerTakeover?.terminal_agent_expected_session_id);
|
|
1112
|
+
const ownerAgentPid = Number(ownerTakeover?.terminal_agent_pid);
|
|
1113
|
+
if (!managedSession ||
|
|
1114
|
+
managedSession.status !== "bound" ||
|
|
1115
|
+
!binding ||
|
|
1116
|
+
!receiptBindingId ||
|
|
1117
|
+
!Number.isSafeInteger(receiptBindingGeneration) ||
|
|
1118
|
+
!receiptNativeThreadId ||
|
|
1119
|
+
receiptBindingId !== stringValue(owner.terminal_binding_id) ||
|
|
1120
|
+
receiptBindingGeneration !== Number(owner.terminal_binding_generation) ||
|
|
1121
|
+
receiptNativeThreadId !== ownerNativeThreadId ||
|
|
1122
|
+
binding.binding_id !== receiptBindingId ||
|
|
1123
|
+
binding.generation !== receiptBindingGeneration ||
|
|
1124
|
+
binding.native_thread_id !== receiptNativeThreadId ||
|
|
1125
|
+
!Number.isSafeInteger(ownerAgentPid) ||
|
|
1126
|
+
binding.native_process.pid !== ownerAgentPid ||
|
|
1127
|
+
!ownerControl ||
|
|
1128
|
+
terminalControlSelectorKey(ownerControl) !==
|
|
1129
|
+
terminalControlSelectorKey(terminalControl) ||
|
|
1130
|
+
terminalControlSelectorKey(binding.terminal_control) !==
|
|
1131
|
+
terminalControlSelectorKey(terminalControl)) {
|
|
1132
|
+
throw new Error(`terminal idempotency key ${messageId} belongs to a safe-aborted Turn ` +
|
|
1133
|
+
"whose Session binding is no longer current; no terminal input was sent");
|
|
1134
|
+
}
|
|
1135
|
+
}
|
|
1136
|
+
function stableDelegateTerminalRoute({ options, request, workspace, requestedAgent }) {
|
|
1137
|
+
const messageId = stringValue(options.messageId);
|
|
1138
|
+
if (!messageId) {
|
|
1139
|
+
return undefined;
|
|
1140
|
+
}
|
|
1141
|
+
const storeDir = path.resolve(storeDirFromOptions(options));
|
|
1142
|
+
const requestHash = terminalBridgeRequestFingerprint(terminalSubmissionPayload(request));
|
|
1143
|
+
const bodyHash = createHash("sha256").update(request).digest("hex");
|
|
1144
|
+
const requestedOpenClawSession = stringValue(options.openclawSession);
|
|
1145
|
+
const matches = listConversations(storeDir).flatMap((owner) => terminalBridgeSubmissionReceipts(owner)
|
|
1146
|
+
.filter((receipt) => stringValue(receipt.message_id) === messageId)
|
|
1147
|
+
.map((receipt) => ({ owner, receipt })));
|
|
1148
|
+
if (matches.length === 0) {
|
|
1149
|
+
return undefined;
|
|
1150
|
+
}
|
|
1151
|
+
const routed = matches.map(({ owner, receipt }) => {
|
|
1152
|
+
const ownerStoreDir = managedSessionStoreDirForConversation(owner);
|
|
1153
|
+
const takeover = isRecord(owner.native_session_takeover)
|
|
1154
|
+
? owner.native_session_takeover
|
|
1155
|
+
: undefined;
|
|
1156
|
+
const terminalControl = terminalControlFromTakeover(takeover);
|
|
1157
|
+
const conversationId = stringValue(takeover?.native_session_id);
|
|
1158
|
+
let eventMessages = [];
|
|
1159
|
+
const eventLogPath = stringValue(owner.event_log_path);
|
|
1160
|
+
if (eventLogPath) {
|
|
1161
|
+
try {
|
|
1162
|
+
eventMessages = readNdjsonLog(eventLogPath)
|
|
1163
|
+
.filter((event) => isRecord(event.message) && event.message.id === messageId)
|
|
1164
|
+
.map((event) => event.message);
|
|
1165
|
+
}
|
|
1166
|
+
catch {
|
|
1167
|
+
eventMessages = [];
|
|
1168
|
+
}
|
|
1169
|
+
}
|
|
1170
|
+
if (eventMessages.length > 1) {
|
|
1171
|
+
throw new Error(`terminal idempotency key ${messageId} has duplicate durable messages`);
|
|
1172
|
+
}
|
|
1173
|
+
const eventMessage = eventMessages[0];
|
|
1174
|
+
const messageType = stringValue(receipt.message_type) ??
|
|
1175
|
+
(isRecord(eventMessage) ? stringValue(eventMessage.type) : undefined);
|
|
1176
|
+
const storedBodyHash = stringValue(receipt.message_body_hash) ??
|
|
1177
|
+
(isRecord(eventMessage) && typeof eventMessage.body === "string"
|
|
1178
|
+
? createHash("sha256").update(eventMessage.body).digest("hex")
|
|
1179
|
+
: undefined);
|
|
1180
|
+
const ownerWorkspace = canonicalWorkspace(owner.workspace);
|
|
1181
|
+
if (!ownerStoreDir ||
|
|
1182
|
+
path.resolve(ownerStoreDir) !== storeDir ||
|
|
1183
|
+
(stringValue(receipt.store_dir) !== undefined &&
|
|
1184
|
+
path.resolve(String(receipt.store_dir)) !== storeDir) ||
|
|
1185
|
+
!terminalControl ||
|
|
1186
|
+
!conversationId ||
|
|
1187
|
+
stringValue(receipt.request_hash) !== requestHash ||
|
|
1188
|
+
messageType !== "task" ||
|
|
1189
|
+
storedBodyHash !== bodyHash ||
|
|
1190
|
+
(requestedOpenClawSession &&
|
|
1191
|
+
(stringValue(receipt.openclaw_session) ?? owner.openclaw_session) !==
|
|
1192
|
+
requestedOpenClawSession) ||
|
|
1193
|
+
(requestedAgent && executorForConversation(owner).kind !== requestedAgent) ||
|
|
1194
|
+
(workspace && ownerWorkspace !== workspace)) {
|
|
1195
|
+
throw new Error(`terminal idempotency key ${messageId} does not match its original ` +
|
|
1196
|
+
"delegate request boundary; no terminal input was sent");
|
|
1197
|
+
}
|
|
1198
|
+
return {
|
|
1199
|
+
owner,
|
|
1200
|
+
receipt,
|
|
1201
|
+
conversationId,
|
|
1202
|
+
workspace: ownerWorkspace,
|
|
1203
|
+
terminalKey: terminalControlSelectorKey(terminalControl),
|
|
1204
|
+
panePid: terminalControl.panePid
|
|
1205
|
+
};
|
|
1206
|
+
});
|
|
1207
|
+
const authoritative = routed.filter(({ receipt }) => !(receipt.status === "aborted" && receipt.safe_to_retry === true));
|
|
1208
|
+
if (authoritative.length > 1) {
|
|
1209
|
+
throw new Error(`terminal idempotency key ${messageId} has multiple durable delegate receipts`);
|
|
1210
|
+
}
|
|
1211
|
+
const terminalKeys = new Set(routed.map((entry) => `${entry.conversationId}\0${entry.terminalKey}\0${entry.panePid}`));
|
|
1212
|
+
if (terminalKeys.size !== 1) {
|
|
1213
|
+
throw new Error(`terminal idempotency key ${messageId} has conflicting terminal routes`);
|
|
1214
|
+
}
|
|
1215
|
+
const selected = authoritative[0] ?? routed.at(-1);
|
|
1216
|
+
if (!selected) {
|
|
1217
|
+
return undefined;
|
|
1218
|
+
}
|
|
1219
|
+
if (selected.receipt.status === "aborted" &&
|
|
1220
|
+
selected.receipt.safe_to_retry === true) {
|
|
1221
|
+
const sessionId = sessionIdForConversation(selected.owner);
|
|
1222
|
+
const ownerControl = terminalControlFromTakeover(isRecord(selected.owner.native_session_takeover)
|
|
1223
|
+
? selected.owner.native_session_takeover
|
|
1224
|
+
: undefined);
|
|
1225
|
+
if (!ownerControl) {
|
|
1226
|
+
throw new Error(`terminal idempotency key ${messageId} has no durable terminal route`);
|
|
1227
|
+
}
|
|
1228
|
+
assertSafeAbortedTerminalRetryBinding({
|
|
1229
|
+
owner: selected.owner,
|
|
1230
|
+
receipt: selected.receipt,
|
|
1231
|
+
storeDir,
|
|
1232
|
+
terminalControl: ownerControl,
|
|
1233
|
+
messageId
|
|
1234
|
+
});
|
|
1235
|
+
return {
|
|
1236
|
+
kind: "session",
|
|
1237
|
+
sessionId,
|
|
1238
|
+
workspace: selected.workspace
|
|
1239
|
+
};
|
|
1240
|
+
}
|
|
1241
|
+
return {
|
|
1242
|
+
kind: "terminal",
|
|
1243
|
+
conversationId: selected.conversationId,
|
|
1244
|
+
workspace: selected.workspace
|
|
1245
|
+
};
|
|
1246
|
+
}
|
|
1066
1247
|
async function runDelegate(options) {
|
|
1067
1248
|
const request = required(options.request, "--request is required");
|
|
1068
1249
|
const workspace = options.workspace === undefined
|
|
@@ -1071,6 +1252,32 @@ async function runDelegate(options) {
|
|
|
1071
1252
|
const requestedAgent = options.agent === undefined
|
|
1072
1253
|
? undefined
|
|
1073
1254
|
: resolveExecutor({ kind: options.agent }).kind;
|
|
1255
|
+
const stableRoute = stableDelegateTerminalRoute({
|
|
1256
|
+
options,
|
|
1257
|
+
request,
|
|
1258
|
+
workspace,
|
|
1259
|
+
requestedAgent
|
|
1260
|
+
});
|
|
1261
|
+
if (stableRoute) {
|
|
1262
|
+
await runSend(stableRoute.kind === "session"
|
|
1263
|
+
? {
|
|
1264
|
+
...options,
|
|
1265
|
+
session: stableRoute.sessionId,
|
|
1266
|
+
conversation: undefined,
|
|
1267
|
+
message: request,
|
|
1268
|
+
workspace: stableRoute.workspace,
|
|
1269
|
+
background: true
|
|
1270
|
+
}
|
|
1271
|
+
: {
|
|
1272
|
+
...options,
|
|
1273
|
+
conversation: stableRoute.conversationId,
|
|
1274
|
+
session: undefined,
|
|
1275
|
+
message: request,
|
|
1276
|
+
workspace: stableRoute.workspace,
|
|
1277
|
+
background: true
|
|
1278
|
+
});
|
|
1279
|
+
return;
|
|
1280
|
+
}
|
|
1074
1281
|
const scan = await buildTerminalListGroup({
|
|
1075
1282
|
options: {
|
|
1076
1283
|
...options,
|
|
@@ -1340,7 +1547,7 @@ function terminalBridgeEnabled(conversation) {
|
|
|
1340
1547
|
: undefined;
|
|
1341
1548
|
return nativeTakeover?.["terminal_bridge"] === true;
|
|
1342
1549
|
}
|
|
1343
|
-
function withTerminalBridgeState({ conversation, message, requestText, startedAt, agentTimeoutMinutes, agentHardTimeoutMinutes, preSendScreenFingerprint, claudeTranscriptAnchor, claudeHome }) {
|
|
1550
|
+
function withTerminalBridgeState({ conversation, message, requestText, startedAt, agentTimeoutMinutes, agentHardTimeoutMinutes, preSendScreenFingerprint, codexRolloutAcceptanceAnchor, claudeTranscriptAnchor, claudeHome }) {
|
|
1344
1551
|
const nativeTakeover = isRecord(conversation.native_session_takeover)
|
|
1345
1552
|
? conversation.native_session_takeover
|
|
1346
1553
|
: {};
|
|
@@ -1354,6 +1561,7 @@ function withTerminalBridgeState({ conversation, message, requestText, startedAt
|
|
|
1354
1561
|
terminal_bridge_request_text: requestText,
|
|
1355
1562
|
terminal_bridge_request_hash: terminalBridgeRequestFingerprint(requestText),
|
|
1356
1563
|
terminal_bridge_pre_send_screen_fingerprint: preSendScreenFingerprint,
|
|
1564
|
+
codex_rollout_acceptance_anchor: codexRolloutAcceptanceAnchor,
|
|
1357
1565
|
claude_transcript_anchor: claudeTranscriptAnchor,
|
|
1358
1566
|
claude_home: claudeHome,
|
|
1359
1567
|
terminal_bridge_completion_claim: undefined,
|
|
@@ -1369,40 +1577,653 @@ function withTerminalBridgeState({ conversation, message, requestText, startedAt
|
|
|
1369
1577
|
updated_at: startedAt
|
|
1370
1578
|
};
|
|
1371
1579
|
}
|
|
1372
|
-
function withTerminalBridgeSubmission({ conversation, messageId, requestText, status, preparedAt, submittedAt, uncertainAt, abortedAt, error }) {
|
|
1580
|
+
function withTerminalBridgeSubmission({ conversation, messageId, messageType, messageBody, requestText, status, preparedAt, textInjectedAt, enterDispatchedAt, agentAcceptedAt, notAcceptedAt, submittedAt, uncertainAt, abortedAt, error, acceptanceEvidence, lastProvenStage, safeToRetry }) {
|
|
1373
1581
|
const nativeTakeover = isRecord(conversation.native_session_takeover)
|
|
1374
1582
|
? conversation.native_session_takeover
|
|
1375
1583
|
: {};
|
|
1376
1584
|
const previousSubmission = isRecord(nativeTakeover.terminal_bridge_submission)
|
|
1377
1585
|
? nativeTakeover.terminal_bridge_submission
|
|
1378
1586
|
: undefined;
|
|
1379
|
-
const
|
|
1587
|
+
const receiptsValue = nativeTakeover.terminal_bridge_submission_receipts;
|
|
1588
|
+
if (receiptsValue !== undefined && !Array.isArray(receiptsValue)) {
|
|
1589
|
+
throw new Error("terminal submission receipt history is malformed");
|
|
1590
|
+
}
|
|
1591
|
+
const storedReceipts = (Array.isArray(receiptsValue) ? receiptsValue : [])
|
|
1592
|
+
.map((value) => {
|
|
1593
|
+
if (!isRecord(value) || !stringValue(value.message_id)) {
|
|
1594
|
+
throw new Error("terminal submission receipt history is malformed");
|
|
1595
|
+
}
|
|
1596
|
+
return value;
|
|
1597
|
+
});
|
|
1598
|
+
const storedReceiptIds = new Set();
|
|
1599
|
+
for (const receipt of storedReceipts) {
|
|
1600
|
+
const receiptId = String(receipt.message_id);
|
|
1601
|
+
if (storedReceiptIds.has(receiptId)) {
|
|
1602
|
+
throw new Error(`terminal submission receipt ${receiptId} is duplicated`);
|
|
1603
|
+
}
|
|
1604
|
+
storedReceiptIds.add(receiptId);
|
|
1605
|
+
}
|
|
1606
|
+
// A v0.10.1 Turn has only the latest singleton receipt. Seed it into the
|
|
1607
|
+
// append-only history before recording a later response so upgrading cannot
|
|
1608
|
+
// erase the only proof that an older message was already dispatched.
|
|
1609
|
+
const previousSubmissionId = stringValue(previousSubmission?.message_id);
|
|
1610
|
+
const previousReceipts = previousSubmissionId &&
|
|
1611
|
+
!storedReceiptIds.has(previousSubmissionId)
|
|
1612
|
+
? [...storedReceipts, previousSubmission]
|
|
1613
|
+
: storedReceipts;
|
|
1614
|
+
const matchingReceipts = previousReceipts.filter((receipt) => stringValue(receipt.message_id) === messageId);
|
|
1615
|
+
if (matchingReceipts.length > 1) {
|
|
1616
|
+
throw new Error(`terminal submission receipt ${messageId} is duplicated`);
|
|
1617
|
+
}
|
|
1618
|
+
const previousReceipt = matchingReceipts[0];
|
|
1619
|
+
const previousGenerationSubmission = stringValue(previousSubmission?.message_id) === messageId
|
|
1620
|
+
? previousSubmission
|
|
1621
|
+
: undefined;
|
|
1622
|
+
if (previousReceipt &&
|
|
1623
|
+
previousGenerationSubmission &&
|
|
1624
|
+
canonicalJson(previousReceipt) !== canonicalJson(previousGenerationSubmission)) {
|
|
1625
|
+
throw new Error(`terminal submission receipt ${messageId} conflicts with its current generation`);
|
|
1626
|
+
}
|
|
1627
|
+
const durableMessageType = messageType ??
|
|
1628
|
+
(stringValue(previousReceipt?.message_type) === "answer"
|
|
1629
|
+
? "answer"
|
|
1630
|
+
: stringValue(previousReceipt?.message_type) === "task"
|
|
1631
|
+
? "task"
|
|
1632
|
+
: stringValue(previousGenerationSubmission?.message_type) === "answer"
|
|
1633
|
+
? "answer"
|
|
1634
|
+
: stringValue(previousGenerationSubmission?.message_type) === "task"
|
|
1635
|
+
? "task"
|
|
1636
|
+
: undefined);
|
|
1637
|
+
const messageBodyHash = messageBody !== undefined
|
|
1638
|
+
? createHash("sha256").update(messageBody).digest("hex")
|
|
1639
|
+
: stringValue(previousReceipt?.message_body_hash) ??
|
|
1640
|
+
stringValue(previousGenerationSubmission?.message_body_hash);
|
|
1641
|
+
const previousDispatcherPid = Number(previousGenerationSubmission?.dispatcher_pid);
|
|
1380
1642
|
const dispatcherPid = status === "prepared" ||
|
|
1381
1643
|
!Number.isSafeInteger(previousDispatcherPid) ||
|
|
1382
1644
|
previousDispatcherPid <= 1
|
|
1383
1645
|
? process.pid
|
|
1384
1646
|
: previousDispatcherPid;
|
|
1647
|
+
const provenStage = lastProvenStage ?? terminalSubmissionLastProvenStage(status, stringValue(previousGenerationSubmission?.last_proven_stage));
|
|
1648
|
+
const validatedAcceptanceEvidence = status === "agent_accepted"
|
|
1649
|
+
? terminalAcceptanceEvidenceForConversation(conversation, requestText, acceptanceEvidence)
|
|
1650
|
+
: undefined;
|
|
1651
|
+
const storedControl = terminalControlFromTakeover(nativeTakeover);
|
|
1652
|
+
const requestHash = terminalBridgeRequestFingerprint(requestText);
|
|
1653
|
+
const candidateImmutableReceiptFields = {
|
|
1654
|
+
session_id: sessionIdForConversation(conversation),
|
|
1655
|
+
turn_id: turnIdForConversation(conversation),
|
|
1656
|
+
message_id: messageId,
|
|
1657
|
+
binding_id: stringValue(conversation.terminal_binding_id),
|
|
1658
|
+
binding_generation: Number.isSafeInteger(Number(conversation.terminal_binding_generation))
|
|
1659
|
+
? Number(conversation.terminal_binding_generation)
|
|
1660
|
+
: undefined,
|
|
1661
|
+
...(durableMessageType ? { message_type: durableMessageType } : {}),
|
|
1662
|
+
...(messageBodyHash ? { message_body_hash: messageBodyHash } : {}),
|
|
1663
|
+
request_hash: requestHash,
|
|
1664
|
+
executor_kind: executorForConversation(conversation).kind,
|
|
1665
|
+
openclaw_session: conversation.openclaw_session,
|
|
1666
|
+
store_dir: managedSessionStoreDirForConversation(conversation),
|
|
1667
|
+
native_thread_id: stringValue(conversation.native_thread_id) ??
|
|
1668
|
+
stringValue(nativeTakeover.terminal_agent_session_id) ??
|
|
1669
|
+
stringValue(nativeTakeover.terminal_agent_expected_session_id),
|
|
1670
|
+
terminal_target: storedControl?.target,
|
|
1671
|
+
terminal_socket_path: storedControl?.socketPath ?? null,
|
|
1672
|
+
terminal_pane_pid: storedControl?.panePid
|
|
1673
|
+
};
|
|
1674
|
+
const immutableReceiptFields = Object.fromEntries(Object.entries(candidateImmutableReceiptFields).map(([key, value]) => [
|
|
1675
|
+
key,
|
|
1676
|
+
value === undefined && previousReceipt?.[key] !== undefined
|
|
1677
|
+
? previousReceipt[key]
|
|
1678
|
+
: value
|
|
1679
|
+
]));
|
|
1680
|
+
if (previousReceipt) {
|
|
1681
|
+
for (const [key, value] of Object.entries(candidateImmutableReceiptFields)) {
|
|
1682
|
+
if (previousReceipt[key] !== undefined &&
|
|
1683
|
+
value !== undefined &&
|
|
1684
|
+
String(previousReceipt[key]) !== String(value)) {
|
|
1685
|
+
throw new Error(`terminal submission receipt ${messageId} changed immutable ${key}`);
|
|
1686
|
+
}
|
|
1687
|
+
}
|
|
1688
|
+
}
|
|
1689
|
+
const nextSubmission = {
|
|
1690
|
+
status,
|
|
1691
|
+
...immutableReceiptFields,
|
|
1692
|
+
prepared_at: preparedAt,
|
|
1693
|
+
dispatcher_pid: dispatcherPid,
|
|
1694
|
+
last_proven_stage: provenStage,
|
|
1695
|
+
...(textInjectedAt ? { text_injected_at: textInjectedAt } : {}),
|
|
1696
|
+
...(enterDispatchedAt ? { enter_dispatched_at: enterDispatchedAt } : {}),
|
|
1697
|
+
...(agentAcceptedAt ? { agent_accepted_at: agentAcceptedAt } : {}),
|
|
1698
|
+
...(notAcceptedAt ? { not_accepted_at: notAcceptedAt } : {}),
|
|
1699
|
+
...(submittedAt ? { submitted_at: submittedAt } : {}),
|
|
1700
|
+
...(uncertainAt ? { uncertain_at: uncertainAt } : {}),
|
|
1701
|
+
...(abortedAt ? { aborted_at: abortedAt } : {}),
|
|
1702
|
+
...(error ? { error: textSummary(error) } : {}),
|
|
1703
|
+
...(safeToRetry !== undefined ? { safe_to_retry: safeToRetry } : {}),
|
|
1704
|
+
...(validatedAcceptanceEvidence
|
|
1705
|
+
? { acceptance_evidence: validatedAcceptanceEvidence }
|
|
1706
|
+
: {})
|
|
1707
|
+
};
|
|
1708
|
+
const nextReceipts = previousReceipt
|
|
1709
|
+
? previousReceipts.map((receipt) => stringValue(receipt.message_id) === messageId
|
|
1710
|
+
? nextSubmission
|
|
1711
|
+
: receipt)
|
|
1712
|
+
: [...previousReceipts, nextSubmission];
|
|
1385
1713
|
return {
|
|
1386
1714
|
...conversation,
|
|
1387
1715
|
native_session_takeover: {
|
|
1388
1716
|
...nativeTakeover,
|
|
1389
|
-
terminal_bridge_submission:
|
|
1390
|
-
|
|
1391
|
-
session_id: sessionIdForConversation(conversation),
|
|
1392
|
-
turn_id: turnIdForConversation(conversation),
|
|
1393
|
-
message_id: messageId,
|
|
1394
|
-
request_hash: terminalBridgeRequestFingerprint(requestText),
|
|
1395
|
-
prepared_at: preparedAt,
|
|
1396
|
-
dispatcher_pid: dispatcherPid,
|
|
1397
|
-
...(submittedAt ? { submitted_at: submittedAt } : {}),
|
|
1398
|
-
...(uncertainAt ? { uncertain_at: uncertainAt } : {}),
|
|
1399
|
-
...(abortedAt ? { aborted_at: abortedAt } : {}),
|
|
1400
|
-
...(error ? { error: textSummary(error) } : {})
|
|
1401
|
-
}
|
|
1717
|
+
terminal_bridge_submission: nextSubmission,
|
|
1718
|
+
terminal_bridge_submission_receipts: nextReceipts
|
|
1402
1719
|
},
|
|
1403
|
-
updated_at:
|
|
1720
|
+
updated_at: agentAcceptedAt ?? notAcceptedAt ?? uncertainAt ?? abortedAt ??
|
|
1721
|
+
enterDispatchedAt ?? textInjectedAt ?? submittedAt ?? preparedAt
|
|
1722
|
+
};
|
|
1723
|
+
}
|
|
1724
|
+
function terminalAcceptanceEvidenceForConversation(conversation, requestText, evidence) {
|
|
1725
|
+
return validateTerminalSubmissionAcceptanceEvidence(evidence, terminalAcceptanceEvidenceExpectation(conversation, requestText));
|
|
1726
|
+
}
|
|
1727
|
+
function terminalAcceptanceEvidenceExpectation(conversation, requestText) {
|
|
1728
|
+
const nativeTakeover = isRecord(conversation.native_session_takeover)
|
|
1729
|
+
? conversation.native_session_takeover
|
|
1730
|
+
: undefined;
|
|
1731
|
+
const executor = executorForConversation(conversation).kind;
|
|
1732
|
+
const nativeThreadId = stringValue(conversation.native_thread_id) ??
|
|
1733
|
+
stringValue(nativeTakeover?.terminal_agent_session_id) ??
|
|
1734
|
+
stringValue(nativeTakeover?.terminal_agent_expected_session_id);
|
|
1735
|
+
if (!nativeThreadId) {
|
|
1736
|
+
throw new Error("native acceptance evidence cannot be bound without an exact native thread");
|
|
1737
|
+
}
|
|
1738
|
+
const requestHash = terminalBridgeRequestFingerprint(requestText);
|
|
1739
|
+
if (!requestHash) {
|
|
1740
|
+
throw new Error("native acceptance evidence has no exact request hash");
|
|
1741
|
+
}
|
|
1742
|
+
return {
|
|
1743
|
+
source: executor === "codex" ? "codex_rollout" : "claude_transcript",
|
|
1744
|
+
nativeThreadId,
|
|
1745
|
+
requestHash
|
|
1404
1746
|
};
|
|
1405
1747
|
}
|
|
1748
|
+
function replayExactActiveTerminalSubmission({ options, terminalControl, requestText, expectedStoreDir, expectedSessionId, expectedTurnId, expectedMessageType = "task", expectedStatePath }) {
|
|
1749
|
+
const messageId = stringValue(options.messageId);
|
|
1750
|
+
if (!messageId) {
|
|
1751
|
+
return false;
|
|
1752
|
+
}
|
|
1753
|
+
const terminalPayload = terminalSubmissionPayload(requestText);
|
|
1754
|
+
const requestHash = terminalBridgeRequestFingerprint(terminalPayload);
|
|
1755
|
+
if (!requestHash) {
|
|
1756
|
+
return false;
|
|
1757
|
+
}
|
|
1758
|
+
const loadedLedger = loadTerminalBridgeDispatchLedger(terminalControl);
|
|
1759
|
+
const ledgerReceiptMatches = terminalLedgerReceiptHistory(loadedLedger)
|
|
1760
|
+
.filter((receipt) => stringValue(receipt.message_id) === messageId);
|
|
1761
|
+
if (ledgerReceiptMatches.length > 1) {
|
|
1762
|
+
throw new Error(`terminal idempotency key ${messageId} has multiple terminal-ledger receipts`);
|
|
1763
|
+
}
|
|
1764
|
+
const ledgerReceipt = ledgerReceiptMatches[0];
|
|
1765
|
+
const loadedLedgerControl = isRecord(loadedLedger?.terminal_control)
|
|
1766
|
+
? loadedLedger.terminal_control
|
|
1767
|
+
: undefined;
|
|
1768
|
+
const loadedLedgerMessageId = stringValue(loadedLedger?.message_id);
|
|
1769
|
+
const loadedLedgerStoreDir = stringValue(loadedLedger?.store_dir);
|
|
1770
|
+
const loadedLedgerStatePath = stringValue(loadedLedger?.state_path);
|
|
1771
|
+
const loadedLedgerOwner = loadedLedger && loadedLedgerMessageId === messageId
|
|
1772
|
+
? loadTerminalDispatchLedgerOwner(loadedLedger)
|
|
1773
|
+
: undefined;
|
|
1774
|
+
const ledgerReceiptControl = isRecord(ledgerReceipt?.terminal_control)
|
|
1775
|
+
? ledgerReceipt.terminal_control
|
|
1776
|
+
: undefined;
|
|
1777
|
+
const expectedMessageBodyHash = createHash("sha256")
|
|
1778
|
+
.update(requestText)
|
|
1779
|
+
.digest("hex");
|
|
1780
|
+
if (ledgerReceipt &&
|
|
1781
|
+
!(ledgerReceipt.status === "aborted" && ledgerReceipt.safe_to_retry === true) &&
|
|
1782
|
+
(stringValue(ledgerReceiptControl?.target) !== terminalControl.target ||
|
|
1783
|
+
(stringValue(ledgerReceiptControl?.socket_path) ?? undefined) !==
|
|
1784
|
+
terminalControl.socketPath ||
|
|
1785
|
+
Number(ledgerReceiptControl?.pane_pid) !== Number(terminalControl.panePid) ||
|
|
1786
|
+
(stringValue(ledgerReceipt.store_dir) !== undefined &&
|
|
1787
|
+
path.resolve(String(ledgerReceipt.store_dir)) !==
|
|
1788
|
+
path.resolve(expectedStoreDir)) ||
|
|
1789
|
+
(expectedSessionId && stringValue(ledgerReceipt.session_id) !== undefined &&
|
|
1790
|
+
stringValue(ledgerReceipt.session_id) !== expectedSessionId) ||
|
|
1791
|
+
(expectedTurnId && stringValue(ledgerReceipt.turn_id) !== undefined &&
|
|
1792
|
+
stringValue(ledgerReceipt.turn_id) !== expectedTurnId) ||
|
|
1793
|
+
(expectedStatePath && stringValue(ledgerReceipt.state_path) !== undefined &&
|
|
1794
|
+
!sameCanonicalStatePath(ledgerReceipt.state_path, expectedStatePath)) ||
|
|
1795
|
+
(stringValue(ledgerReceipt.message_type) !== undefined &&
|
|
1796
|
+
stringValue(ledgerReceipt.message_type) !== expectedMessageType) ||
|
|
1797
|
+
(stringValue(ledgerReceipt.message_body_hash) !== undefined &&
|
|
1798
|
+
stringValue(ledgerReceipt.message_body_hash) !== expectedMessageBodyHash) ||
|
|
1799
|
+
(stringValue(ledgerReceipt.request_hash) !== undefined &&
|
|
1800
|
+
stringValue(ledgerReceipt.request_hash) !== requestHash) ||
|
|
1801
|
+
(stringValue(options.openclawSession) !== undefined &&
|
|
1802
|
+
stringValue(ledgerReceipt.openclaw_session) !== undefined &&
|
|
1803
|
+
stringValue(ledgerReceipt.openclaw_session) !==
|
|
1804
|
+
stringValue(options.openclawSession)))) {
|
|
1805
|
+
throw new Error(`terminal idempotency key ${messageId} does not match its durable ` +
|
|
1806
|
+
"terminal receipt; no terminal input was sent");
|
|
1807
|
+
}
|
|
1808
|
+
if (loadedLedger &&
|
|
1809
|
+
loadedLedgerMessageId === messageId &&
|
|
1810
|
+
(stringValue(loadedLedgerControl?.target) !== terminalControl.target ||
|
|
1811
|
+
(stringValue(loadedLedgerControl?.socket_path) ?? undefined) !==
|
|
1812
|
+
terminalControl.socketPath ||
|
|
1813
|
+
Number(loadedLedgerControl?.pane_pid) !== Number(terminalControl.panePid) ||
|
|
1814
|
+
(loadedLedgerStoreDir !== undefined &&
|
|
1815
|
+
path.resolve(loadedLedgerStoreDir) !== path.resolve(expectedStoreDir)) ||
|
|
1816
|
+
(expectedSessionId && stringValue(loadedLedger.session_id) !== undefined &&
|
|
1817
|
+
stringValue(loadedLedger.session_id) !== expectedSessionId) ||
|
|
1818
|
+
(expectedTurnId && stringValue(loadedLedger.turn_id) !== undefined &&
|
|
1819
|
+
stringValue(loadedLedger.turn_id) !== expectedTurnId) ||
|
|
1820
|
+
(expectedStatePath && loadedLedgerStatePath !== undefined &&
|
|
1821
|
+
!sameCanonicalStatePath(loadedLedgerStatePath, expectedStatePath)) ||
|
|
1822
|
+
(stringValue(loadedLedger.message_type) !== undefined &&
|
|
1823
|
+
stringValue(loadedLedger.message_type) !== expectedMessageType) ||
|
|
1824
|
+
(stringValue(loadedLedger.request_hash) !== undefined &&
|
|
1825
|
+
stringValue(loadedLedger.request_hash) !== requestHash) ||
|
|
1826
|
+
(stringValue(options.openclawSession) !== undefined &&
|
|
1827
|
+
loadedLedgerOwner !== undefined &&
|
|
1828
|
+
loadedLedgerOwner.openclaw_session !==
|
|
1829
|
+
stringValue(options.openclawSession)))) {
|
|
1830
|
+
throw new Error(`terminal idempotency key ${messageId} does not match its original ` +
|
|
1831
|
+
"Store, Session, Turn, OpenClaw session, message, or tmux pane binding; " +
|
|
1832
|
+
"no terminal input was sent");
|
|
1833
|
+
}
|
|
1834
|
+
if (replayExactStoredTerminalSubmission({
|
|
1835
|
+
options,
|
|
1836
|
+
terminalControl,
|
|
1837
|
+
requestText,
|
|
1838
|
+
requestHash,
|
|
1839
|
+
messageId,
|
|
1840
|
+
expectedStoreDir,
|
|
1841
|
+
expectedSessionId,
|
|
1842
|
+
expectedTurnId,
|
|
1843
|
+
expectedMessageType,
|
|
1844
|
+
expectedStatePath
|
|
1845
|
+
})) {
|
|
1846
|
+
return true;
|
|
1847
|
+
}
|
|
1848
|
+
if (ledgerReceipt &&
|
|
1849
|
+
(loadedLedgerMessageId !== messageId ||
|
|
1850
|
+
!["submitted", "enter_dispatched", "agent_accepted"].includes(String(ledgerReceipt.status)) ||
|
|
1851
|
+
!["submitted", "enter_dispatched", "agent_accepted"].includes(String(loadedLedger?.status)))) {
|
|
1852
|
+
throw new Error(`terminal idempotency key ${messageId} already has durable ` +
|
|
1853
|
+
`${String(ledgerReceipt.status)} proof and cannot be dispatched again; ` +
|
|
1854
|
+
"no terminal input was sent");
|
|
1855
|
+
}
|
|
1856
|
+
const incarnationLedger = resolveTerminalDispatchLedgerPaneIncarnation(terminalControl, loadedLedger);
|
|
1857
|
+
const ledger = reconcilePreparedTerminalDispatchLedger(terminalControl, incarnationLedger);
|
|
1858
|
+
const ledgerControl = isRecord(ledger?.terminal_control)
|
|
1859
|
+
? ledger.terminal_control
|
|
1860
|
+
: undefined;
|
|
1861
|
+
if (!ledger ||
|
|
1862
|
+
!["submitted", "enter_dispatched", "agent_accepted"].includes(String(ledger.status)) ||
|
|
1863
|
+
stringValue(ledgerControl?.target) !== terminalControl.target ||
|
|
1864
|
+
(stringValue(ledgerControl?.socket_path) ?? undefined) !==
|
|
1865
|
+
terminalControl.socketPath ||
|
|
1866
|
+
Number(ledgerControl?.pane_pid) !== Number(terminalControl.panePid) ||
|
|
1867
|
+
stringValue(ledger.message_id) !== messageId ||
|
|
1868
|
+
(stringValue(ledger.message_type) !== undefined &&
|
|
1869
|
+
stringValue(ledger.message_type) !== expectedMessageType) ||
|
|
1870
|
+
stringValue(ledger.request_hash) !== requestHash) {
|
|
1871
|
+
return false;
|
|
1872
|
+
}
|
|
1873
|
+
const owner = loadTerminalDispatchLedgerOwner(ledger);
|
|
1874
|
+
const ledgerStoreDir = stringValue(ledger.store_dir);
|
|
1875
|
+
const ownerStoreDir = owner
|
|
1876
|
+
? managedSessionStoreDirForConversation(owner)
|
|
1877
|
+
: undefined;
|
|
1878
|
+
if (!owner ||
|
|
1879
|
+
TERMINAL_DISPATCH_RELEASE_STATUSES.has(owner.status) ||
|
|
1880
|
+
!ledgerStoreDir ||
|
|
1881
|
+
!ownerStoreDir ||
|
|
1882
|
+
path.resolve(ledgerStoreDir) !== path.resolve(expectedStoreDir) ||
|
|
1883
|
+
path.resolve(ownerStoreDir) !== path.resolve(expectedStoreDir) ||
|
|
1884
|
+
(expectedSessionId && sessionIdForConversation(owner) !== expectedSessionId) ||
|
|
1885
|
+
(expectedTurnId && turnIdForConversation(owner) !== expectedTurnId) ||
|
|
1886
|
+
(expectedStatePath && !sameCanonicalStatePath(ledger.state_path, expectedStatePath)) ||
|
|
1887
|
+
(stringValue(options.openclawSession) &&
|
|
1888
|
+
owner.openclaw_session !== stringValue(options.openclawSession))) {
|
|
1889
|
+
return false;
|
|
1890
|
+
}
|
|
1891
|
+
const submission = terminalBridgeSubmission(owner);
|
|
1892
|
+
const nativeTakeover = isRecord(owner.native_session_takeover)
|
|
1893
|
+
? owner.native_session_takeover
|
|
1894
|
+
: undefined;
|
|
1895
|
+
if (stringValue(nativeTakeover?.terminal_bridge_message_id) !== messageId ||
|
|
1896
|
+
stringValue(submission?.message_id) !== messageId ||
|
|
1897
|
+
(stringValue(submission?.message_type) !== undefined &&
|
|
1898
|
+
stringValue(submission?.message_type) !== expectedMessageType) ||
|
|
1899
|
+
stringValue(submission?.request_hash) !== requestHash) {
|
|
1900
|
+
return false;
|
|
1901
|
+
}
|
|
1902
|
+
const executor = executorForConversation(owner);
|
|
1903
|
+
const nativeThreadId = stringValue(owner.native_thread_id) ??
|
|
1904
|
+
stringValue(nativeTakeover?.terminal_agent_session_id) ??
|
|
1905
|
+
stringValue(nativeTakeover?.terminal_agent_expected_session_id) ??
|
|
1906
|
+
sessionIdForConversation(owner);
|
|
1907
|
+
const proofLevel = String(ledger.status);
|
|
1908
|
+
const replayReceipt = terminalSubmissionReplayReceipt({
|
|
1909
|
+
proofLevel,
|
|
1910
|
+
evidence: ledger.acceptance_evidence,
|
|
1911
|
+
expected: {
|
|
1912
|
+
source: executor.kind === "codex"
|
|
1913
|
+
? "codex_rollout"
|
|
1914
|
+
: "claude_transcript",
|
|
1915
|
+
nativeThreadId,
|
|
1916
|
+
requestHash
|
|
1917
|
+
}
|
|
1918
|
+
});
|
|
1919
|
+
const logPath = stringValue(ledger.event_log_path) ??
|
|
1920
|
+
stringValue(owner.event_log_path);
|
|
1921
|
+
let loggedMessage;
|
|
1922
|
+
if (logPath) {
|
|
1923
|
+
try {
|
|
1924
|
+
loggedMessage = readNdjsonLog(logPath).find((event) => isRecord(event.message) && event.message.id === messageId)?.message;
|
|
1925
|
+
}
|
|
1926
|
+
catch {
|
|
1927
|
+
loggedMessage = undefined;
|
|
1928
|
+
}
|
|
1929
|
+
}
|
|
1930
|
+
if (isRecord(loggedMessage) &&
|
|
1931
|
+
(loggedMessage.type !== expectedMessageType ||
|
|
1932
|
+
loggedMessage.body !== requestText ||
|
|
1933
|
+
loggedMessage.conversation_id !== owner.conversation_id ||
|
|
1934
|
+
loggedMessage.session_id !== sessionIdForConversation(owner) ||
|
|
1935
|
+
loggedMessage.turn_id !== turnIdForConversation(owner))) {
|
|
1936
|
+
return false;
|
|
1937
|
+
}
|
|
1938
|
+
const durableMessageType = stringValue(ledger.message_type) ??
|
|
1939
|
+
stringValue(submission?.message_type) ??
|
|
1940
|
+
(isRecord(loggedMessage) ? stringValue(loggedMessage.type) : undefined);
|
|
1941
|
+
if (durableMessageType !== expectedMessageType) {
|
|
1942
|
+
return false;
|
|
1943
|
+
}
|
|
1944
|
+
const replayedMessage = isRecord(loggedMessage)
|
|
1945
|
+
? loggedMessage
|
|
1946
|
+
: createMessage({
|
|
1947
|
+
conversation: owner,
|
|
1948
|
+
id: messageId,
|
|
1949
|
+
from: "openclaw",
|
|
1950
|
+
to: executor.actor,
|
|
1951
|
+
type: expectedMessageType,
|
|
1952
|
+
body: requestText,
|
|
1953
|
+
metadata: {
|
|
1954
|
+
executor_kind: executor.kind,
|
|
1955
|
+
executor_session: executor.session
|
|
1956
|
+
}
|
|
1957
|
+
});
|
|
1958
|
+
const acceptanceInvalid = replayReceipt.submission_outcome === "uncertain";
|
|
1959
|
+
printJson({
|
|
1960
|
+
session_id: sessionIdForConversation(owner),
|
|
1961
|
+
turn_id: turnIdForConversation(owner),
|
|
1962
|
+
conversation: owner,
|
|
1963
|
+
message: replayedMessage,
|
|
1964
|
+
delivered: replayReceipt.delivered,
|
|
1965
|
+
status: replayReceipt.status,
|
|
1966
|
+
submission_outcome: replayReceipt.submission_outcome,
|
|
1967
|
+
background: true,
|
|
1968
|
+
callback_expected: !acceptanceInvalid && Boolean(owner.gateway_method ?? ledger.callback_expected),
|
|
1969
|
+
terminal_control: terminalControl,
|
|
1970
|
+
executor,
|
|
1971
|
+
replayed: replayReceipt.replayed,
|
|
1972
|
+
delivery_receipt: replayReceipt.delivery_receipt,
|
|
1973
|
+
...(replayReceipt.do_not_retry
|
|
1974
|
+
? { do_not_retry: replayReceipt.do_not_retry }
|
|
1975
|
+
: {}),
|
|
1976
|
+
reason: replayReceipt.delivered
|
|
1977
|
+
? "AKK replayed the durable native acceptance receipt and sent no additional tmux input."
|
|
1978
|
+
: acceptanceInvalid
|
|
1979
|
+
? "AKK replayed an invalid native acceptance receipt as uncertain and sent no additional tmux input."
|
|
1980
|
+
: "AKK replayed the original transport proof without upgrading it and sent no additional tmux input.",
|
|
1981
|
+
openclaw_next_action: replayReceipt.delivered
|
|
1982
|
+
? openClawYieldNextAction({
|
|
1983
|
+
conversationId: owner.conversation_id,
|
|
1984
|
+
sessionId: sessionIdForConversation(owner),
|
|
1985
|
+
turnId: turnIdForConversation(owner),
|
|
1986
|
+
source: "terminal_control",
|
|
1987
|
+
callbackExpected: Boolean(owner.gateway_method ?? ledger.callback_expected)
|
|
1988
|
+
})
|
|
1989
|
+
: {
|
|
1990
|
+
action: "inspect",
|
|
1991
|
+
conversation_id: owner.conversation_id,
|
|
1992
|
+
session_id: sessionIdForConversation(owner),
|
|
1993
|
+
turn_id: turnIdForConversation(owner),
|
|
1994
|
+
do_not_retry: true,
|
|
1995
|
+
reason: acceptanceInvalid
|
|
1996
|
+
? "Stored native acceptance evidence is invalid."
|
|
1997
|
+
: "Only terminal transport is proven."
|
|
1998
|
+
}
|
|
1999
|
+
});
|
|
2000
|
+
return true;
|
|
2001
|
+
}
|
|
2002
|
+
function validateStoredTerminalSubmissionMatch({ owner, receipt, options, terminalControl, requestText, requestHash, expectedStoreDir, expectedSessionId, expectedTurnId, expectedMessageType, expectedStatePath }) {
|
|
2003
|
+
const messageId = required(stringValue(receipt.message_id), "stored terminal receipt message id is required");
|
|
2004
|
+
const ownerStoreDir = managedSessionStoreDirForConversation(owner);
|
|
2005
|
+
const nativeTakeover = isRecord(owner.native_session_takeover)
|
|
2006
|
+
? owner.native_session_takeover
|
|
2007
|
+
: undefined;
|
|
2008
|
+
const storedControl = terminalControlFromTakeover(nativeTakeover);
|
|
2009
|
+
const receiptStoreDir = stringValue(receipt.store_dir) ?? ownerStoreDir;
|
|
2010
|
+
const receiptSessionId = stringValue(receipt.session_id) ??
|
|
2011
|
+
sessionIdForConversation(owner);
|
|
2012
|
+
const receiptTurnId = stringValue(receipt.turn_id) ??
|
|
2013
|
+
turnIdForConversation(owner);
|
|
2014
|
+
const receiptOpenClawSession = stringValue(receipt.openclaw_session) ??
|
|
2015
|
+
owner.openclaw_session;
|
|
2016
|
+
const receiptTarget = stringValue(receipt.terminal_target) ??
|
|
2017
|
+
storedControl?.target;
|
|
2018
|
+
const receiptSocketPath = receipt.terminal_socket_path === null
|
|
2019
|
+
? undefined
|
|
2020
|
+
: stringValue(receipt.terminal_socket_path) ?? storedControl?.socketPath;
|
|
2021
|
+
const receiptPanePid = Number(receipt.terminal_pane_pid ?? storedControl?.panePid);
|
|
2022
|
+
const requestedOpenClawSession = stringValue(options.openclawSession);
|
|
2023
|
+
if (!ownerStoreDir ||
|
|
2024
|
+
!receiptStoreDir ||
|
|
2025
|
+
path.resolve(ownerStoreDir) !== path.resolve(expectedStoreDir) ||
|
|
2026
|
+
path.resolve(receiptStoreDir) !== path.resolve(expectedStoreDir) ||
|
|
2027
|
+
!storedControl ||
|
|
2028
|
+
receiptTarget !== terminalControl.target ||
|
|
2029
|
+
receiptSocketPath !== terminalControl.socketPath ||
|
|
2030
|
+
receiptPanePid !== Number(terminalControl.panePid) ||
|
|
2031
|
+
receiptSessionId !== sessionIdForConversation(owner) ||
|
|
2032
|
+
receiptTurnId !== turnIdForConversation(owner) ||
|
|
2033
|
+
(expectedSessionId && receiptSessionId !== expectedSessionId) ||
|
|
2034
|
+
(expectedTurnId && receiptTurnId !== expectedTurnId) ||
|
|
2035
|
+
(expectedStatePath && !sameCanonicalStatePath(owner.state_path, expectedStatePath)) ||
|
|
2036
|
+
(requestedOpenClawSession && receiptOpenClawSession !== requestedOpenClawSession) ||
|
|
2037
|
+
stringValue(receipt.request_hash) !== requestHash ||
|
|
2038
|
+
(stringValue(receipt.executor_kind) !== undefined &&
|
|
2039
|
+
stringValue(receipt.executor_kind) !== executorForConversation(owner).kind)) {
|
|
2040
|
+
throw new Error(`terminal idempotency key ${messageId} does not match its original ` +
|
|
2041
|
+
"Store, Session, Turn, OpenClaw session, or tmux pane binding; no terminal input was sent");
|
|
2042
|
+
}
|
|
2043
|
+
const logPath = stringValue(owner.event_log_path);
|
|
2044
|
+
let loggedMessages = [];
|
|
2045
|
+
if (logPath) {
|
|
2046
|
+
try {
|
|
2047
|
+
loggedMessages = readNdjsonLog(logPath)
|
|
2048
|
+
.filter((event) => isRecord(event.message) && event.message.id === messageId)
|
|
2049
|
+
.map((event) => event.message);
|
|
2050
|
+
}
|
|
2051
|
+
catch {
|
|
2052
|
+
loggedMessages = [];
|
|
2053
|
+
}
|
|
2054
|
+
}
|
|
2055
|
+
if (loggedMessages.length > 1) {
|
|
2056
|
+
throw new Error(`terminal idempotency key ${messageId} has duplicate durable messages`);
|
|
2057
|
+
}
|
|
2058
|
+
const loggedMessage = loggedMessages[0];
|
|
2059
|
+
if (isRecord(loggedMessage) &&
|
|
2060
|
+
(loggedMessage.type !== expectedMessageType ||
|
|
2061
|
+
loggedMessage.body !== requestText ||
|
|
2062
|
+
loggedMessage.conversation_id !== owner.conversation_id ||
|
|
2063
|
+
loggedMessage.session_id !== sessionIdForConversation(owner) ||
|
|
2064
|
+
loggedMessage.turn_id !== turnIdForConversation(owner))) {
|
|
2065
|
+
throw new Error(`terminal idempotency key ${messageId} does not match its durable message; no terminal input was sent`);
|
|
2066
|
+
}
|
|
2067
|
+
const durableMessageType = stringValue(receipt.message_type) ??
|
|
2068
|
+
(isRecord(loggedMessage) ? stringValue(loggedMessage.type) : undefined);
|
|
2069
|
+
const durableMessageBodyHash = stringValue(receipt.message_body_hash) ??
|
|
2070
|
+
(isRecord(loggedMessage) && typeof loggedMessage.body === "string"
|
|
2071
|
+
? createHash("sha256").update(loggedMessage.body).digest("hex")
|
|
2072
|
+
: undefined);
|
|
2073
|
+
const expectedMessageBodyHash = createHash("sha256")
|
|
2074
|
+
.update(requestText)
|
|
2075
|
+
.digest("hex");
|
|
2076
|
+
if (durableMessageType !== expectedMessageType ||
|
|
2077
|
+
durableMessageBodyHash !== expectedMessageBodyHash) {
|
|
2078
|
+
throw new Error(`terminal idempotency key ${messageId} was already used for a different message; no terminal input was sent`);
|
|
2079
|
+
}
|
|
2080
|
+
return loggedMessage;
|
|
2081
|
+
}
|
|
2082
|
+
function replayExactStoredTerminalSubmission({ options, terminalControl, requestText, requestHash, messageId, expectedStoreDir, expectedSessionId, expectedTurnId, expectedMessageType, expectedStatePath }) {
|
|
2083
|
+
const allMatches = listConversations(expectedStoreDir).flatMap((owner) => terminalBridgeSubmissionReceipts(owner)
|
|
2084
|
+
.filter((receipt) => stringValue(receipt.message_id) === messageId)
|
|
2085
|
+
.map((receipt) => ({ owner, receipt })));
|
|
2086
|
+
const validatedMatches = allMatches.map((match) => ({
|
|
2087
|
+
...match,
|
|
2088
|
+
loggedMessage: validateStoredTerminalSubmissionMatch({
|
|
2089
|
+
...match,
|
|
2090
|
+
options,
|
|
2091
|
+
terminalControl,
|
|
2092
|
+
requestText,
|
|
2093
|
+
requestHash,
|
|
2094
|
+
expectedStoreDir,
|
|
2095
|
+
expectedSessionId,
|
|
2096
|
+
expectedTurnId,
|
|
2097
|
+
expectedMessageType,
|
|
2098
|
+
expectedStatePath
|
|
2099
|
+
})
|
|
2100
|
+
}));
|
|
2101
|
+
const matches = validatedMatches.filter(({ receipt }) => !(receipt.status === "aborted" && receipt.safe_to_retry === true));
|
|
2102
|
+
if (matches.length > 1) {
|
|
2103
|
+
throw new Error(`terminal idempotency key ${messageId} has multiple durable receipts in one Store`);
|
|
2104
|
+
}
|
|
2105
|
+
const match = matches[0];
|
|
2106
|
+
if (!match) {
|
|
2107
|
+
for (const { owner, receipt } of validatedMatches) {
|
|
2108
|
+
assertSafeAbortedTerminalRetryBinding({
|
|
2109
|
+
owner,
|
|
2110
|
+
receipt,
|
|
2111
|
+
storeDir: expectedStoreDir,
|
|
2112
|
+
terminalControl,
|
|
2113
|
+
messageId
|
|
2114
|
+
});
|
|
2115
|
+
}
|
|
2116
|
+
// A prepared-stage failure with a restored ledger proves that tmux was
|
|
2117
|
+
// untouched. The same exact id may therefore start a fresh attempt; all
|
|
2118
|
+
// immutable boundaries above were still validated before allowing it.
|
|
2119
|
+
return false;
|
|
2120
|
+
}
|
|
2121
|
+
const { owner, receipt, loggedMessage } = match;
|
|
2122
|
+
const nativeTakeover = isRecord(owner.native_session_takeover)
|
|
2123
|
+
? owner.native_session_takeover
|
|
2124
|
+
: undefined;
|
|
2125
|
+
const currentSubmission = terminalBridgeSubmission(owner);
|
|
2126
|
+
const isCurrentSubmission = stringValue(currentSubmission?.message_id) === messageId;
|
|
2127
|
+
if (isCurrentSubmission &&
|
|
2128
|
+
!TERMINAL_DISPATCH_RELEASE_STATUSES.has(owner.status)) {
|
|
2129
|
+
return false;
|
|
2130
|
+
}
|
|
2131
|
+
if (!["submitted", "enter_dispatched", "agent_accepted"].includes(String(receipt.status))) {
|
|
2132
|
+
throw new Error(`terminal idempotency key ${messageId} has durable ${String(receipt.status)} ` +
|
|
2133
|
+
"proof and must not be retried; no terminal input was sent");
|
|
2134
|
+
}
|
|
2135
|
+
const executor = executorForConversation(owner);
|
|
2136
|
+
const nativeThreadId = stringValue(receipt.native_thread_id) ??
|
|
2137
|
+
stringValue(owner.native_thread_id) ??
|
|
2138
|
+
stringValue(nativeTakeover?.terminal_agent_session_id) ??
|
|
2139
|
+
stringValue(nativeTakeover?.terminal_agent_expected_session_id) ??
|
|
2140
|
+
sessionIdForConversation(owner);
|
|
2141
|
+
const proofLevel = String(receipt.status);
|
|
2142
|
+
const replayReceipt = terminalSubmissionReplayReceipt({
|
|
2143
|
+
proofLevel,
|
|
2144
|
+
evidence: receipt.acceptance_evidence,
|
|
2145
|
+
expected: {
|
|
2146
|
+
source: executor.kind === "codex"
|
|
2147
|
+
? "codex_rollout"
|
|
2148
|
+
: "claude_transcript",
|
|
2149
|
+
nativeThreadId,
|
|
2150
|
+
requestHash
|
|
2151
|
+
}
|
|
2152
|
+
});
|
|
2153
|
+
const replayedMessage = isRecord(loggedMessage)
|
|
2154
|
+
? loggedMessage
|
|
2155
|
+
: createMessage({
|
|
2156
|
+
conversation: owner,
|
|
2157
|
+
id: messageId,
|
|
2158
|
+
from: "openclaw",
|
|
2159
|
+
to: executor.actor,
|
|
2160
|
+
type: expectedMessageType,
|
|
2161
|
+
body: requestText,
|
|
2162
|
+
metadata: {
|
|
2163
|
+
executor_kind: executor.kind,
|
|
2164
|
+
executor_session: executor.session
|
|
2165
|
+
}
|
|
2166
|
+
});
|
|
2167
|
+
const acceptanceInvalid = replayReceipt.submission_outcome === "uncertain";
|
|
2168
|
+
const callbackExpected = Boolean(owner.gateway_method);
|
|
2169
|
+
printJson({
|
|
2170
|
+
session_id: sessionIdForConversation(owner),
|
|
2171
|
+
turn_id: turnIdForConversation(owner),
|
|
2172
|
+
conversation: owner,
|
|
2173
|
+
message: replayedMessage,
|
|
2174
|
+
delivered: replayReceipt.delivered,
|
|
2175
|
+
status: replayReceipt.status,
|
|
2176
|
+
submission_outcome: replayReceipt.submission_outcome,
|
|
2177
|
+
background: true,
|
|
2178
|
+
callback_expected: !acceptanceInvalid && callbackExpected,
|
|
2179
|
+
terminal_control: terminalControl,
|
|
2180
|
+
executor,
|
|
2181
|
+
replayed: true,
|
|
2182
|
+
delivery_receipt: replayReceipt.delivery_receipt,
|
|
2183
|
+
...(replayReceipt.do_not_retry
|
|
2184
|
+
? { do_not_retry: replayReceipt.do_not_retry }
|
|
2185
|
+
: {}),
|
|
2186
|
+
reason: replayReceipt.delivered
|
|
2187
|
+
? "AKK replayed the stored durable native acceptance receipt and sent no additional tmux input."
|
|
2188
|
+
: acceptanceInvalid
|
|
2189
|
+
? "AKK replayed an invalid stored native acceptance receipt as uncertain and sent no additional tmux input."
|
|
2190
|
+
: "AKK replayed the stored transport proof without upgrading it and sent no additional tmux input.",
|
|
2191
|
+
openclaw_next_action: replayReceipt.delivered
|
|
2192
|
+
? openClawYieldNextAction({
|
|
2193
|
+
conversationId: owner.conversation_id,
|
|
2194
|
+
sessionId: sessionIdForConversation(owner),
|
|
2195
|
+
turnId: turnIdForConversation(owner),
|
|
2196
|
+
source: "terminal_control",
|
|
2197
|
+
callbackExpected
|
|
2198
|
+
})
|
|
2199
|
+
: {
|
|
2200
|
+
action: "inspect",
|
|
2201
|
+
conversation_id: owner.conversation_id,
|
|
2202
|
+
session_id: sessionIdForConversation(owner),
|
|
2203
|
+
turn_id: turnIdForConversation(owner),
|
|
2204
|
+
do_not_retry: true,
|
|
2205
|
+
reason: acceptanceInvalid
|
|
2206
|
+
? "Stored native acceptance evidence is invalid."
|
|
2207
|
+
: "Only terminal transport is proven."
|
|
2208
|
+
}
|
|
2209
|
+
});
|
|
2210
|
+
return true;
|
|
2211
|
+
}
|
|
2212
|
+
function terminalSubmissionLastProvenStage(status, previous) {
|
|
2213
|
+
if (status === "agent_accepted") {
|
|
2214
|
+
return "agent_accepted";
|
|
2215
|
+
}
|
|
2216
|
+
if (status === "enter_dispatched" ||
|
|
2217
|
+
status === "submitted" ||
|
|
2218
|
+
status === "not_accepted") {
|
|
2219
|
+
return "enter_dispatched";
|
|
2220
|
+
}
|
|
2221
|
+
if (status === "uncertain" &&
|
|
2222
|
+
["prepared", "text_injected", "enter_dispatched", "agent_accepted"].includes(previous ?? "")) {
|
|
2223
|
+
return previous;
|
|
2224
|
+
}
|
|
2225
|
+
return status === "text_injected" ? "text_injected" : "prepared";
|
|
2226
|
+
}
|
|
1406
2227
|
function terminalBridgeSubmission(conversation) {
|
|
1407
2228
|
const nativeTakeover = isRecord(conversation?.native_session_takeover)
|
|
1408
2229
|
? conversation.native_session_takeover
|
|
@@ -1411,10 +2232,45 @@ function terminalBridgeSubmission(conversation) {
|
|
|
1411
2232
|
? nativeTakeover.terminal_bridge_submission
|
|
1412
2233
|
: undefined;
|
|
1413
2234
|
}
|
|
2235
|
+
function terminalBridgeSubmissionReceipts(conversation) {
|
|
2236
|
+
const nativeTakeover = isRecord(conversation.native_session_takeover)
|
|
2237
|
+
? conversation.native_session_takeover
|
|
2238
|
+
: undefined;
|
|
2239
|
+
const historyValue = nativeTakeover?.terminal_bridge_submission_receipts;
|
|
2240
|
+
if (historyValue !== undefined && !Array.isArray(historyValue)) {
|
|
2241
|
+
throw new Error("terminal submission receipt history is malformed");
|
|
2242
|
+
}
|
|
2243
|
+
const receipts = (Array.isArray(historyValue) ? historyValue : []).map((value) => {
|
|
2244
|
+
if (!isRecord(value) || !stringValue(value.message_id)) {
|
|
2245
|
+
throw new Error("terminal submission receipt history is malformed");
|
|
2246
|
+
}
|
|
2247
|
+
return value;
|
|
2248
|
+
});
|
|
2249
|
+
const ids = new Set();
|
|
2250
|
+
for (const receipt of receipts) {
|
|
2251
|
+
const id = String(receipt.message_id);
|
|
2252
|
+
if (ids.has(id)) {
|
|
2253
|
+
throw new Error(`terminal submission receipt ${id} is duplicated`);
|
|
2254
|
+
}
|
|
2255
|
+
ids.add(id);
|
|
2256
|
+
}
|
|
2257
|
+
const current = terminalBridgeSubmission(conversation);
|
|
2258
|
+
const currentId = stringValue(current?.message_id);
|
|
2259
|
+
if (current && currentId && ids.has(currentId)) {
|
|
2260
|
+
const historical = receipts.find((receipt) => stringValue(receipt.message_id) === currentId);
|
|
2261
|
+
if (!historical || canonicalJson(historical) !== canonicalJson(current)) {
|
|
2262
|
+
throw new Error(`terminal submission receipt ${currentId} conflicts with its current generation`);
|
|
2263
|
+
}
|
|
2264
|
+
}
|
|
2265
|
+
return current && currentId && !ids.has(currentId)
|
|
2266
|
+
? [...receipts, current]
|
|
2267
|
+
: receipts;
|
|
2268
|
+
}
|
|
1414
2269
|
function unresolvedTerminalBridgeSubmission(conversation) {
|
|
1415
2270
|
const submission = terminalBridgeSubmission(conversation);
|
|
1416
2271
|
return submission &&
|
|
1417
|
-
|
|
2272
|
+
["prepared", "text_injected", "enter_dispatched", "not_accepted", "uncertain"]
|
|
2273
|
+
.includes(String(submission.status))
|
|
1418
2274
|
? submission
|
|
1419
2275
|
: undefined;
|
|
1420
2276
|
}
|
|
@@ -1429,10 +2285,18 @@ function assertNoUnresolvedTerminalBridgeSubmission(storeDir, terminalControl, c
|
|
|
1429
2285
|
if (candidate.conversation_id === currentConversationId ||
|
|
1430
2286
|
!isActiveStatus(candidate.status) ||
|
|
1431
2287
|
!submission ||
|
|
1432
|
-
![
|
|
2288
|
+
![
|
|
2289
|
+
"prepared",
|
|
2290
|
+
"text_injected",
|
|
2291
|
+
"enter_dispatched",
|
|
2292
|
+
"agent_accepted",
|
|
2293
|
+
"submitted",
|
|
2294
|
+
"not_accepted",
|
|
2295
|
+
"uncertain"
|
|
2296
|
+
].includes(String(submission.status))) {
|
|
1433
2297
|
continue;
|
|
1434
2298
|
}
|
|
1435
|
-
if (submission.status
|
|
2299
|
+
if (["submitted", "agent_accepted"].includes(String(submission.status)) &&
|
|
1436
2300
|
stringValue(submission.request_hash) !== requestHash) {
|
|
1437
2301
|
continue;
|
|
1438
2302
|
}
|
|
@@ -2291,8 +3155,12 @@ function terminalDispatchOwnership(terminalControl) {
|
|
|
2291
3155
|
}
|
|
2292
3156
|
if (![
|
|
2293
3157
|
"prepared",
|
|
3158
|
+
"text_injected",
|
|
3159
|
+
"enter_dispatched",
|
|
3160
|
+
"agent_accepted",
|
|
2294
3161
|
"dispatching",
|
|
2295
3162
|
"submitted",
|
|
3163
|
+
"not_accepted",
|
|
2296
3164
|
"uncertain"
|
|
2297
3165
|
].includes(String(ledger.status))) {
|
|
2298
3166
|
return { state: "none" };
|
|
@@ -2320,7 +3188,16 @@ function terminalDispatchOwnership(terminalControl) {
|
|
|
2320
3188
|
: undefined;
|
|
2321
3189
|
const ledgerMessageId = stringValue(ledger.message_id);
|
|
2322
3190
|
const ownerMessageId = stringValue(ownerTakeover?.terminal_bridge_message_id);
|
|
2323
|
-
if ([
|
|
3191
|
+
if ([
|
|
3192
|
+
"prepared",
|
|
3193
|
+
"text_injected",
|
|
3194
|
+
"enter_dispatched",
|
|
3195
|
+
"agent_accepted",
|
|
3196
|
+
"dispatching",
|
|
3197
|
+
"submitted",
|
|
3198
|
+
"not_accepted",
|
|
3199
|
+
"uncertain"
|
|
3200
|
+
].includes(String(ledger.status)) &&
|
|
2324
3201
|
ledgerMessageId &&
|
|
2325
3202
|
ownerMessageId !== ledgerMessageId) {
|
|
2326
3203
|
return {
|
|
@@ -3985,11 +4862,18 @@ function assertTerminalLifecycleReady({ options, terminal, terminalStatus }) {
|
|
|
3985
4862
|
`transition ${stringValue(ledger?.transition_id) ?? "with invalid identity"}`);
|
|
3986
4863
|
}
|
|
3987
4864
|
if (ledger &&
|
|
3988
|
-
[
|
|
4865
|
+
[
|
|
4866
|
+
"prepared",
|
|
4867
|
+
"text_injected",
|
|
4868
|
+
"enter_dispatched",
|
|
4869
|
+
"dispatching",
|
|
4870
|
+
"not_accepted",
|
|
4871
|
+
"uncertain"
|
|
4872
|
+
].includes(String(ledger.status))) {
|
|
3989
4873
|
throw new Error(`terminal ${terminal.terminalControl.target} has an unresolved ` +
|
|
3990
4874
|
`${String(ledger.kind ?? "turn")} operation (${String(ledger.status)})`);
|
|
3991
4875
|
}
|
|
3992
|
-
if (ledger
|
|
4876
|
+
if (ledger && ["submitted", "agent_accepted"].includes(String(ledger.status))) {
|
|
3993
4877
|
const owner = loadTerminalDispatchLedgerOwner(ledger);
|
|
3994
4878
|
if (!owner || !TERMINAL_DISPATCH_RELEASE_STATUSES.has(owner.status)) {
|
|
3995
4879
|
throw new Error(`terminal ${terminal.terminalControl.target} still has a submitted operation`);
|
|
@@ -6000,6 +6884,7 @@ function prepareManagedSend({ options, statePath, logPath, messageBody, stateLoc
|
|
|
6000
6884
|
}
|
|
6001
6885
|
const message = createMessage({
|
|
6002
6886
|
conversation,
|
|
6887
|
+
id: stringValue(options.messageId),
|
|
6003
6888
|
from: "openclaw",
|
|
6004
6889
|
to: executor.actor,
|
|
6005
6890
|
type,
|
|
@@ -6060,7 +6945,6 @@ async function runSend(options) {
|
|
|
6060
6945
|
}
|
|
6061
6946
|
const rawStoreDir = storeDirFromOptions(options);
|
|
6062
6947
|
const releaseTerminalLock = acquireFileLock(terminalBridgeSendLockPath(rawStoreDir, terminalConversation.terminalControl), { timeoutMs: 30000 });
|
|
6063
|
-
let releaseStateLock;
|
|
6064
6948
|
try {
|
|
6065
6949
|
await withStoreWriterLeaseAsync(rawStoreDir, async () => {
|
|
6066
6950
|
await recoverLifecycleFenceBeforeMutation({
|
|
@@ -6071,6 +6955,15 @@ async function runSend(options) {
|
|
|
6071
6955
|
// protocol migration, rather than mutable Turn recency, is the only code
|
|
6072
6956
|
// allowed to materialize Session records from existing Turns.
|
|
6073
6957
|
ensureStoreWritable(rawStoreDir);
|
|
6958
|
+
if (replayExactActiveTerminalSubmission({
|
|
6959
|
+
options,
|
|
6960
|
+
terminalControl: terminalConversation.terminalControl,
|
|
6961
|
+
requestText: String(messageBody),
|
|
6962
|
+
expectedStoreDir: rawStoreDir,
|
|
6963
|
+
expectedMessageType: "task"
|
|
6964
|
+
})) {
|
|
6965
|
+
return;
|
|
6966
|
+
}
|
|
6074
6967
|
const claimedSession = soleBoundManagedSessionClaimForTerminal(rawStoreDir, terminalConversation);
|
|
6075
6968
|
const knownCodexCompanions = claimedSession
|
|
6076
6969
|
? codexAllowedCompanionSetForManagedSession({
|
|
@@ -6178,33 +7071,33 @@ async function runSend(options) {
|
|
|
6178
7071
|
});
|
|
6179
7072
|
ensureStoreWritable(managed.conversation.store_dir);
|
|
6180
7073
|
ensureDir(path.dirname(managed.statePath));
|
|
6181
|
-
releaseStateLock = acquireFileLock(`${managed.statePath}.lock`);
|
|
6182
|
-
|
|
6183
|
-
|
|
6184
|
-
|
|
6185
|
-
|
|
6186
|
-
|
|
6187
|
-
|
|
6188
|
-
|
|
6189
|
-
|
|
6190
|
-
|
|
6191
|
-
|
|
6192
|
-
|
|
6193
|
-
|
|
6194
|
-
|
|
6195
|
-
|
|
6196
|
-
|
|
6197
|
-
|
|
6198
|
-
|
|
7074
|
+
const releaseStateLock = acquireFileLock(`${managed.statePath}.lock`);
|
|
7075
|
+
try {
|
|
7076
|
+
await runTerminalControlSend({
|
|
7077
|
+
options,
|
|
7078
|
+
conversation: managed.conversation,
|
|
7079
|
+
nextConversation: managed.nextConversation,
|
|
7080
|
+
statePath: managed.statePath,
|
|
7081
|
+
logPath: managed.logPath,
|
|
7082
|
+
executor: managed.executor,
|
|
7083
|
+
message: managed.message,
|
|
7084
|
+
terminalControl: terminalConversation.terminalControl,
|
|
7085
|
+
terminalSendLockHeld: true,
|
|
7086
|
+
terminalStateLockHeld: true,
|
|
7087
|
+
storeWriterLeaseHeld: true,
|
|
7088
|
+
recordMessageAfterSend: true,
|
|
7089
|
+
recordRawAttachmentAfterSend: reusableTurn === undefined,
|
|
7090
|
+
allowedPreMaterializationIdentity,
|
|
7091
|
+
allowedAdditionalIdentities
|
|
7092
|
+
});
|
|
7093
|
+
}
|
|
7094
|
+
finally {
|
|
7095
|
+
releaseStateLock();
|
|
7096
|
+
}
|
|
6199
7097
|
});
|
|
6200
7098
|
}
|
|
6201
7099
|
finally {
|
|
6202
|
-
|
|
6203
|
-
releaseStateLock?.();
|
|
6204
|
-
}
|
|
6205
|
-
finally {
|
|
6206
|
-
releaseTerminalLock();
|
|
6207
|
-
}
|
|
7100
|
+
releaseTerminalLock();
|
|
6208
7101
|
}
|
|
6209
7102
|
return;
|
|
6210
7103
|
}
|
|
@@ -6243,7 +7136,6 @@ async function runSend(options) {
|
|
|
6243
7136
|
throw new Error(`session ${sessionId} is not attached to a live tmux terminal`);
|
|
6244
7137
|
}
|
|
6245
7138
|
const releaseTerminalLock = acquireFileLock(terminalBridgeSendLockPath(storeDir, resolvedTerminal.terminalControl), { timeoutMs: 30000 });
|
|
6246
|
-
let releaseStateLock;
|
|
6247
7139
|
try {
|
|
6248
7140
|
await withStoreWriterLeaseAsync(storeDir, async () => {
|
|
6249
7141
|
await recoverLifecycleFenceBeforeMutation({
|
|
@@ -6254,6 +7146,16 @@ async function runSend(options) {
|
|
|
6254
7146
|
// atomic migration. Once protocol 3 is active, a missing Session is corrupt
|
|
6255
7147
|
// state and must not be reconstructed from whichever Turn looks newest.
|
|
6256
7148
|
ensureStoreWritable(storeDir);
|
|
7149
|
+
if (replayExactActiveTerminalSubmission({
|
|
7150
|
+
options,
|
|
7151
|
+
terminalControl: resolvedTerminal.terminalControl,
|
|
7152
|
+
requestText: String(messageBody),
|
|
7153
|
+
expectedStoreDir: storeDir,
|
|
7154
|
+
expectedSessionId: sessionId,
|
|
7155
|
+
expectedMessageType: "task"
|
|
7156
|
+
})) {
|
|
7157
|
+
return;
|
|
7158
|
+
}
|
|
6257
7159
|
let currentSession = tryLoadManagedSession(storeDir, sessionId);
|
|
6258
7160
|
const knownCodexCompanions = currentSession
|
|
6259
7161
|
? codexAllowedCompanionSetForManagedSession({
|
|
@@ -6344,32 +7246,32 @@ async function runSend(options) {
|
|
|
6344
7246
|
});
|
|
6345
7247
|
ensureStoreWritable(managed.conversation.store_dir);
|
|
6346
7248
|
ensureDir(path.dirname(managed.statePath));
|
|
6347
|
-
releaseStateLock = acquireFileLock(`${managed.statePath}.lock`);
|
|
6348
|
-
|
|
6349
|
-
|
|
6350
|
-
|
|
6351
|
-
|
|
6352
|
-
|
|
6353
|
-
|
|
6354
|
-
|
|
6355
|
-
|
|
6356
|
-
|
|
6357
|
-
|
|
6358
|
-
|
|
6359
|
-
|
|
6360
|
-
|
|
6361
|
-
|
|
6362
|
-
|
|
6363
|
-
|
|
6364
|
-
|
|
6365
|
-
|
|
6366
|
-
|
|
6367
|
-
|
|
6368
|
-
|
|
6369
|
-
}
|
|
6370
|
-
|
|
6371
|
-
|
|
6372
|
-
|
|
7249
|
+
const releaseStateLock = acquireFileLock(`${managed.statePath}.lock`);
|
|
7250
|
+
try {
|
|
7251
|
+
await runTerminalControlSend({
|
|
7252
|
+
options,
|
|
7253
|
+
conversation: managed.conversation,
|
|
7254
|
+
nextConversation: managed.nextConversation,
|
|
7255
|
+
statePath: managed.statePath,
|
|
7256
|
+
logPath: managed.logPath,
|
|
7257
|
+
executor: managed.executor,
|
|
7258
|
+
message: managed.message,
|
|
7259
|
+
terminalControl: resolvedTerminal.terminalControl,
|
|
7260
|
+
terminalSendLockHeld: true,
|
|
7261
|
+
terminalStateLockHeld: true,
|
|
7262
|
+
storeWriterLeaseHeld: true,
|
|
7263
|
+
recordMessageAfterSend: true,
|
|
7264
|
+
allowedPreMaterializationIdentity,
|
|
7265
|
+
allowedAdditionalIdentities
|
|
7266
|
+
});
|
|
7267
|
+
}
|
|
7268
|
+
finally {
|
|
7269
|
+
releaseStateLock();
|
|
7270
|
+
}
|
|
7271
|
+
});
|
|
7272
|
+
}
|
|
7273
|
+
finally {
|
|
7274
|
+
releaseTerminalLock();
|
|
6373
7275
|
}
|
|
6374
7276
|
}
|
|
6375
7277
|
async function runRespond(options) {
|
|
@@ -6386,101 +7288,151 @@ async function runRespond(options) {
|
|
|
6386
7288
|
async function runTurnResponse({ options, messageBody }) {
|
|
6387
7289
|
const loaded = loadConversationFromOptions(options);
|
|
6388
7290
|
const { statePath, logPath } = loaded;
|
|
6389
|
-
const
|
|
6390
|
-
|
|
6391
|
-
|
|
6392
|
-
|
|
6393
|
-
|
|
6394
|
-
|
|
6395
|
-
|
|
6396
|
-
|
|
6397
|
-
|
|
6398
|
-
? conversation.native_session_takeover
|
|
7291
|
+
const initialConversation = loaded.conversation;
|
|
7292
|
+
const requestedOpenClawSession = stringValue(options.openclawSession);
|
|
7293
|
+
if (requestedOpenClawSession &&
|
|
7294
|
+
initialConversation.openclaw_session !== requestedOpenClawSession) {
|
|
7295
|
+
throw new Error(`turn ${turnIdForConversation(initialConversation)} belongs to a ` +
|
|
7296
|
+
"different OpenClaw session; no terminal input was sent");
|
|
7297
|
+
}
|
|
7298
|
+
const nativeTakeover = isRecord(initialConversation.native_session_takeover)
|
|
7299
|
+
? initialConversation.native_session_takeover
|
|
6399
7300
|
: undefined;
|
|
6400
|
-
const
|
|
6401
|
-
|
|
6402
|
-
|
|
6403
|
-
|
|
6404
|
-
|
|
6405
|
-
|
|
7301
|
+
const storedTerminalControl = terminalControlFromTakeover(nativeTakeover);
|
|
7302
|
+
const nativeTerminalId = stringValue(nativeTakeover?.native_session_id);
|
|
7303
|
+
if (!storedTerminalControl || !nativeTerminalId) {
|
|
7304
|
+
throw new Error(`turn ${turnIdForConversation(initialConversation)} is not attached to a live tmux terminal`);
|
|
7305
|
+
}
|
|
7306
|
+
const liveTerminal = await createTerminalAgentBridge(options)
|
|
7307
|
+
.resolveConversationId(nativeTerminalId);
|
|
7308
|
+
if (!liveTerminal ||
|
|
7309
|
+
liveTerminal.agent !== executorForConversation(initialConversation).kind ||
|
|
7310
|
+
liveTerminal.terminalControl.target !== storedTerminalControl.target ||
|
|
7311
|
+
liveTerminal.terminalControl.socketPath !== storedTerminalControl.socketPath ||
|
|
7312
|
+
liveTerminal.terminalControl.panePid !== storedTerminalControl.panePid) {
|
|
7313
|
+
throw new Error(`turn ${turnIdForConversation(initialConversation)} is not attached to its expected live tmux terminal`);
|
|
7314
|
+
}
|
|
7315
|
+
const terminalControl = liveTerminal.terminalControl;
|
|
7316
|
+
const responseStoreDir = pathsForConversationDir(path.dirname(statePath)).storeDir;
|
|
7317
|
+
const releaseTerminalLock = acquireFileLock(terminalBridgeSendLockPath(responseStoreDir, terminalControl), { timeoutMs: 30000 });
|
|
6406
7318
|
try {
|
|
6407
|
-
|
|
6408
|
-
|
|
6409
|
-
|
|
6410
|
-
|
|
6411
|
-
|
|
6412
|
-
|
|
6413
|
-
|
|
6414
|
-
|
|
6415
|
-
|
|
6416
|
-
|
|
6417
|
-
|
|
6418
|
-
|
|
6419
|
-
|
|
6420
|
-
|
|
6421
|
-
|
|
6422
|
-
|
|
6423
|
-
|
|
6424
|
-
|
|
6425
|
-
|
|
6426
|
-
|
|
6427
|
-
|
|
6428
|
-
|
|
6429
|
-
|
|
6430
|
-
|
|
6431
|
-
|
|
6432
|
-
|
|
6433
|
-
|
|
6434
|
-
|
|
6435
|
-
|
|
6436
|
-
|
|
6437
|
-
|
|
6438
|
-
|
|
6439
|
-
|
|
6440
|
-
|
|
6441
|
-
|
|
6442
|
-
|
|
6443
|
-
|
|
6444
|
-
|
|
6445
|
-
|
|
6446
|
-
|
|
6447
|
-
|
|
6448
|
-
|
|
6449
|
-
|
|
6450
|
-
|
|
6451
|
-
|
|
6452
|
-
|
|
6453
|
-
|
|
6454
|
-
|
|
6455
|
-
|
|
6456
|
-
|
|
6457
|
-
|
|
6458
|
-
|
|
6459
|
-
|
|
6460
|
-
|
|
6461
|
-
|
|
6462
|
-
|
|
6463
|
-
|
|
6464
|
-
|
|
6465
|
-
|
|
6466
|
-
|
|
6467
|
-
|
|
6468
|
-
|
|
6469
|
-
|
|
6470
|
-
|
|
6471
|
-
|
|
6472
|
-
|
|
6473
|
-
|
|
6474
|
-
|
|
7319
|
+
await withStoreWriterLeaseAsync(responseStoreDir, async () => {
|
|
7320
|
+
// Legacy identity migration may write the Turn, so keep it inside the
|
|
7321
|
+
// canonical terminal -> Store writer -> state ordering as well.
|
|
7322
|
+
await migrateLegacyTerminalAgentIdentity({ ...loaded, options });
|
|
7323
|
+
const releaseStateLock = acquireFileLock(`${statePath}.lock`);
|
|
7324
|
+
try {
|
|
7325
|
+
const lockedConversation = loadState(statePath);
|
|
7326
|
+
const lockedTakeover = isRecord(lockedConversation.native_session_takeover)
|
|
7327
|
+
? lockedConversation.native_session_takeover
|
|
7328
|
+
: undefined;
|
|
7329
|
+
const lockedControl = terminalControlFromTakeover(lockedTakeover);
|
|
7330
|
+
if (stringValue(lockedTakeover?.native_session_id) !== nativeTerminalId ||
|
|
7331
|
+
executorForConversation(lockedConversation).kind !== liveTerminal.agent ||
|
|
7332
|
+
!lockedControl ||
|
|
7333
|
+
lockedControl.target !== terminalControl.target ||
|
|
7334
|
+
lockedControl.socketPath !== terminalControl.socketPath ||
|
|
7335
|
+
lockedControl.panePid !== terminalControl.panePid) {
|
|
7336
|
+
throw new Error("terminal control changed while waiting to respond; refresh status and retry");
|
|
7337
|
+
}
|
|
7338
|
+
if (requestedOpenClawSession &&
|
|
7339
|
+
lockedConversation.openclaw_session !== requestedOpenClawSession) {
|
|
7340
|
+
throw new Error(`turn ${turnIdForConversation(lockedConversation)} belongs to a ` +
|
|
7341
|
+
"different OpenClaw session; no terminal input was sent");
|
|
7342
|
+
}
|
|
7343
|
+
if (replayExactActiveTerminalSubmission({
|
|
7344
|
+
options,
|
|
7345
|
+
terminalControl,
|
|
7346
|
+
requestText: String(messageBody),
|
|
7347
|
+
expectedStoreDir: responseStoreDir,
|
|
7348
|
+
expectedSessionId: sessionIdForConversation(lockedConversation),
|
|
7349
|
+
expectedTurnId: turnIdForConversation(lockedConversation),
|
|
7350
|
+
expectedMessageType: "answer",
|
|
7351
|
+
expectedStatePath: statePath
|
|
7352
|
+
})) {
|
|
7353
|
+
return;
|
|
7354
|
+
}
|
|
7355
|
+
if (lockedConversation.status !== "waiting_for_openclaw") {
|
|
7356
|
+
throw new Error(`cannot respond to turn ${turnIdForConversation(lockedConversation)}; ` +
|
|
7357
|
+
`turn is ${lockedConversation.status}, not waiting_for_openclaw`);
|
|
7358
|
+
}
|
|
7359
|
+
const prepared = prepareManagedSend({
|
|
7360
|
+
options: { ...options, type: "answer" },
|
|
7361
|
+
statePath,
|
|
7362
|
+
logPath,
|
|
7363
|
+
messageBody,
|
|
7364
|
+
stateLockHeld: true,
|
|
7365
|
+
persist: false
|
|
7366
|
+
});
|
|
7367
|
+
const preparedTakeover = isRecord(prepared.conversation.native_session_takeover)
|
|
7368
|
+
? prepared.conversation.native_session_takeover
|
|
7369
|
+
: undefined;
|
|
7370
|
+
const terminalAgentPid = Number(preparedTakeover?.terminal_agent_pid);
|
|
7371
|
+
const responseManagedSession = tryLoadManagedSession(responseStoreDir, sessionIdForConversation(prepared.conversation));
|
|
7372
|
+
const responseCodexCompanions = prepared.executor.kind === "codex" && responseManagedSession
|
|
7373
|
+
? codexAllowedCompanionSetForManagedSession({
|
|
7374
|
+
storeDir: responseStoreDir,
|
|
7375
|
+
session: responseManagedSession
|
|
7376
|
+
})
|
|
7377
|
+
: { additional: [] };
|
|
7378
|
+
const currentNativeIdentity = await resolveCurrentNativeAgentSessionIdentity({
|
|
7379
|
+
options,
|
|
7380
|
+
agent: prepared.executor.kind,
|
|
7381
|
+
pid: terminalAgentPid,
|
|
7382
|
+
cwd: terminalControl.currentPath,
|
|
7383
|
+
preferredSessionId: responseCodexCompanions.primary
|
|
7384
|
+
? stringValue(preparedTakeover?.terminal_agent_session_id)
|
|
7385
|
+
: undefined,
|
|
7386
|
+
allowedCompanionIdentity: responseCodexCompanions.primary,
|
|
7387
|
+
allowedAdditionalIdentities: responseCodexCompanions.additional
|
|
7388
|
+
});
|
|
7389
|
+
assertNativeAgentIdentityForTurn({
|
|
7390
|
+
conversation: prepared.conversation,
|
|
7391
|
+
currentIdentity: currentNativeIdentity,
|
|
7392
|
+
operation: "respond to"
|
|
7393
|
+
});
|
|
7394
|
+
const currentTerminalControl = terminalControlFromTakeover(prepared.nativeTakeoverForSend);
|
|
7395
|
+
if (!currentTerminalControl ||
|
|
7396
|
+
currentTerminalControl.target !== terminalControl.target ||
|
|
7397
|
+
currentTerminalControl.socketPath !== terminalControl.socketPath ||
|
|
7398
|
+
currentTerminalControl.panePid !== terminalControl.panePid) {
|
|
7399
|
+
throw new Error("terminal control changed while waiting to respond; refresh status and retry");
|
|
7400
|
+
}
|
|
7401
|
+
const responseOptions = {
|
|
7402
|
+
...options,
|
|
7403
|
+
type: "answer",
|
|
7404
|
+
agentTimeoutMinutes: options.agentTimeoutMinutes ??
|
|
7405
|
+
preparedTakeover?.terminal_bridge_inactivity_timeout_minutes ??
|
|
7406
|
+
DEFAULT_AGENT_TIMEOUT_MINUTES,
|
|
7407
|
+
agentHardTimeoutMinutes: options.agentHardTimeoutMinutes ??
|
|
7408
|
+
preparedTakeover?.terminal_bridge_hard_timeout_minutes ??
|
|
7409
|
+
DEFAULT_AGENT_HARD_TIMEOUT_MINUTES
|
|
7410
|
+
};
|
|
7411
|
+
await runTerminalControlSend({
|
|
7412
|
+
options: responseOptions,
|
|
7413
|
+
conversation: prepared.conversation,
|
|
7414
|
+
nextConversation: prepared.nextConversation,
|
|
7415
|
+
statePath,
|
|
7416
|
+
logPath,
|
|
7417
|
+
executor: prepared.executor,
|
|
7418
|
+
message: prepared.message,
|
|
7419
|
+
terminalControl: currentTerminalControl,
|
|
7420
|
+
terminalSendLockHeld: true,
|
|
7421
|
+
terminalStateLockHeld: true,
|
|
7422
|
+
storeWriterLeaseHeld: true,
|
|
7423
|
+
recordMessageAfterSend: true,
|
|
7424
|
+
allowedPreMaterializationIdentity: responseCodexCompanions.primary,
|
|
7425
|
+
allowedAdditionalIdentities: responseCodexCompanions.additional,
|
|
7426
|
+
continuingTurnResponse: true
|
|
7427
|
+
});
|
|
7428
|
+
}
|
|
7429
|
+
finally {
|
|
7430
|
+
releaseStateLock();
|
|
7431
|
+
}
|
|
6475
7432
|
});
|
|
6476
7433
|
}
|
|
6477
7434
|
finally {
|
|
6478
|
-
|
|
6479
|
-
releaseStateLock?.();
|
|
6480
|
-
}
|
|
6481
|
-
finally {
|
|
6482
|
-
releaseTerminalLock();
|
|
6483
|
-
}
|
|
7435
|
+
releaseTerminalLock();
|
|
6484
7436
|
}
|
|
6485
7437
|
}
|
|
6486
7438
|
async function runApprove(options) {
|
|
@@ -7167,14 +8119,18 @@ async function runTerminalControlSend({ options, conversation, nextConversation,
|
|
|
7167
8119
|
"refresh AKK list and use its exact recovery action");
|
|
7168
8120
|
}
|
|
7169
8121
|
if (previousDispatchLedger?.status === "prepared" ||
|
|
8122
|
+
previousDispatchLedger?.status === "text_injected" ||
|
|
7170
8123
|
previousDispatchLedger?.status === "dispatching" ||
|
|
7171
|
-
previousDispatchLedger?.status === "uncertain"
|
|
8124
|
+
previousDispatchLedger?.status === "uncertain" ||
|
|
8125
|
+
previousDispatchLedger?.status === "not_accepted") {
|
|
7172
8126
|
throw new Error(`terminal ${terminalControl.target} has a terminal-level ` +
|
|
7173
8127
|
`${String(previousDispatchLedger.status)} dispatch owned by ` +
|
|
7174
8128
|
`${stringValue(previousDispatchLedger.conversation_id) ?? "an unknown conversation"}; ` +
|
|
7175
8129
|
"inspect the shared tmux pane and explicitly close that AKK conversation before retrying");
|
|
7176
8130
|
}
|
|
7177
|
-
if (previousDispatchLedger?.status === "submitted"
|
|
8131
|
+
if (previousDispatchLedger?.status === "submitted" ||
|
|
8132
|
+
previousDispatchLedger?.status === "enter_dispatched" ||
|
|
8133
|
+
previousDispatchLedger?.status === "agent_accepted") {
|
|
7178
8134
|
const owner = loadTerminalDispatchLedgerOwner(previousDispatchLedger);
|
|
7179
8135
|
if (!owner) {
|
|
7180
8136
|
throw new Error(`terminal ${terminalControl.target} has a submitted dispatch whose ` +
|
|
@@ -7194,6 +8150,14 @@ async function runTerminalControlSend({ options, conversation, nextConversation,
|
|
|
7194
8150
|
stringValue(previousDispatchLedger.message_id) === message.id &&
|
|
7195
8151
|
sameCanonicalStatePath(previousDispatchLedger.state_path, statePath));
|
|
7196
8152
|
if (exactDispatchReplay) {
|
|
8153
|
+
const proofLevel = String(previousDispatchLedger.status);
|
|
8154
|
+
const replayReceipt = terminalSubmissionReplayReceipt({
|
|
8155
|
+
proofLevel,
|
|
8156
|
+
evidence: previousDispatchLedger.acceptance_evidence,
|
|
8157
|
+
expected: terminalAcceptanceEvidenceExpectation(owner, terminalPayload)
|
|
8158
|
+
});
|
|
8159
|
+
const accepted = replayReceipt.delivered;
|
|
8160
|
+
const acceptanceInvalid = replayReceipt.submission_outcome === "uncertain";
|
|
7197
8161
|
const receiptConversationId = stringValue(previousDispatchLedger.conversation_id) ??
|
|
7198
8162
|
owner.conversation_id;
|
|
7199
8163
|
const receiptSessionId = sessionIdForConversation(owner);
|
|
@@ -7217,24 +8181,43 @@ async function runTerminalControlSend({ options, conversation, nextConversation,
|
|
|
7217
8181
|
turn_id: receiptTurnId
|
|
7218
8182
|
}
|
|
7219
8183
|
},
|
|
7220
|
-
delivered:
|
|
7221
|
-
status:
|
|
8184
|
+
delivered: replayReceipt.delivered,
|
|
8185
|
+
status: replayReceipt.status,
|
|
8186
|
+
submission_outcome: replayReceipt.submission_outcome,
|
|
7222
8187
|
background: true,
|
|
7223
|
-
callback_expected: Boolean(owner.gateway_method ??
|
|
8188
|
+
callback_expected: !acceptanceInvalid && Boolean(owner.gateway_method ??
|
|
7224
8189
|
previousDispatchLedger.callback_expected),
|
|
7225
8190
|
terminal_control: terminalControl,
|
|
7226
8191
|
executor,
|
|
7227
|
-
replayed:
|
|
7228
|
-
delivery_receipt:
|
|
7229
|
-
|
|
7230
|
-
|
|
7231
|
-
|
|
7232
|
-
|
|
7233
|
-
|
|
7234
|
-
|
|
7235
|
-
|
|
7236
|
-
|
|
7237
|
-
|
|
8192
|
+
replayed: replayReceipt.replayed,
|
|
8193
|
+
delivery_receipt: replayReceipt.delivery_receipt,
|
|
8194
|
+
...(replayReceipt.do_not_retry
|
|
8195
|
+
? { do_not_retry: replayReceipt.do_not_retry }
|
|
8196
|
+
: {}),
|
|
8197
|
+
reason: accepted
|
|
8198
|
+
? "AKK replayed the durable native acceptance receipt for an identical active terminal request and did not send tmux input again."
|
|
8199
|
+
: acceptanceInvalid
|
|
8200
|
+
? `AKK refused to replay an invalid native acceptance receipt (${replayReceipt.evidence_error ?? "evidence validation failed"}); no tmux input was sent.`
|
|
8201
|
+
: "AKK replayed the original transport-level receipt without upgrading it to native acceptance and did not send tmux input again.",
|
|
8202
|
+
openclaw_next_action: accepted
|
|
8203
|
+
? openClawYieldNextAction({
|
|
8204
|
+
conversationId: receiptConversationId,
|
|
8205
|
+
sessionId: receiptSessionId,
|
|
8206
|
+
turnId: receiptTurnId,
|
|
8207
|
+
source: "terminal_control",
|
|
8208
|
+
callbackExpected: Boolean(owner.gateway_method ??
|
|
8209
|
+
previousDispatchLedger.callback_expected)
|
|
8210
|
+
})
|
|
8211
|
+
: {
|
|
8212
|
+
action: "inspect",
|
|
8213
|
+
conversation_id: receiptConversationId,
|
|
8214
|
+
session_id: receiptSessionId,
|
|
8215
|
+
turn_id: receiptTurnId,
|
|
8216
|
+
do_not_retry: true,
|
|
8217
|
+
reason: acceptanceInvalid
|
|
8218
|
+
? "The stored native acceptance evidence is invalid; inspect and explicitly close this Turn."
|
|
8219
|
+
: "Only terminal transport is proven; wait for native acceptance or inspect the shared pane."
|
|
8220
|
+
}
|
|
7238
8221
|
});
|
|
7239
8222
|
return;
|
|
7240
8223
|
}
|
|
@@ -7288,6 +8271,7 @@ async function runTerminalControlSend({ options, conversation, nextConversation,
|
|
|
7288
8271
|
messageId: message.id
|
|
7289
8272
|
};
|
|
7290
8273
|
let preSendScreenFingerprint;
|
|
8274
|
+
let codexRolloutAcceptanceAnchor;
|
|
7291
8275
|
let claudeTranscriptAnchor;
|
|
7292
8276
|
const claudeHome = executor.kind === "claude"
|
|
7293
8277
|
? path.resolve(expandHome(options.claudeHome) ?? defaultClaudeHome())
|
|
@@ -7322,7 +8306,16 @@ async function runTerminalControlSend({ options, conversation, nextConversation,
|
|
|
7322
8306
|
if (bridge) {
|
|
7323
8307
|
preSendScreenFingerprint = stringValue(status.screen.digest) ??
|
|
7324
8308
|
terminalBridgeScreenFingerprint(status.screen.excerpt);
|
|
7325
|
-
if (executor.kind === "
|
|
8309
|
+
if (executor.kind === "codex") {
|
|
8310
|
+
codexRolloutAcceptanceAnchor = captureCodexAcceptanceAnchorForSend({
|
|
8311
|
+
options,
|
|
8312
|
+
currentIdentity: currentNativeIdentity,
|
|
8313
|
+
expectedNativeThreadId: expectedManagedNativeThreadId,
|
|
8314
|
+
allowedPreMaterializationIdentity,
|
|
8315
|
+
needsPostSendNativeBinding
|
|
8316
|
+
});
|
|
8317
|
+
}
|
|
8318
|
+
else {
|
|
7326
8319
|
claudeTranscriptAnchor = captureClaudeTranscriptAnchor({
|
|
7327
8320
|
sessionId: preSendRuntime.sessionId,
|
|
7328
8321
|
cwd: preSendRuntime.cwd,
|
|
@@ -7348,6 +8341,7 @@ async function runTerminalControlSend({ options, conversation, nextConversation,
|
|
|
7348
8341
|
agentTimeoutMinutes,
|
|
7349
8342
|
agentHardTimeoutMinutes,
|
|
7350
8343
|
preSendScreenFingerprint,
|
|
8344
|
+
codexRolloutAcceptanceAnchor,
|
|
7351
8345
|
claudeTranscriptAnchor,
|
|
7352
8346
|
claudeHome
|
|
7353
8347
|
})
|
|
@@ -7355,6 +8349,8 @@ async function runTerminalControlSend({ options, conversation, nextConversation,
|
|
|
7355
8349
|
const preparedConversation = withTerminalBridgeSubmission({
|
|
7356
8350
|
conversation: bridgeConversation,
|
|
7357
8351
|
messageId: message.id,
|
|
8352
|
+
messageType: message.type,
|
|
8353
|
+
messageBody: String(message.body),
|
|
7358
8354
|
requestText: terminalPayload,
|
|
7359
8355
|
status: "prepared",
|
|
7360
8356
|
preparedAt: bridgeStartedAt
|
|
@@ -7367,6 +8363,7 @@ async function runTerminalControlSend({ options, conversation, nextConversation,
|
|
|
7367
8363
|
session_id: sessionIdForConversation(preparedConversation),
|
|
7368
8364
|
turn_id: turnIdForConversation(preparedConversation),
|
|
7369
8365
|
message_id: message.id,
|
|
8366
|
+
message_type: message.type,
|
|
7370
8367
|
request_hash: terminalRequestHash,
|
|
7371
8368
|
prepared_at: bridgeStartedAt,
|
|
7372
8369
|
dispatcher_pid: process.pid,
|
|
@@ -7435,32 +8432,6 @@ async function runTerminalControlSend({ options, conversation, nextConversation,
|
|
|
7435
8432
|
request_hash: terminalBridgeRequestFingerprint(terminalPayload),
|
|
7436
8433
|
dispatcher_pid: process.pid
|
|
7437
8434
|
});
|
|
7438
|
-
bridgeMonitor = bridge
|
|
7439
|
-
? startTerminalBridgeMonitorForConversation({
|
|
7440
|
-
conversation: preparedConversation,
|
|
7441
|
-
statePath,
|
|
7442
|
-
logPath,
|
|
7443
|
-
options
|
|
7444
|
-
})
|
|
7445
|
-
: undefined;
|
|
7446
|
-
if (bridgeMonitor) {
|
|
7447
|
-
appendEvent(logPath, {
|
|
7448
|
-
ts: new Date().toISOString(),
|
|
7449
|
-
conversation_id: preparedConversation.conversation_id,
|
|
7450
|
-
event: "terminal_bridge_monitor_launch",
|
|
7451
|
-
pid: bridgeMonitor.pid ?? null,
|
|
7452
|
-
terminal_control: terminalControl,
|
|
7453
|
-
phase: "before_terminal_submit",
|
|
7454
|
-
agent_timeout_minutes: agentTimeoutMinutes,
|
|
7455
|
-
agent_hard_timeout_minutes: agentHardTimeoutMinutes
|
|
7456
|
-
});
|
|
7457
|
-
runtimeLog("info", "terminal_bridge_monitor_launch", {
|
|
7458
|
-
conversation_id: preparedConversation.conversation_id,
|
|
7459
|
-
monitor_pid: bridgeMonitor.pid ?? null,
|
|
7460
|
-
terminal_target: terminalControl.target,
|
|
7461
|
-
phase: "before_terminal_submit"
|
|
7462
|
-
});
|
|
7463
|
-
}
|
|
7464
8435
|
}
|
|
7465
8436
|
catch (error) {
|
|
7466
8437
|
const abortedAt = new Date().toISOString();
|
|
@@ -7490,18 +8461,44 @@ async function runTerminalControlSend({ options, conversation, nextConversation,
|
|
|
7490
8461
|
failed_at: abortedAt,
|
|
7491
8462
|
failure_reason: "terminal submission setup failed before tmux input"
|
|
7492
8463
|
}
|
|
7493
|
-
:
|
|
8464
|
+
: {
|
|
8465
|
+
...preparedConversation,
|
|
8466
|
+
status: conversation.status,
|
|
8467
|
+
...(conversation.idle_since
|
|
8468
|
+
? { idle_since: conversation.idle_since }
|
|
8469
|
+
: {})
|
|
8470
|
+
};
|
|
7494
8471
|
const abortedConversation = withTerminalBridgeSubmission({
|
|
7495
8472
|
conversation: failureBase,
|
|
7496
8473
|
messageId: message.id,
|
|
8474
|
+
messageType: message.type,
|
|
8475
|
+
messageBody: String(message.body),
|
|
7497
8476
|
requestText: terminalPayload,
|
|
7498
8477
|
status: "aborted",
|
|
7499
8478
|
preparedAt: bridgeStartedAt,
|
|
7500
8479
|
abortedAt,
|
|
7501
|
-
error: errorMessage
|
|
8480
|
+
error: errorMessage,
|
|
8481
|
+
safeToRetry: dispatchLedgerRestored
|
|
7502
8482
|
});
|
|
8483
|
+
let abortedStatePersisted = false;
|
|
7503
8484
|
try {
|
|
8485
|
+
if (process.env.AKK_TEST_ABORTED_STATE_PERSISTENCE_FAILURE === "1") {
|
|
8486
|
+
throw new Error("injected aborted submission state persistence failure");
|
|
8487
|
+
}
|
|
7504
8488
|
saveState(statePath, abortedConversation);
|
|
8489
|
+
abortedStatePersisted = true;
|
|
8490
|
+
}
|
|
8491
|
+
catch (persistenceError) {
|
|
8492
|
+
runtimeLog("error", "terminal_message_submit_aborted_persist_failed", {
|
|
8493
|
+
conversation_id: abortedConversation.conversation_id,
|
|
8494
|
+
terminal_target: terminalControl.target,
|
|
8495
|
+
error: persistenceError instanceof Error
|
|
8496
|
+
? persistenceError.message
|
|
8497
|
+
: String(persistenceError)
|
|
8498
|
+
});
|
|
8499
|
+
}
|
|
8500
|
+
const safeToRetry = dispatchLedgerRestored && abortedStatePersisted;
|
|
8501
|
+
try {
|
|
7505
8502
|
appendEvent(logPath, {
|
|
7506
8503
|
ts: abortedAt,
|
|
7507
8504
|
conversation_id: abortedConversation.conversation_id,
|
|
@@ -7510,11 +8507,11 @@ async function runTerminalControlSend({ options, conversation, nextConversation,
|
|
|
7510
8507
|
executor,
|
|
7511
8508
|
terminal_control: terminalControl,
|
|
7512
8509
|
error: textSummary(errorMessage),
|
|
7513
|
-
safe_to_retry:
|
|
8510
|
+
safe_to_retry: safeToRetry
|
|
7514
8511
|
});
|
|
7515
8512
|
}
|
|
7516
8513
|
catch (persistenceError) {
|
|
7517
|
-
runtimeLog("error", "
|
|
8514
|
+
runtimeLog("error", "terminal_message_submit_aborted_event_failed", {
|
|
7518
8515
|
conversation_id: abortedConversation.conversation_id,
|
|
7519
8516
|
terminal_target: terminalControl.target,
|
|
7520
8517
|
error: persistenceError instanceof Error
|
|
@@ -7522,16 +8519,32 @@ async function runTerminalControlSend({ options, conversation, nextConversation,
|
|
|
7522
8519
|
: String(persistenceError)
|
|
7523
8520
|
});
|
|
7524
8521
|
}
|
|
8522
|
+
const reportedAbortedConversation = safeToRetry
|
|
8523
|
+
? abortedConversation
|
|
8524
|
+
: withTerminalBridgeSubmission({
|
|
8525
|
+
conversation: abortedConversation,
|
|
8526
|
+
messageId: message.id,
|
|
8527
|
+
messageType: message.type,
|
|
8528
|
+
messageBody: String(message.body),
|
|
8529
|
+
requestText: terminalPayload,
|
|
8530
|
+
status: "aborted",
|
|
8531
|
+
preparedAt: bridgeStartedAt,
|
|
8532
|
+
abortedAt,
|
|
8533
|
+
error: errorMessage,
|
|
8534
|
+
safeToRetry: false
|
|
8535
|
+
});
|
|
7525
8536
|
runtimeLog("error", "terminal_message_submit_aborted", {
|
|
7526
8537
|
conversation_id: abortedConversation.conversation_id,
|
|
7527
8538
|
terminal_target: terminalControl.target,
|
|
7528
8539
|
error: errorMessage,
|
|
7529
|
-
safe_to_retry:
|
|
8540
|
+
safe_to_retry: safeToRetry,
|
|
8541
|
+
dispatch_ledger_restored: dispatchLedgerRestored,
|
|
8542
|
+
aborted_state_persisted: abortedStatePersisted
|
|
7530
8543
|
});
|
|
7531
8544
|
printJson({
|
|
7532
8545
|
session_id: sessionIdForConversation(abortedConversation),
|
|
7533
8546
|
turn_id: turnIdForConversation(abortedConversation),
|
|
7534
|
-
conversation:
|
|
8547
|
+
conversation: reportedAbortedConversation,
|
|
7535
8548
|
message,
|
|
7536
8549
|
delivered: false,
|
|
7537
8550
|
status: "submission_aborted",
|
|
@@ -7541,32 +8554,108 @@ async function runTerminalControlSend({ options, conversation, nextConversation,
|
|
|
7541
8554
|
terminal_control: terminalControl,
|
|
7542
8555
|
monitor_pid: bridgeMonitor?.pid ?? null,
|
|
7543
8556
|
executor,
|
|
7544
|
-
safe_to_retry:
|
|
7545
|
-
do_not_retry: !
|
|
7546
|
-
reason:
|
|
8557
|
+
safe_to_retry: safeToRetry,
|
|
8558
|
+
do_not_retry: !safeToRetry,
|
|
8559
|
+
reason: safeToRetry
|
|
7547
8560
|
? "AKK failed before touching tmux; this terminal submission was not sent and may be retried."
|
|
7548
|
-
:
|
|
8561
|
+
: !dispatchLedgerRestored
|
|
8562
|
+
? "AKK failed before tmux input but could not restore the terminal dispatch ledger; inspect and close the conversation before retrying."
|
|
8563
|
+
: "AKK failed before tmux input but could not persist the aborted receipt; inspect the conversation before retrying.",
|
|
7549
8564
|
openclaw_next_action: {
|
|
7550
|
-
action:
|
|
8565
|
+
action: safeToRetry ? "retry" : "inspect",
|
|
7551
8566
|
conversation_id: abortedConversation.conversation_id,
|
|
7552
|
-
session_id: sessionIdForConversation(
|
|
7553
|
-
turn_id: turnIdForConversation(
|
|
7554
|
-
safe_to_retry:
|
|
7555
|
-
do_not_retry: !
|
|
7556
|
-
reason:
|
|
8567
|
+
session_id: sessionIdForConversation(reportedAbortedConversation),
|
|
8568
|
+
turn_id: turnIdForConversation(reportedAbortedConversation),
|
|
8569
|
+
safe_to_retry: safeToRetry,
|
|
8570
|
+
do_not_retry: !safeToRetry,
|
|
8571
|
+
reason: safeToRetry
|
|
7557
8572
|
? "The failure occurred before any tmux input."
|
|
7558
|
-
:
|
|
8573
|
+
: !dispatchLedgerRestored
|
|
8574
|
+
? "The terminal ledger could not be restored automatically."
|
|
8575
|
+
: "The aborted receipt could not be made durable."
|
|
7559
8576
|
}
|
|
7560
8577
|
});
|
|
7561
8578
|
return;
|
|
7562
8579
|
}
|
|
7563
8580
|
let deliveredConversation;
|
|
8581
|
+
let stagedConversation = preparedConversation;
|
|
8582
|
+
let textInjectedAt;
|
|
8583
|
+
let enterDispatchedAt;
|
|
8584
|
+
let acceptanceResult;
|
|
8585
|
+
let bookkeepingWarning;
|
|
8586
|
+
const recordPostTransportBookkeepingFailure = (phase, error) => {
|
|
8587
|
+
const message = error instanceof Error ? error.message : String(error);
|
|
8588
|
+
bookkeepingWarning ??= message;
|
|
8589
|
+
runtimeLog("warn", "terminal_message_post_transport_bookkeeping_failed", {
|
|
8590
|
+
conversation_id: stagedConversation.conversation_id,
|
|
8591
|
+
terminal_target: terminalControl.target,
|
|
8592
|
+
phase,
|
|
8593
|
+
error: message
|
|
8594
|
+
});
|
|
8595
|
+
};
|
|
7564
8596
|
try {
|
|
7565
8597
|
await terminalBridge.send(executor.kind, terminalControl, terminalPayload, {
|
|
7566
|
-
runtime: preSendRuntime
|
|
8598
|
+
runtime: preSendRuntime,
|
|
8599
|
+
async onTransportStage(event) {
|
|
8600
|
+
const stageAt = new Date().toISOString();
|
|
8601
|
+
if (event.stage === "text_injected") {
|
|
8602
|
+
textInjectedAt = stageAt;
|
|
8603
|
+
}
|
|
8604
|
+
else {
|
|
8605
|
+
enterDispatchedAt = stageAt;
|
|
8606
|
+
}
|
|
8607
|
+
stagedConversation = withTerminalBridgeSubmission({
|
|
8608
|
+
conversation: stagedConversation,
|
|
8609
|
+
messageId: message.id,
|
|
8610
|
+
messageType: message.type,
|
|
8611
|
+
messageBody: String(message.body),
|
|
8612
|
+
requestText: terminalPayload,
|
|
8613
|
+
status: event.stage,
|
|
8614
|
+
preparedAt: bridgeStartedAt,
|
|
8615
|
+
textInjectedAt,
|
|
8616
|
+
enterDispatchedAt
|
|
8617
|
+
});
|
|
8618
|
+
saveState(statePath, stagedConversation);
|
|
8619
|
+
saveTerminalBridgeDispatchLedger(terminalControl, {
|
|
8620
|
+
...terminalBindingLedgerFields(stagedConversation),
|
|
8621
|
+
status: event.stage,
|
|
8622
|
+
generation_id: message.id,
|
|
8623
|
+
conversation_id: stagedConversation.conversation_id,
|
|
8624
|
+
session_id: sessionIdForConversation(stagedConversation),
|
|
8625
|
+
turn_id: turnIdForConversation(stagedConversation),
|
|
8626
|
+
message_id: message.id,
|
|
8627
|
+
message_type: message.type,
|
|
8628
|
+
request_hash: terminalRequestHash,
|
|
8629
|
+
prepared_at: bridgeStartedAt,
|
|
8630
|
+
...(textInjectedAt ? { text_injected_at: textInjectedAt } : {}),
|
|
8631
|
+
...(enterDispatchedAt ? { enter_dispatched_at: enterDispatchedAt } : {}),
|
|
8632
|
+
dispatcher_pid: process.pid,
|
|
8633
|
+
state_path: statePath,
|
|
8634
|
+
event_log_path: logPath,
|
|
8635
|
+
callback_expected: Boolean(stagedConversation.gateway_method),
|
|
8636
|
+
previous_generation_id: stringValue(previousDispatchLedger?.generation_id) ??
|
|
8637
|
+
stringValue(previousDispatchLedger?.message_id)
|
|
8638
|
+
});
|
|
8639
|
+
try {
|
|
8640
|
+
appendEvent(logPath, {
|
|
8641
|
+
ts: stageAt,
|
|
8642
|
+
conversation_id: stagedConversation.conversation_id,
|
|
8643
|
+
event: `terminal_message_${event.stage}`,
|
|
8644
|
+
message_id: message.id,
|
|
8645
|
+
executor,
|
|
8646
|
+
terminal_control: terminalControl,
|
|
8647
|
+
request_hash: terminalRequestHash
|
|
8648
|
+
});
|
|
8649
|
+
}
|
|
8650
|
+
catch (error) {
|
|
8651
|
+
recordPostTransportBookkeepingFailure(event.stage, error);
|
|
8652
|
+
}
|
|
8653
|
+
}
|
|
7567
8654
|
});
|
|
7568
|
-
|
|
7569
|
-
|
|
8655
|
+
if (!enterDispatchedAt) {
|
|
8656
|
+
throw new Error("terminal bridge returned without an enter_dispatched receipt");
|
|
8657
|
+
}
|
|
8658
|
+
let submittedBase = stagedConversation;
|
|
7570
8659
|
if (needsPostSendNativeBinding) {
|
|
7571
8660
|
let boundIdentity;
|
|
7572
8661
|
let boundConversation;
|
|
@@ -7593,7 +8682,7 @@ async function runTerminalControlSend({ options, conversation, nextConversation,
|
|
|
7593
8682
|
throw new Error(`native agent created thread ${boundIdentity.sessionId}, expected ` +
|
|
7594
8683
|
`${expectedNativeThreadId}`);
|
|
7595
8684
|
}
|
|
7596
|
-
boundConversation = withNativeAgentSessionIdentity(
|
|
8685
|
+
boundConversation = withNativeAgentSessionIdentity(stagedConversation, boundIdentity);
|
|
7597
8686
|
const bindingStoreDir = managedSessionStoreDirForConversation(boundConversation);
|
|
7598
8687
|
if (!bindingStoreDir) {
|
|
7599
8688
|
throw new Error("managed Session Store is unavailable before native identity commit");
|
|
@@ -7633,16 +8722,16 @@ async function runTerminalControlSend({ options, conversation, nextConversation,
|
|
|
7633
8722
|
if (!boundIdentity) {
|
|
7634
8723
|
const bindingFailedAt = new Date().toISOString();
|
|
7635
8724
|
const bindingReason = bindingError
|
|
7636
|
-
? `AKK
|
|
7637
|
-
: "AKK
|
|
8725
|
+
? `AKK dispatched terminal input but could not verify the new native agent session: ${bindingError}`
|
|
8726
|
+
: "AKK dispatched terminal input but no exact native agent session appeared within the binding window";
|
|
7638
8727
|
const unfencedBase = {
|
|
7639
|
-
...
|
|
8728
|
+
...stagedConversation,
|
|
7640
8729
|
status: "stalled",
|
|
7641
8730
|
stalled_at: bindingFailedAt,
|
|
7642
8731
|
stalled_reason: bindingReason,
|
|
7643
8732
|
native_session_takeover: {
|
|
7644
|
-
...(isRecord(
|
|
7645
|
-
?
|
|
8733
|
+
...(isRecord(stagedConversation.native_session_takeover)
|
|
8734
|
+
? stagedConversation.native_session_takeover
|
|
7646
8735
|
: {}),
|
|
7647
8736
|
terminal_agent_identity_status: "unresolved_after_submit",
|
|
7648
8737
|
terminal_agent_identity_error: textSummary(bindingReason)
|
|
@@ -7652,10 +8741,16 @@ async function runTerminalControlSend({ options, conversation, nextConversation,
|
|
|
7652
8741
|
const unfencedConversation = withTerminalBridgeSubmission({
|
|
7653
8742
|
conversation: unfencedBase,
|
|
7654
8743
|
messageId: message.id,
|
|
8744
|
+
messageType: message.type,
|
|
8745
|
+
messageBody: String(message.body),
|
|
7655
8746
|
requestText: terminalPayload,
|
|
7656
|
-
status: "
|
|
8747
|
+
status: "uncertain",
|
|
7657
8748
|
preparedAt: bridgeStartedAt,
|
|
7658
|
-
|
|
8749
|
+
textInjectedAt,
|
|
8750
|
+
enterDispatchedAt,
|
|
8751
|
+
uncertainAt: bindingFailedAt,
|
|
8752
|
+
error: bindingReason,
|
|
8753
|
+
lastProvenStage: "enter_dispatched"
|
|
7659
8754
|
});
|
|
7660
8755
|
quarantineManagedSessionBinding({
|
|
7661
8756
|
conversation: unfencedConversation,
|
|
@@ -7663,15 +8758,18 @@ async function runTerminalControlSend({ options, conversation, nextConversation,
|
|
|
7663
8758
|
});
|
|
7664
8759
|
saveTerminalBridgeDispatchLedger(terminalControl, {
|
|
7665
8760
|
...terminalBindingLedgerFields(unfencedConversation),
|
|
7666
|
-
status: "
|
|
8761
|
+
status: "uncertain",
|
|
7667
8762
|
generation_id: message.id,
|
|
7668
8763
|
conversation_id: unfencedConversation.conversation_id,
|
|
7669
8764
|
session_id: sessionIdForConversation(unfencedConversation),
|
|
7670
8765
|
turn_id: turnIdForConversation(unfencedConversation),
|
|
7671
8766
|
message_id: message.id,
|
|
8767
|
+
message_type: message.type,
|
|
7672
8768
|
request_hash: terminalRequestHash,
|
|
7673
8769
|
prepared_at: bridgeStartedAt,
|
|
7674
|
-
|
|
8770
|
+
text_injected_at: textInjectedAt,
|
|
8771
|
+
enter_dispatched_at: enterDispatchedAt,
|
|
8772
|
+
uncertain_at: bindingFailedAt,
|
|
7675
8773
|
dispatcher_pid: process.pid,
|
|
7676
8774
|
state_path: statePath,
|
|
7677
8775
|
event_log_path: logPath,
|
|
@@ -7690,7 +8788,7 @@ async function runTerminalControlSend({ options, conversation, nextConversation,
|
|
|
7690
8788
|
executor,
|
|
7691
8789
|
terminal_control: terminalControl,
|
|
7692
8790
|
error: textSummary(bindingReason),
|
|
7693
|
-
delivered:
|
|
8791
|
+
delivered: false,
|
|
7694
8792
|
do_not_retry: true
|
|
7695
8793
|
});
|
|
7696
8794
|
runtimeLog("error", "terminal_agent_identity_binding_failed", {
|
|
@@ -7698,7 +8796,7 @@ async function runTerminalControlSend({ options, conversation, nextConversation,
|
|
|
7698
8796
|
agent: executor.kind,
|
|
7699
8797
|
terminal_target: terminalControl.target,
|
|
7700
8798
|
error: bindingReason,
|
|
7701
|
-
delivered:
|
|
8799
|
+
delivered: false,
|
|
7702
8800
|
do_not_retry: true
|
|
7703
8801
|
});
|
|
7704
8802
|
printJson({
|
|
@@ -7706,15 +8804,15 @@ async function runTerminalControlSend({ options, conversation, nextConversation,
|
|
|
7706
8804
|
turn_id: turnIdForConversation(unfencedConversation),
|
|
7707
8805
|
conversation: unfencedConversation,
|
|
7708
8806
|
message,
|
|
7709
|
-
delivered:
|
|
7710
|
-
status: "
|
|
7711
|
-
submission_outcome: "
|
|
8807
|
+
delivered: false,
|
|
8808
|
+
status: "submission_uncertain",
|
|
8809
|
+
submission_outcome: "uncertain",
|
|
7712
8810
|
background: true,
|
|
7713
8811
|
callback_expected: false,
|
|
7714
8812
|
terminal_control: terminalControl,
|
|
7715
8813
|
monitor_pid: bridgeMonitor?.pid ?? null,
|
|
7716
8814
|
executor,
|
|
7717
|
-
delivery_receipt: "
|
|
8815
|
+
delivery_receipt: "enter_dispatched",
|
|
7718
8816
|
do_not_retry: true,
|
|
7719
8817
|
reason: bindingReason,
|
|
7720
8818
|
openclaw_next_action: {
|
|
@@ -7730,40 +8828,152 @@ async function runTerminalControlSend({ options, conversation, nextConversation,
|
|
|
7730
8828
|
}
|
|
7731
8829
|
submittedBase = boundConversation;
|
|
7732
8830
|
}
|
|
7733
|
-
|
|
8831
|
+
acceptanceResult = await pollTerminalSubmissionAcceptance({
|
|
8832
|
+
options,
|
|
8833
|
+
executor: executor.kind,
|
|
7734
8834
|
conversation: submittedBase,
|
|
8835
|
+
terminalControl,
|
|
8836
|
+
terminalBridge
|
|
8837
|
+
});
|
|
8838
|
+
const acceptanceResolvedAt = new Date().toISOString();
|
|
8839
|
+
const terminalStatus = acceptanceResult.outcome === "pending_acceptance"
|
|
8840
|
+
? "enter_dispatched"
|
|
8841
|
+
: acceptanceResult.outcome;
|
|
8842
|
+
const outcomeBase = acceptanceResult.outcome === "not_accepted" ||
|
|
8843
|
+
acceptanceResult.outcome === "uncertain"
|
|
8844
|
+
? {
|
|
8845
|
+
...submittedBase,
|
|
8846
|
+
status: "stalled",
|
|
8847
|
+
stalled_at: acceptanceResolvedAt,
|
|
8848
|
+
stalled_reason: acceptanceResult.reason,
|
|
8849
|
+
updated_at: acceptanceResolvedAt
|
|
8850
|
+
}
|
|
8851
|
+
: submittedBase;
|
|
8852
|
+
deliveredConversation = withTerminalBridgeSubmission({
|
|
8853
|
+
conversation: outcomeBase,
|
|
7735
8854
|
messageId: message.id,
|
|
8855
|
+
messageType: message.type,
|
|
8856
|
+
messageBody: String(message.body),
|
|
7736
8857
|
requestText: terminalPayload,
|
|
7737
|
-
status:
|
|
8858
|
+
status: terminalStatus,
|
|
7738
8859
|
preparedAt: bridgeStartedAt,
|
|
7739
|
-
|
|
8860
|
+
textInjectedAt,
|
|
8861
|
+
enterDispatchedAt,
|
|
8862
|
+
...(acceptanceResult.outcome === "agent_accepted"
|
|
8863
|
+
? {
|
|
8864
|
+
agentAcceptedAt: acceptanceResolvedAt,
|
|
8865
|
+
acceptanceEvidence: acceptanceResult.evidence
|
|
8866
|
+
}
|
|
8867
|
+
: {}),
|
|
8868
|
+
...(acceptanceResult.outcome === "not_accepted"
|
|
8869
|
+
? { notAcceptedAt: acceptanceResolvedAt }
|
|
8870
|
+
: {}),
|
|
8871
|
+
...(acceptanceResult.outcome === "uncertain"
|
|
8872
|
+
? {
|
|
8873
|
+
uncertainAt: acceptanceResolvedAt,
|
|
8874
|
+
error: acceptanceResult.reason,
|
|
8875
|
+
lastProvenStage: "enter_dispatched"
|
|
8876
|
+
}
|
|
8877
|
+
: {})
|
|
7740
8878
|
});
|
|
7741
8879
|
saveState(statePath, deliveredConversation);
|
|
7742
|
-
|
|
7743
|
-
|
|
7744
|
-
|
|
7745
|
-
|
|
7746
|
-
|
|
7747
|
-
|
|
7748
|
-
|
|
7749
|
-
|
|
7750
|
-
|
|
7751
|
-
|
|
7752
|
-
|
|
7753
|
-
|
|
7754
|
-
|
|
7755
|
-
|
|
7756
|
-
|
|
7757
|
-
|
|
7758
|
-
|
|
7759
|
-
|
|
8880
|
+
try {
|
|
8881
|
+
if (process.env.AKK_TEST_FINAL_TERMINAL_LEDGER_FAILURE === "1") {
|
|
8882
|
+
throw new Error("injected final terminal ledger persistence failure");
|
|
8883
|
+
}
|
|
8884
|
+
saveTerminalBridgeDispatchLedger(terminalControl, {
|
|
8885
|
+
...terminalBindingLedgerFields(deliveredConversation),
|
|
8886
|
+
status: terminalStatus,
|
|
8887
|
+
generation_id: message.id,
|
|
8888
|
+
conversation_id: deliveredConversation.conversation_id,
|
|
8889
|
+
session_id: sessionIdForConversation(deliveredConversation),
|
|
8890
|
+
turn_id: turnIdForConversation(deliveredConversation),
|
|
8891
|
+
message_id: message.id,
|
|
8892
|
+
message_type: message.type,
|
|
8893
|
+
request_hash: terminalRequestHash,
|
|
8894
|
+
prepared_at: bridgeStartedAt,
|
|
8895
|
+
text_injected_at: textInjectedAt,
|
|
8896
|
+
enter_dispatched_at: enterDispatchedAt,
|
|
8897
|
+
...(acceptanceResult.outcome === "agent_accepted"
|
|
8898
|
+
? {
|
|
8899
|
+
agent_accepted_at: acceptanceResolvedAt,
|
|
8900
|
+
acceptance_evidence: acceptanceResult.evidence
|
|
8901
|
+
}
|
|
8902
|
+
: {}),
|
|
8903
|
+
...(acceptanceResult.outcome === "not_accepted"
|
|
8904
|
+
? { not_accepted_at: acceptanceResolvedAt }
|
|
8905
|
+
: {}),
|
|
8906
|
+
...(acceptanceResult.outcome === "uncertain"
|
|
8907
|
+
? {
|
|
8908
|
+
uncertain_at: acceptanceResolvedAt,
|
|
8909
|
+
error: textSummary(acceptanceResult.reason)
|
|
8910
|
+
}
|
|
8911
|
+
: {}),
|
|
8912
|
+
dispatcher_pid: null,
|
|
8913
|
+
state_path: statePath,
|
|
8914
|
+
event_log_path: logPath,
|
|
8915
|
+
callback_expected: Boolean(deliveredConversation.gateway_method),
|
|
8916
|
+
previous_generation_id: stringValue(previousDispatchLedger?.generation_id) ??
|
|
8917
|
+
stringValue(previousDispatchLedger?.message_id)
|
|
8918
|
+
});
|
|
8919
|
+
}
|
|
8920
|
+
catch (error) {
|
|
8921
|
+
// State is the durable proof authority. Once a valid native ACK has
|
|
8922
|
+
// committed there, a lagging ledger is bookkeeping debt and must never
|
|
8923
|
+
// overwrite that strongest proof with `uncertain`.
|
|
8924
|
+
recordPostTransportBookkeepingFailure("final_terminal_ledger", error);
|
|
8925
|
+
}
|
|
8926
|
+
try {
|
|
8927
|
+
appendEvent(logPath, {
|
|
8928
|
+
ts: acceptanceResolvedAt,
|
|
8929
|
+
conversation_id: deliveredConversation.conversation_id,
|
|
8930
|
+
event: acceptanceResult.outcome === "agent_accepted"
|
|
8931
|
+
? "terminal_message_agent_accepted"
|
|
8932
|
+
: acceptanceResult.outcome === "pending_acceptance"
|
|
8933
|
+
? "terminal_message_acceptance_pending"
|
|
8934
|
+
: `terminal_message_${acceptanceResult.outcome}`,
|
|
8935
|
+
message_id: message.id,
|
|
8936
|
+
executor,
|
|
8937
|
+
terminal_control: terminalControl,
|
|
8938
|
+
delivery_receipt: terminalStatus,
|
|
8939
|
+
do_not_retry: acceptanceResult.outcome !== "agent_accepted"
|
|
8940
|
+
});
|
|
8941
|
+
}
|
|
8942
|
+
catch (error) {
|
|
8943
|
+
recordPostTransportBookkeepingFailure(terminalStatus, error);
|
|
8944
|
+
}
|
|
8945
|
+
if (bridge &&
|
|
8946
|
+
(acceptanceResult.outcome === "agent_accepted" ||
|
|
8947
|
+
acceptanceResult.outcome === "pending_acceptance")) {
|
|
8948
|
+
try {
|
|
8949
|
+
bridgeMonitor = startTerminalBridgeMonitorForConversation({
|
|
8950
|
+
conversation: deliveredConversation,
|
|
8951
|
+
statePath,
|
|
8952
|
+
logPath,
|
|
8953
|
+
options
|
|
8954
|
+
});
|
|
8955
|
+
if (bridgeMonitor) {
|
|
8956
|
+
appendEvent(logPath, {
|
|
8957
|
+
ts: new Date().toISOString(),
|
|
8958
|
+
conversation_id: deliveredConversation.conversation_id,
|
|
8959
|
+
event: "terminal_bridge_monitor_launch",
|
|
8960
|
+
pid: bridgeMonitor.pid ?? null,
|
|
8961
|
+
terminal_control: terminalControl,
|
|
8962
|
+
phase: acceptanceResult.outcome,
|
|
8963
|
+
agent_timeout_minutes: agentTimeoutMinutes,
|
|
8964
|
+
agent_hard_timeout_minutes: agentHardTimeoutMinutes
|
|
8965
|
+
});
|
|
8966
|
+
}
|
|
8967
|
+
}
|
|
8968
|
+
catch (error) {
|
|
8969
|
+
recordPostTransportBookkeepingFailure("monitor_launch", error);
|
|
8970
|
+
}
|
|
8971
|
+
}
|
|
7760
8972
|
}
|
|
7761
8973
|
catch (error) {
|
|
7762
8974
|
const uncertainAt = new Date().toISOString();
|
|
7763
8975
|
const errorMessage = error instanceof Error ? error.message : String(error);
|
|
7764
|
-
const failureBase =
|
|
7765
|
-
? conversation
|
|
7766
|
-
: preparedConversation;
|
|
8976
|
+
const failureBase = stagedConversation;
|
|
7767
8977
|
const stalledFailureBase = {
|
|
7768
8978
|
...failureBase,
|
|
7769
8979
|
status: "stalled",
|
|
@@ -7774,11 +8984,20 @@ async function runTerminalControlSend({ options, conversation, nextConversation,
|
|
|
7774
8984
|
const uncertainConversation = withTerminalBridgeSubmission({
|
|
7775
8985
|
conversation: stalledFailureBase,
|
|
7776
8986
|
messageId: message.id,
|
|
8987
|
+
messageType: message.type,
|
|
8988
|
+
messageBody: String(message.body),
|
|
7777
8989
|
requestText: terminalPayload,
|
|
7778
8990
|
status: "uncertain",
|
|
7779
8991
|
preparedAt: bridgeStartedAt,
|
|
8992
|
+
textInjectedAt,
|
|
8993
|
+
enterDispatchedAt,
|
|
7780
8994
|
uncertainAt,
|
|
7781
|
-
error: errorMessage
|
|
8995
|
+
error: errorMessage,
|
|
8996
|
+
lastProvenStage: enterDispatchedAt
|
|
8997
|
+
? "enter_dispatched"
|
|
8998
|
+
: textInjectedAt
|
|
8999
|
+
? "text_injected"
|
|
9000
|
+
: "prepared"
|
|
7782
9001
|
});
|
|
7783
9002
|
try {
|
|
7784
9003
|
saveTerminalBridgeDispatchLedger(terminalControl, {
|
|
@@ -7789,8 +9008,11 @@ async function runTerminalControlSend({ options, conversation, nextConversation,
|
|
|
7789
9008
|
session_id: sessionIdForConversation(uncertainConversation),
|
|
7790
9009
|
turn_id: turnIdForConversation(uncertainConversation),
|
|
7791
9010
|
message_id: message.id,
|
|
9011
|
+
message_type: message.type,
|
|
7792
9012
|
request_hash: terminalRequestHash,
|
|
7793
9013
|
prepared_at: bridgeStartedAt,
|
|
9014
|
+
...(textInjectedAt ? { text_injected_at: textInjectedAt } : {}),
|
|
9015
|
+
...(enterDispatchedAt ? { enter_dispatched_at: enterDispatchedAt } : {}),
|
|
7794
9016
|
uncertain_at: uncertainAt,
|
|
7795
9017
|
dispatcher_pid: process.pid,
|
|
7796
9018
|
state_path: statePath,
|
|
@@ -7850,7 +9072,11 @@ async function runTerminalControlSend({ options, conversation, nextConversation,
|
|
|
7850
9072
|
executor,
|
|
7851
9073
|
do_not_retry: true,
|
|
7852
9074
|
stalled_conversation_ids: stalledConversationIds,
|
|
7853
|
-
reason:
|
|
9075
|
+
reason: enterDispatchedAt
|
|
9076
|
+
? "AKK dispatched Enter but native acceptance or its exact identity became uncertain. Do not retry automatically; inspect this conversation and pane."
|
|
9077
|
+
: textInjectedAt
|
|
9078
|
+
? "AKK injected text but could not prove that Enter was dispatched. Do not retry automatically; inspect this conversation and pane."
|
|
9079
|
+
: "AKK could not prove that terminal input remained untouched. Inspect this conversation before retrying.",
|
|
7854
9080
|
openclaw_next_action: {
|
|
7855
9081
|
action: "inspect",
|
|
7856
9082
|
conversation_id: uncertainConversation.conversation_id,
|
|
@@ -7862,7 +9088,8 @@ async function runTerminalControlSend({ options, conversation, nextConversation,
|
|
|
7862
9088
|
});
|
|
7863
9089
|
return;
|
|
7864
9090
|
}
|
|
7865
|
-
|
|
9091
|
+
const nativeAccepted = acceptanceResult?.outcome === "agent_accepted";
|
|
9092
|
+
const acceptancePending = acceptanceResult?.outcome === "pending_acceptance";
|
|
7866
9093
|
try {
|
|
7867
9094
|
appendEvent(logPath, {
|
|
7868
9095
|
ts: new Date().toISOString(),
|
|
@@ -7888,54 +9115,466 @@ async function runTerminalControlSend({ options, conversation, nextConversation,
|
|
|
7888
9115
|
conversation_id: deliveredConversation.conversation_id,
|
|
7889
9116
|
terminal_target: terminalControl.target,
|
|
7890
9117
|
error: bookkeepingWarning,
|
|
7891
|
-
delivered:
|
|
9118
|
+
delivered: nativeAccepted
|
|
9119
|
+
});
|
|
9120
|
+
try {
|
|
9121
|
+
appendEvent(logPath, {
|
|
9122
|
+
ts: new Date().toISOString(),
|
|
9123
|
+
conversation_id: deliveredConversation.conversation_id,
|
|
9124
|
+
event: "terminal_message_post_submit_bookkeeping_failed",
|
|
9125
|
+
terminal_control: terminalControl,
|
|
9126
|
+
error: textSummary(bookkeepingWarning),
|
|
9127
|
+
delivered: nativeAccepted
|
|
9128
|
+
});
|
|
9129
|
+
}
|
|
9130
|
+
catch {
|
|
9131
|
+
// The durable submitted receipt remains authoritative even if the event log is unavailable.
|
|
9132
|
+
}
|
|
9133
|
+
}
|
|
9134
|
+
printJson({
|
|
9135
|
+
session_id: sessionIdForConversation(deliveredConversation),
|
|
9136
|
+
turn_id: turnIdForConversation(deliveredConversation),
|
|
9137
|
+
conversation: deliveredConversation,
|
|
9138
|
+
message,
|
|
9139
|
+
delivered: nativeAccepted,
|
|
9140
|
+
status: nativeAccepted
|
|
9141
|
+
? "async_pending"
|
|
9142
|
+
: acceptancePending
|
|
9143
|
+
? "submission_pending_acceptance"
|
|
9144
|
+
: acceptanceResult?.outcome === "not_accepted"
|
|
9145
|
+
? "submission_not_accepted"
|
|
9146
|
+
: "submission_uncertain",
|
|
9147
|
+
submission_outcome: acceptanceResult?.outcome,
|
|
9148
|
+
background: true,
|
|
9149
|
+
callback_expected: Boolean(deliveredConversation.gateway_method &&
|
|
9150
|
+
(nativeAccepted || acceptancePending)),
|
|
9151
|
+
terminal_control: terminalControl,
|
|
9152
|
+
monitor_pid: bridgeMonitor?.pid ?? null,
|
|
9153
|
+
executor,
|
|
9154
|
+
budget: budgetAction(deliveredConversation),
|
|
9155
|
+
delivery_receipt: nativeAccepted
|
|
9156
|
+
? "agent_accepted"
|
|
9157
|
+
: acceptancePending
|
|
9158
|
+
? "enter_dispatched"
|
|
9159
|
+
: acceptanceResult?.outcome,
|
|
9160
|
+
...(!nativeAccepted ? { do_not_retry: true } : {}),
|
|
9161
|
+
...(bookkeepingWarning
|
|
9162
|
+
? {
|
|
9163
|
+
bookkeeping_warning: textSummary(bookkeepingWarning)
|
|
9164
|
+
}
|
|
9165
|
+
: {}),
|
|
9166
|
+
openclaw_next_action: nativeAccepted
|
|
9167
|
+
? openClawYieldNextAction({
|
|
9168
|
+
conversationId: deliveredConversation.conversation_id,
|
|
9169
|
+
sessionId: sessionIdForConversation(deliveredConversation),
|
|
9170
|
+
turnId: turnIdForConversation(deliveredConversation),
|
|
9171
|
+
source: "terminal_control",
|
|
9172
|
+
callbackExpected: Boolean(deliveredConversation.gateway_method)
|
|
9173
|
+
})
|
|
9174
|
+
: {
|
|
9175
|
+
action: acceptancePending ? "wait_for_acceptance" : "inspect",
|
|
9176
|
+
conversation_id: deliveredConversation.conversation_id,
|
|
9177
|
+
session_id: sessionIdForConversation(deliveredConversation),
|
|
9178
|
+
turn_id: turnIdForConversation(deliveredConversation),
|
|
9179
|
+
do_not_retry: true,
|
|
9180
|
+
reason: acceptancePending
|
|
9181
|
+
? "Terminal transport is proven and the background monitor is still waiting for native acceptance."
|
|
9182
|
+
: acceptanceResult?.outcome === "not_accepted"
|
|
9183
|
+
? "The exact draft is still present; inspect the composer and do not send a duplicate."
|
|
9184
|
+
: "Native acceptance evidence became uncertain; inspect the shared pane."
|
|
9185
|
+
}
|
|
9186
|
+
});
|
|
9187
|
+
}
|
|
9188
|
+
function terminalSubmissionPayload(payload) {
|
|
9189
|
+
assertOrdinaryTerminalPayloadDoesNotInvokeNativeLifecycle(payload);
|
|
9190
|
+
return payload.trimEnd();
|
|
9191
|
+
}
|
|
9192
|
+
function allowsSyntheticTerminalAcceptance(_options) {
|
|
9193
|
+
return process.env.AKK_TEST_ALLOW_SYNTHETIC_TERMINAL_ACCEPTANCE === "1";
|
|
9194
|
+
}
|
|
9195
|
+
function syntheticTerminalAcceptanceEvidence({ options, executor, conversation, requestHash }) {
|
|
9196
|
+
if (!allowsSyntheticTerminalAcceptance(options)) {
|
|
9197
|
+
return undefined;
|
|
9198
|
+
}
|
|
9199
|
+
const outcome = process.env.AKK_TEST_TERMINAL_ACCEPTANCE_OUTCOME ?? "accepted";
|
|
9200
|
+
if (outcome === "pending" || outcome === "not_accepted") {
|
|
9201
|
+
return undefined;
|
|
9202
|
+
}
|
|
9203
|
+
if (outcome === "identity_drift" || outcome === "uncertain") {
|
|
9204
|
+
throw new Error("injected terminal acceptance identity drift");
|
|
9205
|
+
}
|
|
9206
|
+
if (outcome !== "accepted") {
|
|
9207
|
+
throw new Error(`unsupported injected terminal acceptance outcome ${outcome}`);
|
|
9208
|
+
}
|
|
9209
|
+
const nativeTakeover = isRecord(conversation.native_session_takeover)
|
|
9210
|
+
? conversation.native_session_takeover
|
|
9211
|
+
: undefined;
|
|
9212
|
+
const nativeThreadId = stringValue(nativeTakeover?.terminal_agent_session_id) ?? stringValue(nativeTakeover?.terminal_agent_expected_session_id) ??
|
|
9213
|
+
sessionIdForConversation(conversation);
|
|
9214
|
+
const evidenceBase = {
|
|
9215
|
+
source: executor === "codex"
|
|
9216
|
+
? "codex_rollout"
|
|
9217
|
+
: "claude_transcript",
|
|
9218
|
+
kind: "native_user_turn",
|
|
9219
|
+
nativeThreadId,
|
|
9220
|
+
requestHash,
|
|
9221
|
+
acceptanceId: `test-${turnIdForConversation(conversation)}`,
|
|
9222
|
+
anchorFingerprint: createHash("sha256")
|
|
9223
|
+
.update(`test-anchor:${nativeThreadId}:${requestHash}`)
|
|
9224
|
+
.digest("hex")
|
|
9225
|
+
};
|
|
9226
|
+
return {
|
|
9227
|
+
...evidenceBase,
|
|
9228
|
+
evidenceFingerprint: createHash("sha256")
|
|
9229
|
+
.update(JSON.stringify(evidenceBase))
|
|
9230
|
+
.digest("hex")
|
|
9231
|
+
};
|
|
9232
|
+
}
|
|
9233
|
+
function captureCodexAcceptanceAnchorForSend({ options, currentIdentity, expectedNativeThreadId, allowedPreMaterializationIdentity, needsPostSendNativeBinding }) {
|
|
9234
|
+
if (allowsSyntheticTerminalAcceptance(options)) {
|
|
9235
|
+
return undefined;
|
|
9236
|
+
}
|
|
9237
|
+
const nativeThreadId = needsPostSendNativeBinding
|
|
9238
|
+
? expectedNativeThreadId
|
|
9239
|
+
: currentIdentity?.sessionId;
|
|
9240
|
+
const processUuid = currentIdentity?.processUuid ??
|
|
9241
|
+
allowedPreMaterializationIdentity?.processUuid;
|
|
9242
|
+
const processBirth = currentIdentity?.processBirth ??
|
|
9243
|
+
allowedPreMaterializationIdentity?.processBirth;
|
|
9244
|
+
if (!nativeThreadId || !processUuid || !processBirth) {
|
|
9245
|
+
throw new Error("Codex native acceptance anchor requires an exact thread and process incarnation");
|
|
9246
|
+
}
|
|
9247
|
+
const base = { nativeThreadId, processUuid, processBirth };
|
|
9248
|
+
if (!needsPostSendNativeBinding && currentIdentity?.rollout) {
|
|
9249
|
+
return captureCodexRolloutAcceptanceAnchor({
|
|
9250
|
+
...base,
|
|
9251
|
+
mode: "existing",
|
|
9252
|
+
rollout: currentIdentity.rollout
|
|
9253
|
+
});
|
|
9254
|
+
}
|
|
9255
|
+
return captureCodexRolloutAcceptanceAnchor({
|
|
9256
|
+
...base,
|
|
9257
|
+
mode: "pre_materialization",
|
|
9258
|
+
expectedEmptyNativeSession: true
|
|
9259
|
+
});
|
|
9260
|
+
}
|
|
9261
|
+
async function detectTerminalSubmissionAcceptance({ options, executor, conversation, terminalControl }) {
|
|
9262
|
+
const nativeTakeover = isRecord(conversation.native_session_takeover)
|
|
9263
|
+
? conversation.native_session_takeover
|
|
9264
|
+
: undefined;
|
|
9265
|
+
const requestHash = stringValue(nativeTakeover?.terminal_bridge_request_hash);
|
|
9266
|
+
if (!requestHash) {
|
|
9267
|
+
throw new Error("terminal acceptance request hash is unavailable");
|
|
9268
|
+
}
|
|
9269
|
+
assertTurnBindingCurrent(conversation, "monitor");
|
|
9270
|
+
const synthetic = syntheticTerminalAcceptanceEvidence({
|
|
9271
|
+
options,
|
|
9272
|
+
executor,
|
|
9273
|
+
conversation,
|
|
9274
|
+
requestHash
|
|
9275
|
+
});
|
|
9276
|
+
if (synthetic) {
|
|
9277
|
+
return synthetic;
|
|
9278
|
+
}
|
|
9279
|
+
if (allowsSyntheticTerminalAcceptance(options)) {
|
|
9280
|
+
return undefined;
|
|
9281
|
+
}
|
|
9282
|
+
if (executor === "codex") {
|
|
9283
|
+
const anchor = nativeTakeover?.codex_rollout_acceptance_anchor;
|
|
9284
|
+
if (!isRecord(anchor)) {
|
|
9285
|
+
throw new Error("Codex rollout acceptance anchor is unavailable");
|
|
9286
|
+
}
|
|
9287
|
+
const pid = Number(nativeTakeover?.terminal_agent_pid);
|
|
9288
|
+
const currentIdentity = await resolveCurrentNativeAgentSessionIdentity({
|
|
9289
|
+
options,
|
|
9290
|
+
agent: "codex",
|
|
9291
|
+
pid,
|
|
9292
|
+
cwd: terminalControl.currentPath,
|
|
9293
|
+
preferredSessionId: stringValue(anchor.native_thread_id)
|
|
9294
|
+
});
|
|
9295
|
+
if (!currentIdentity) {
|
|
9296
|
+
return undefined;
|
|
9297
|
+
}
|
|
9298
|
+
return detectCodexRolloutAcceptance({
|
|
9299
|
+
anchor: anchor,
|
|
9300
|
+
currentIdentity,
|
|
9301
|
+
requestHash
|
|
7892
9302
|
});
|
|
9303
|
+
}
|
|
9304
|
+
const claudeEvidence = detectClaudeTranscriptAcceptance(terminalDurableRequestForConversation(conversation, terminalControl), {
|
|
9305
|
+
claudeHome: expandHome(options.claudeHome),
|
|
9306
|
+
agentRows: loadClaudeAgentRows(options)
|
|
9307
|
+
});
|
|
9308
|
+
if (!claudeEvidence) {
|
|
9309
|
+
return undefined;
|
|
9310
|
+
}
|
|
9311
|
+
return claudeEvidence;
|
|
9312
|
+
}
|
|
9313
|
+
async function pollTerminalSubmissionAcceptance({ options, executor, conversation, terminalControl, terminalBridge }) {
|
|
9314
|
+
const timeoutMs = positiveMilliseconds(options.terminalAcceptanceTimeoutMs ??
|
|
9315
|
+
DEFAULT_TERMINAL_ACCEPTANCE_TIMEOUT_MS, "--terminal-acceptance-timeout-ms");
|
|
9316
|
+
const pollIntervalMs = Math.max(10, Math.min(timeoutMs, Number(options.terminalAcceptancePollIntervalMs ??
|
|
9317
|
+
DEFAULT_TERMINAL_ACCEPTANCE_POLL_INTERVAL_MS)));
|
|
9318
|
+
const deadline = Date.now() + timeoutMs;
|
|
9319
|
+
while (true) {
|
|
9320
|
+
try {
|
|
9321
|
+
const evidence = await detectTerminalSubmissionAcceptance({
|
|
9322
|
+
options,
|
|
9323
|
+
executor,
|
|
9324
|
+
conversation,
|
|
9325
|
+
terminalControl
|
|
9326
|
+
});
|
|
9327
|
+
if (evidence) {
|
|
9328
|
+
return { outcome: "agent_accepted", evidence };
|
|
9329
|
+
}
|
|
9330
|
+
}
|
|
9331
|
+
catch (error) {
|
|
9332
|
+
return {
|
|
9333
|
+
outcome: "uncertain",
|
|
9334
|
+
reason: error instanceof Error ? error.message : String(error)
|
|
9335
|
+
};
|
|
9336
|
+
}
|
|
9337
|
+
if (Date.now() >= deadline) {
|
|
9338
|
+
break;
|
|
9339
|
+
}
|
|
9340
|
+
await new Promise((resolve) => setTimeout(resolve, pollIntervalMs));
|
|
9341
|
+
}
|
|
9342
|
+
if (allowsSyntheticTerminalAcceptance(options) &&
|
|
9343
|
+
process.env.AKK_TEST_TERMINAL_ACCEPTANCE_OUTCOME === "not_accepted") {
|
|
9344
|
+
return {
|
|
9345
|
+
outcome: "not_accepted",
|
|
9346
|
+
reason: "the exact managed draft remains in the terminal composer"
|
|
9347
|
+
};
|
|
9348
|
+
}
|
|
9349
|
+
try {
|
|
9350
|
+
const requestText = String(isRecord(conversation.native_session_takeover)
|
|
9351
|
+
? conversation.native_session_takeover.terminal_bridge_request_text ?? ""
|
|
9352
|
+
: "");
|
|
9353
|
+
if (await terminalBridge.proveExactDraftStillPresent(executor, terminalControl, requestText, {
|
|
9354
|
+
scrollbackLines: Number(options.scrollbackLines ?? 240),
|
|
9355
|
+
runtime: terminalRuntimeIdentityForConversation(conversation, terminalControl)
|
|
9356
|
+
})) {
|
|
9357
|
+
return {
|
|
9358
|
+
outcome: "not_accepted",
|
|
9359
|
+
reason: "the exact managed draft remains in the terminal composer"
|
|
9360
|
+
};
|
|
9361
|
+
}
|
|
9362
|
+
}
|
|
9363
|
+
catch {
|
|
9364
|
+
// Native acceptance evidence remains authoritative. A best-effort draft
|
|
9365
|
+
// inspection failure does not turn a bounded no-ACK into an identity claim.
|
|
9366
|
+
}
|
|
9367
|
+
return { outcome: "pending_acceptance" };
|
|
9368
|
+
}
|
|
9369
|
+
async function reconcileTerminalAcceptanceInMonitor({ options, conversation, statePath, logPath, terminalControl, executor, terminalBridge }) {
|
|
9370
|
+
const evidence = await detectTerminalSubmissionAcceptance({
|
|
9371
|
+
options,
|
|
9372
|
+
executor: executor.kind,
|
|
9373
|
+
conversation,
|
|
9374
|
+
terminalControl
|
|
9375
|
+
});
|
|
9376
|
+
const submission = terminalBridgeSubmission(conversation);
|
|
9377
|
+
const nativeTakeover = isRecord(conversation.native_session_takeover)
|
|
9378
|
+
? conversation.native_session_takeover
|
|
9379
|
+
: undefined;
|
|
9380
|
+
const messageId = stringValue(submission?.message_id);
|
|
9381
|
+
if (!messageId) {
|
|
9382
|
+
throw new Error("terminal acceptance monitor lost its exact message identity");
|
|
9383
|
+
}
|
|
9384
|
+
let notAcceptedReason;
|
|
9385
|
+
if (!evidence) {
|
|
9386
|
+
const enterAt = validTimestampMs(submission?.enter_dispatched_at);
|
|
9387
|
+
if (enterAt !== undefined && Date.now() - enterAt >= 250) {
|
|
9388
|
+
const requestText = String(nativeTakeover?.terminal_bridge_request_text ?? "");
|
|
9389
|
+
if (await terminalBridge.proveExactDraftStillPresent(executor.kind, terminalControl, requestText, {
|
|
9390
|
+
scrollbackLines: Number(options.scrollbackLines ?? 240),
|
|
9391
|
+
runtime: terminalRuntimeIdentityForConversation(conversation, terminalControl)
|
|
9392
|
+
})) {
|
|
9393
|
+
notAcceptedReason =
|
|
9394
|
+
"the exact managed draft remains in the terminal composer";
|
|
9395
|
+
}
|
|
9396
|
+
}
|
|
9397
|
+
if (!notAcceptedReason) {
|
|
9398
|
+
return { outcome: "pending" };
|
|
9399
|
+
}
|
|
9400
|
+
}
|
|
9401
|
+
const resolvedAt = new Date().toISOString();
|
|
9402
|
+
const writerStoreDir = pathsForConversationDir(path.dirname(statePath)).storeDir;
|
|
9403
|
+
return await withStoreWriterLeaseAsync(writerStoreDir, async () => {
|
|
9404
|
+
const releaseStateLock = acquireFileLock(`${statePath}.lock`);
|
|
9405
|
+
try {
|
|
9406
|
+
const current = loadState(statePath);
|
|
9407
|
+
const currentSubmission = terminalBridgeSubmission(current);
|
|
9408
|
+
const currentTakeover = isRecord(current.native_session_takeover)
|
|
9409
|
+
? current.native_session_takeover
|
|
9410
|
+
: undefined;
|
|
9411
|
+
if (current.status !== conversation.status ||
|
|
9412
|
+
stringValue(currentTakeover?.terminal_bridge_message_id) !== messageId ||
|
|
9413
|
+
stringValue(currentSubmission?.message_id) !== messageId ||
|
|
9414
|
+
currentSubmission?.status !== "enter_dispatched") {
|
|
9415
|
+
throw new Error("terminal acceptance generation changed before monitor reconciliation");
|
|
9416
|
+
}
|
|
9417
|
+
const requestText = String(currentTakeover?.terminal_bridge_request_text ?? current.user_request ?? "");
|
|
9418
|
+
const nextBase = notAcceptedReason
|
|
9419
|
+
? {
|
|
9420
|
+
...current,
|
|
9421
|
+
status: "stalled",
|
|
9422
|
+
stalled_at: resolvedAt,
|
|
9423
|
+
stalled_reason: notAcceptedReason,
|
|
9424
|
+
updated_at: resolvedAt
|
|
9425
|
+
}
|
|
9426
|
+
: current;
|
|
9427
|
+
const acceptedConversation = withTerminalBridgeSubmission({
|
|
9428
|
+
conversation: nextBase,
|
|
9429
|
+
messageId,
|
|
9430
|
+
requestText,
|
|
9431
|
+
status: notAcceptedReason ? "not_accepted" : "agent_accepted",
|
|
9432
|
+
preparedAt: stringValue(currentSubmission.prepared_at) ?? resolvedAt,
|
|
9433
|
+
textInjectedAt: stringValue(currentSubmission.text_injected_at),
|
|
9434
|
+
enterDispatchedAt: stringValue(currentSubmission.enter_dispatched_at),
|
|
9435
|
+
...(notAcceptedReason
|
|
9436
|
+
? { notAcceptedAt: resolvedAt }
|
|
9437
|
+
: {
|
|
9438
|
+
agentAcceptedAt: resolvedAt,
|
|
9439
|
+
acceptanceEvidence: evidence
|
|
9440
|
+
})
|
|
9441
|
+
});
|
|
9442
|
+
const ledger = reconcilePreparedTerminalDispatchLedger(terminalControl, loadTerminalBridgeDispatchLedger(terminalControl));
|
|
9443
|
+
if (stringValue(ledger?.message_id) !== messageId ||
|
|
9444
|
+
ledger?.status !== "enter_dispatched") {
|
|
9445
|
+
throw new Error("terminal dispatch ledger changed before acceptance reconciliation");
|
|
9446
|
+
}
|
|
9447
|
+
saveState(statePath, acceptedConversation);
|
|
9448
|
+
try {
|
|
9449
|
+
if (process.env.AKK_TEST_MONITOR_FINAL_TERMINAL_LEDGER_FAILURE === "1") {
|
|
9450
|
+
throw new Error("injected monitor final terminal ledger persistence failure");
|
|
9451
|
+
}
|
|
9452
|
+
saveTerminalBridgeDispatchLedger(terminalControl, {
|
|
9453
|
+
...ledger,
|
|
9454
|
+
...terminalBindingLedgerFields(acceptedConversation),
|
|
9455
|
+
status: notAcceptedReason ? "not_accepted" : "agent_accepted",
|
|
9456
|
+
...(notAcceptedReason
|
|
9457
|
+
? { not_accepted_at: resolvedAt }
|
|
9458
|
+
: {
|
|
9459
|
+
agent_accepted_at: resolvedAt,
|
|
9460
|
+
acceptance_evidence: evidence
|
|
9461
|
+
}),
|
|
9462
|
+
dispatcher_pid: null
|
|
9463
|
+
});
|
|
9464
|
+
}
|
|
9465
|
+
catch (error) {
|
|
9466
|
+
runtimeLog("warn", "terminal_acceptance_monitor_ledger_lagging", {
|
|
9467
|
+
conversation_id: acceptedConversation.conversation_id,
|
|
9468
|
+
message_id: messageId,
|
|
9469
|
+
terminal_target: terminalControl.target,
|
|
9470
|
+
durable_submission_status: notAcceptedReason
|
|
9471
|
+
? "not_accepted"
|
|
9472
|
+
: "agent_accepted",
|
|
9473
|
+
error: error instanceof Error ? error.message : String(error)
|
|
9474
|
+
});
|
|
9475
|
+
}
|
|
9476
|
+
try {
|
|
9477
|
+
if (process.env.AKK_TEST_MONITOR_FINAL_EVENT_FAILURE === "1") {
|
|
9478
|
+
throw new Error("injected monitor final acceptance event persistence failure");
|
|
9479
|
+
}
|
|
9480
|
+
appendEvent(logPath, {
|
|
9481
|
+
ts: resolvedAt,
|
|
9482
|
+
conversation_id: acceptedConversation.conversation_id,
|
|
9483
|
+
event: notAcceptedReason
|
|
9484
|
+
? "terminal_message_not_accepted"
|
|
9485
|
+
: "terminal_message_agent_accepted",
|
|
9486
|
+
message_id: messageId,
|
|
9487
|
+
terminal_control: terminalControl,
|
|
9488
|
+
do_not_retry: Boolean(notAcceptedReason)
|
|
9489
|
+
});
|
|
9490
|
+
}
|
|
9491
|
+
catch (error) {
|
|
9492
|
+
runtimeLog("warn", "terminal_acceptance_monitor_event_lagging", {
|
|
9493
|
+
conversation_id: acceptedConversation.conversation_id,
|
|
9494
|
+
message_id: messageId,
|
|
9495
|
+
terminal_target: terminalControl.target,
|
|
9496
|
+
durable_submission_status: notAcceptedReason
|
|
9497
|
+
? "not_accepted"
|
|
9498
|
+
: "agent_accepted",
|
|
9499
|
+
error: error instanceof Error ? error.message : String(error)
|
|
9500
|
+
});
|
|
9501
|
+
}
|
|
9502
|
+
return notAcceptedReason
|
|
9503
|
+
? { outcome: "not_accepted", conversation: acceptedConversation }
|
|
9504
|
+
: { outcome: "accepted", conversation: acceptedConversation };
|
|
9505
|
+
}
|
|
9506
|
+
finally {
|
|
9507
|
+
releaseStateLock();
|
|
9508
|
+
}
|
|
9509
|
+
});
|
|
9510
|
+
}
|
|
9511
|
+
function markTerminalAcceptanceUncertain({ conversation, statePath, logPath, terminalControl, reason }) {
|
|
9512
|
+
const messageId = stringValue(terminalBridgeSubmission(conversation)?.message_id);
|
|
9513
|
+
const uncertainAt = new Date().toISOString();
|
|
9514
|
+
const writerStoreDir = pathsForConversationDir(path.dirname(statePath)).storeDir;
|
|
9515
|
+
return withStoreWriterLease(writerStoreDir, () => {
|
|
9516
|
+
const releaseStateLock = acquireFileLock(`${statePath}.lock`);
|
|
7893
9517
|
try {
|
|
9518
|
+
const current = loadState(statePath);
|
|
9519
|
+
const submission = terminalBridgeSubmission(current);
|
|
9520
|
+
const currentMessageId = stringValue(submission?.message_id);
|
|
9521
|
+
if (!messageId ||
|
|
9522
|
+
currentMessageId !== messageId ||
|
|
9523
|
+
!["text_injected", "enter_dispatched"].includes(String(submission?.status))) {
|
|
9524
|
+
return current;
|
|
9525
|
+
}
|
|
9526
|
+
const takeover = isRecord(current.native_session_takeover)
|
|
9527
|
+
? current.native_session_takeover
|
|
9528
|
+
: undefined;
|
|
9529
|
+
const requestText = String(takeover?.terminal_bridge_request_text ?? current.user_request ?? "");
|
|
9530
|
+
const uncertain = withTerminalBridgeSubmission({
|
|
9531
|
+
conversation: {
|
|
9532
|
+
...current,
|
|
9533
|
+
status: "stalled",
|
|
9534
|
+
stalled_at: uncertainAt,
|
|
9535
|
+
stalled_reason: reason,
|
|
9536
|
+
updated_at: uncertainAt
|
|
9537
|
+
},
|
|
9538
|
+
messageId,
|
|
9539
|
+
requestText,
|
|
9540
|
+
status: "uncertain",
|
|
9541
|
+
preparedAt: stringValue(submission?.prepared_at) ?? uncertainAt,
|
|
9542
|
+
textInjectedAt: stringValue(submission?.text_injected_at),
|
|
9543
|
+
enterDispatchedAt: stringValue(submission?.enter_dispatched_at),
|
|
9544
|
+
uncertainAt,
|
|
9545
|
+
error: reason,
|
|
9546
|
+
lastProvenStage: submission?.status === "enter_dispatched"
|
|
9547
|
+
? "enter_dispatched"
|
|
9548
|
+
: "text_injected"
|
|
9549
|
+
});
|
|
9550
|
+
const ledger = loadTerminalBridgeDispatchLedger(terminalControl);
|
|
9551
|
+
if (stringValue(ledger?.message_id) === messageId &&
|
|
9552
|
+
["text_injected", "enter_dispatched"].includes(String(ledger?.status))) {
|
|
9553
|
+
saveTerminalBridgeDispatchLedger(terminalControl, {
|
|
9554
|
+
...ledger,
|
|
9555
|
+
status: "uncertain",
|
|
9556
|
+
uncertain_at: uncertainAt,
|
|
9557
|
+
error: textSummary(reason),
|
|
9558
|
+
dispatcher_pid: null
|
|
9559
|
+
});
|
|
9560
|
+
}
|
|
9561
|
+
saveState(statePath, uncertain);
|
|
7894
9562
|
appendEvent(logPath, {
|
|
7895
|
-
ts:
|
|
7896
|
-
conversation_id:
|
|
7897
|
-
event: "
|
|
9563
|
+
ts: uncertainAt,
|
|
9564
|
+
conversation_id: uncertain.conversation_id,
|
|
9565
|
+
event: "terminal_message_acceptance_uncertain",
|
|
9566
|
+
message_id: messageId,
|
|
7898
9567
|
terminal_control: terminalControl,
|
|
7899
|
-
error: textSummary(
|
|
7900
|
-
|
|
9568
|
+
error: textSummary(reason),
|
|
9569
|
+
do_not_retry: true
|
|
7901
9570
|
});
|
|
9571
|
+
return uncertain;
|
|
7902
9572
|
}
|
|
7903
|
-
|
|
7904
|
-
|
|
9573
|
+
finally {
|
|
9574
|
+
releaseStateLock();
|
|
7905
9575
|
}
|
|
7906
|
-
}
|
|
7907
|
-
printJson({
|
|
7908
|
-
session_id: sessionIdForConversation(deliveredConversation),
|
|
7909
|
-
turn_id: turnIdForConversation(deliveredConversation),
|
|
7910
|
-
conversation: deliveredConversation,
|
|
7911
|
-
message,
|
|
7912
|
-
delivered: true,
|
|
7913
|
-
status: "async_pending",
|
|
7914
|
-
background: true,
|
|
7915
|
-
callback_expected: Boolean(deliveredConversation.gateway_method),
|
|
7916
|
-
terminal_control: terminalControl,
|
|
7917
|
-
monitor_pid: bridgeMonitor?.pid ?? null,
|
|
7918
|
-
executor,
|
|
7919
|
-
budget: budgetAction(deliveredConversation),
|
|
7920
|
-
delivery_receipt: "submitted",
|
|
7921
|
-
...(bookkeepingWarning
|
|
7922
|
-
? {
|
|
7923
|
-
bookkeeping_warning: textSummary(bookkeepingWarning)
|
|
7924
|
-
}
|
|
7925
|
-
: {}),
|
|
7926
|
-
openclaw_next_action: openClawYieldNextAction({
|
|
7927
|
-
conversationId: deliveredConversation.conversation_id,
|
|
7928
|
-
sessionId: sessionIdForConversation(deliveredConversation),
|
|
7929
|
-
turnId: turnIdForConversation(deliveredConversation),
|
|
7930
|
-
source: "terminal_control",
|
|
7931
|
-
callbackExpected: Boolean(deliveredConversation.gateway_method)
|
|
7932
|
-
})
|
|
7933
9576
|
});
|
|
7934
9577
|
}
|
|
7935
|
-
function terminalSubmissionPayload(payload) {
|
|
7936
|
-
assertOrdinaryTerminalPayloadDoesNotInvokeNativeLifecycle(payload);
|
|
7937
|
-
return payload.trimEnd();
|
|
7938
|
-
}
|
|
7939
9578
|
function assertOrdinaryTerminalPayloadDoesNotInvokeNativeLifecycle(payload) {
|
|
7940
9579
|
const firstEffectiveLine = String(payload)
|
|
7941
9580
|
.split(/\r\n?|\n/u)
|
|
@@ -8608,6 +10247,7 @@ function createManagedTerminalTurn({ options, conversationId, agent, pid, messag
|
|
|
8608
10247
|
}, paths);
|
|
8609
10248
|
const message = createMessage({
|
|
8610
10249
|
conversation: attachedConversation,
|
|
10250
|
+
id: stringValue(options.messageId),
|
|
8611
10251
|
from: "openclaw",
|
|
8612
10252
|
to: executor.actor,
|
|
8613
10253
|
type: options.type ?? "task",
|
|
@@ -9153,15 +10793,22 @@ function terminalBridgeReconciliationEligibility(conversation) {
|
|
|
9153
10793
|
const dispatchLedger = loadTerminalBridgeDispatchLedger(terminalControl);
|
|
9154
10794
|
if (dispatchLedger &&
|
|
9155
10795
|
(stringValue(dispatchLedger.message_id) !== terminalMessageId ||
|
|
9156
|
-
![
|
|
10796
|
+
![
|
|
10797
|
+
"prepared",
|
|
10798
|
+
"text_injected",
|
|
10799
|
+
"enter_dispatched",
|
|
10800
|
+
"agent_accepted",
|
|
10801
|
+
"submitted"
|
|
10802
|
+
].includes(String(dispatchLedger.status)))) {
|
|
9157
10803
|
return {
|
|
9158
10804
|
eligible: false,
|
|
9159
10805
|
reason: `terminal_dispatch_${String(dispatchLedger.status ?? "generation_replaced")}`
|
|
9160
10806
|
};
|
|
9161
10807
|
}
|
|
9162
10808
|
const submission = terminalBridgeSubmission(conversation);
|
|
9163
|
-
if (
|
|
9164
|
-
(submission
|
|
10809
|
+
if (submission &&
|
|
10810
|
+
stringValue(submission.message_id) === terminalMessageId &&
|
|
10811
|
+
["not_accepted", "uncertain", "aborted"].includes(String(submission.status))) {
|
|
9165
10812
|
return {
|
|
9166
10813
|
eligible: false,
|
|
9167
10814
|
reason: `terminal_submission_${submission.status}`
|
|
@@ -9544,8 +11191,12 @@ async function runTerminalDispatchClose({ options, terminalConversation }) {
|
|
|
9544
11191
|
}
|
|
9545
11192
|
if (![
|
|
9546
11193
|
"prepared",
|
|
11194
|
+
"text_injected",
|
|
11195
|
+
"enter_dispatched",
|
|
9547
11196
|
"dispatching",
|
|
11197
|
+
"agent_accepted",
|
|
9548
11198
|
"submitted",
|
|
11199
|
+
"not_accepted",
|
|
9549
11200
|
"uncertain",
|
|
9550
11201
|
"verified"
|
|
9551
11202
|
].includes(ledger.status)) {
|
|
@@ -10074,6 +11725,91 @@ async function runTerminalBridgeMonitorWithLock(options) {
|
|
|
10074
11725
|
submission &&
|
|
10075
11726
|
stringValue(submission.message_id) === currentMessageId) {
|
|
10076
11727
|
const submissionStatus = stringValue(submission.status);
|
|
11728
|
+
if (submissionStatus === "text_injected" ||
|
|
11729
|
+
submissionStatus === "enter_dispatched") {
|
|
11730
|
+
const dispatcherPid = Number(submission.dispatcher_pid);
|
|
11731
|
+
if (Number.isSafeInteger(dispatcherPid) &&
|
|
11732
|
+
dispatcherPid > 1 &&
|
|
11733
|
+
isProcessAlive(dispatcherPid)) {
|
|
11734
|
+
sleepSync(pollIntervalMs);
|
|
11735
|
+
continue;
|
|
11736
|
+
}
|
|
11737
|
+
if (submissionStatus === "text_injected") {
|
|
11738
|
+
conversation = markTerminalAcceptanceUncertain({
|
|
11739
|
+
conversation,
|
|
11740
|
+
statePath,
|
|
11741
|
+
logPath,
|
|
11742
|
+
terminalControl,
|
|
11743
|
+
reason: "terminal dispatcher exited after text injection but before Enter dispatch was durably proven"
|
|
11744
|
+
});
|
|
11745
|
+
printJson({
|
|
11746
|
+
conversation,
|
|
11747
|
+
monitored: true,
|
|
11748
|
+
terminal_bridge: true,
|
|
11749
|
+
completed: false,
|
|
11750
|
+
submission_outcome: "uncertain",
|
|
11751
|
+
delivery_receipt: "text_injected",
|
|
11752
|
+
do_not_retry: true,
|
|
11753
|
+
reason: "text was injected but native submission was not proven; inspect the composer"
|
|
11754
|
+
});
|
|
11755
|
+
return;
|
|
11756
|
+
}
|
|
11757
|
+
const releaseTerminalLock = acquireFileLock(terminalBridgeSendLockPath(storeDirFromOptions(options), terminalControl), { timeoutMs: 30000 });
|
|
11758
|
+
try {
|
|
11759
|
+
const reconciliation = await reconcileTerminalAcceptanceInMonitor({
|
|
11760
|
+
options,
|
|
11761
|
+
conversation,
|
|
11762
|
+
statePath,
|
|
11763
|
+
logPath,
|
|
11764
|
+
terminalControl,
|
|
11765
|
+
executor,
|
|
11766
|
+
terminalBridge
|
|
11767
|
+
});
|
|
11768
|
+
if (reconciliation.outcome === "accepted") {
|
|
11769
|
+
conversation = reconciliation.conversation;
|
|
11770
|
+
continue;
|
|
11771
|
+
}
|
|
11772
|
+
if (reconciliation.outcome === "not_accepted") {
|
|
11773
|
+
printJson({
|
|
11774
|
+
conversation: reconciliation.conversation,
|
|
11775
|
+
monitored: true,
|
|
11776
|
+
terminal_bridge: true,
|
|
11777
|
+
completed: false,
|
|
11778
|
+
submission_outcome: "not_accepted",
|
|
11779
|
+
delivery_receipt: "enter_dispatched",
|
|
11780
|
+
do_not_retry: true,
|
|
11781
|
+
reason: "the exact managed draft remains in the terminal composer"
|
|
11782
|
+
});
|
|
11783
|
+
return;
|
|
11784
|
+
}
|
|
11785
|
+
}
|
|
11786
|
+
catch (error) {
|
|
11787
|
+
const reason = error instanceof Error ? error.message : String(error);
|
|
11788
|
+
conversation = markTerminalAcceptanceUncertain({
|
|
11789
|
+
conversation,
|
|
11790
|
+
statePath,
|
|
11791
|
+
logPath,
|
|
11792
|
+
terminalControl,
|
|
11793
|
+
reason
|
|
11794
|
+
});
|
|
11795
|
+
printJson({
|
|
11796
|
+
conversation,
|
|
11797
|
+
monitored: true,
|
|
11798
|
+
terminal_bridge: true,
|
|
11799
|
+
completed: false,
|
|
11800
|
+
submission_outcome: "uncertain",
|
|
11801
|
+
delivery_receipt: "enter_dispatched",
|
|
11802
|
+
do_not_retry: true,
|
|
11803
|
+
reason
|
|
11804
|
+
});
|
|
11805
|
+
return;
|
|
11806
|
+
}
|
|
11807
|
+
finally {
|
|
11808
|
+
releaseTerminalLock();
|
|
11809
|
+
}
|
|
11810
|
+
sleepSync(pollIntervalMs);
|
|
11811
|
+
continue;
|
|
11812
|
+
}
|
|
10077
11813
|
if (submissionStatus === "prepared") {
|
|
10078
11814
|
const dispatcherPid = Number(submission.dispatcher_pid);
|
|
10079
11815
|
if (Number.isSafeInteger(dispatcherPid) &&
|
|
@@ -10084,115 +11820,128 @@ async function runTerminalBridgeMonitorWithLock(options) {
|
|
|
10084
11820
|
}
|
|
10085
11821
|
const releaseTerminalLock = acquireFileLock(terminalBridgeSendLockPath(storeDirFromOptions(options), terminalControl), { timeoutMs: 30000 });
|
|
10086
11822
|
try {
|
|
10087
|
-
const
|
|
10088
|
-
|
|
10089
|
-
|
|
10090
|
-
const
|
|
10091
|
-
|
|
10092
|
-
|
|
10093
|
-
|
|
10094
|
-
|
|
10095
|
-
|
|
10096
|
-
|
|
10097
|
-
stringValue(
|
|
10098
|
-
|
|
10099
|
-
|
|
10100
|
-
|
|
10101
|
-
|
|
10102
|
-
|
|
10103
|
-
|
|
10104
|
-
|
|
10105
|
-
|
|
10106
|
-
const submittedConversation = withTerminalBridgeSubmission({
|
|
10107
|
-
conversation: current,
|
|
10108
|
-
messageId: currentMessageId,
|
|
10109
|
-
requestText,
|
|
10110
|
-
status: "submitted",
|
|
10111
|
-
preparedAt: stringValue(currentSubmission.prepared_at) ??
|
|
10112
|
-
submittedAt,
|
|
10113
|
-
submittedAt
|
|
10114
|
-
});
|
|
10115
|
-
saveState(statePath, submittedConversation);
|
|
10116
|
-
conversation = submittedConversation;
|
|
10117
|
-
}
|
|
10118
|
-
else {
|
|
10119
|
-
const uncertainAt = new Date().toISOString();
|
|
10120
|
-
const uncertainConversation = withTerminalBridgeSubmission({
|
|
10121
|
-
conversation: {
|
|
10122
|
-
...current,
|
|
10123
|
-
status: "stalled",
|
|
10124
|
-
stalled_at: uncertainAt,
|
|
10125
|
-
stalled_reason: "terminal dispatcher exited before AKK could prove the tmux submission",
|
|
10126
|
-
updated_at: uncertainAt
|
|
10127
|
-
},
|
|
10128
|
-
messageId: currentMessageId,
|
|
10129
|
-
requestText,
|
|
10130
|
-
status: "uncertain",
|
|
10131
|
-
preparedAt: stringValue(currentSubmission.prepared_at) ??
|
|
10132
|
-
uncertainAt,
|
|
10133
|
-
uncertainAt,
|
|
10134
|
-
error: "the terminal dispatcher exited before AKK could persist a submitted receipt"
|
|
10135
|
-
});
|
|
10136
|
-
if (!dispatchLedger ||
|
|
11823
|
+
const writerStoreDir = pathsForConversationDir(path.dirname(statePath)).storeDir;
|
|
11824
|
+
await withStoreWriterLeaseAsync(writerStoreDir, async () => {
|
|
11825
|
+
const dispatchLedger = loadTerminalBridgeDispatchLedger(terminalControl);
|
|
11826
|
+
const releaseStateLock = acquireFileLock(`${statePath}.lock`);
|
|
11827
|
+
try {
|
|
11828
|
+
const current = loadState(statePath);
|
|
11829
|
+
const currentSubmission = terminalBridgeSubmission(current);
|
|
11830
|
+
const currentTakeover = isRecord(current.native_session_takeover)
|
|
11831
|
+
? current.native_session_takeover
|
|
11832
|
+
: undefined;
|
|
11833
|
+
const expectedMessageId = stringValue(currentTakeover?.terminal_bridge_message_id);
|
|
11834
|
+
if (expectedMessageId === currentMessageId &&
|
|
11835
|
+
stringValue(currentSubmission?.message_id) === currentMessageId &&
|
|
11836
|
+
currentSubmission?.status === "prepared") {
|
|
11837
|
+
const requestText = String(currentTakeover?.terminal_bridge_request_text ??
|
|
11838
|
+
current.user_request ??
|
|
11839
|
+
"");
|
|
11840
|
+
if (dispatchLedger &&
|
|
11841
|
+
["submitted", "agent_accepted"].includes(String(dispatchLedger.status)) &&
|
|
10137
11842
|
stringValue(dispatchLedger.message_id) === currentMessageId) {
|
|
10138
|
-
|
|
10139
|
-
|
|
11843
|
+
const submittedAt = stringValue(dispatchLedger.agent_accepted_at) ??
|
|
11844
|
+
stringValue(dispatchLedger.submitted_at) ??
|
|
11845
|
+
new Date().toISOString();
|
|
11846
|
+
const recoveredStatus = dispatchLedger.status === "agent_accepted"
|
|
11847
|
+
? "agent_accepted"
|
|
11848
|
+
: "submitted";
|
|
11849
|
+
const submittedConversation = withTerminalBridgeSubmission({
|
|
11850
|
+
conversation: current,
|
|
11851
|
+
messageId: currentMessageId,
|
|
11852
|
+
requestText,
|
|
11853
|
+
status: recoveredStatus,
|
|
11854
|
+
preparedAt: stringValue(currentSubmission.prepared_at) ??
|
|
11855
|
+
submittedAt,
|
|
11856
|
+
...(recoveredStatus === "agent_accepted"
|
|
11857
|
+
? {
|
|
11858
|
+
agentAcceptedAt: submittedAt,
|
|
11859
|
+
acceptanceEvidence: dispatchLedger.acceptance_evidence
|
|
11860
|
+
}
|
|
11861
|
+
: { submittedAt })
|
|
11862
|
+
});
|
|
11863
|
+
saveState(statePath, submittedConversation);
|
|
11864
|
+
conversation = submittedConversation;
|
|
11865
|
+
}
|
|
11866
|
+
else {
|
|
11867
|
+
const uncertainAt = new Date().toISOString();
|
|
11868
|
+
const uncertainConversation = withTerminalBridgeSubmission({
|
|
11869
|
+
conversation: {
|
|
11870
|
+
...current,
|
|
11871
|
+
status: "stalled",
|
|
11872
|
+
stalled_at: uncertainAt,
|
|
11873
|
+
stalled_reason: "terminal dispatcher exited before AKK could prove the tmux submission",
|
|
11874
|
+
updated_at: uncertainAt
|
|
11875
|
+
},
|
|
11876
|
+
messageId: currentMessageId,
|
|
11877
|
+
requestText,
|
|
10140
11878
|
status: "uncertain",
|
|
10141
|
-
|
|
11879
|
+
preparedAt: stringValue(currentSubmission.prepared_at) ??
|
|
11880
|
+
uncertainAt,
|
|
11881
|
+
uncertainAt,
|
|
11882
|
+
error: "the terminal dispatcher exited before AKK could persist a submitted receipt"
|
|
11883
|
+
});
|
|
11884
|
+
if (!dispatchLedger ||
|
|
11885
|
+
stringValue(dispatchLedger.message_id) === currentMessageId) {
|
|
11886
|
+
saveTerminalBridgeDispatchLedger(terminalControl, {
|
|
11887
|
+
...(dispatchLedger ?? {}),
|
|
11888
|
+
status: "uncertain",
|
|
11889
|
+
generation_id: currentMessageId,
|
|
11890
|
+
conversation_id: uncertainConversation.conversation_id,
|
|
11891
|
+
message_id: currentMessageId,
|
|
11892
|
+
request_hash: terminalBridgeRequestFingerprint(requestText),
|
|
11893
|
+
prepared_at: stringValue(currentSubmission.prepared_at) ??
|
|
11894
|
+
uncertainAt,
|
|
11895
|
+
uncertain_at: uncertainAt,
|
|
11896
|
+
dispatcher_pid: Number.isSafeInteger(dispatcherPid) &&
|
|
11897
|
+
dispatcherPid > 1
|
|
11898
|
+
? dispatcherPid
|
|
11899
|
+
: null,
|
|
11900
|
+
state_path: statePath,
|
|
11901
|
+
event_log_path: logPath,
|
|
11902
|
+
callback_expected: Boolean(uncertainConversation.gateway_method),
|
|
11903
|
+
reason: "dispatcher_exited_before_submitted_receipt"
|
|
11904
|
+
});
|
|
11905
|
+
}
|
|
11906
|
+
saveState(statePath, uncertainConversation);
|
|
11907
|
+
appendEvent(logPath, {
|
|
11908
|
+
ts: uncertainAt,
|
|
10142
11909
|
conversation_id: uncertainConversation.conversation_id,
|
|
11910
|
+
event: "terminal_message_submit_uncertain",
|
|
10143
11911
|
message_id: currentMessageId,
|
|
10144
|
-
|
|
10145
|
-
prepared_at: stringValue(currentSubmission.prepared_at) ??
|
|
10146
|
-
uncertainAt,
|
|
10147
|
-
uncertain_at: uncertainAt,
|
|
11912
|
+
reason: "dispatcher_exited_before_submitted_receipt",
|
|
10148
11913
|
dispatcher_pid: Number.isSafeInteger(dispatcherPid) &&
|
|
10149
11914
|
dispatcherPid > 1
|
|
10150
11915
|
? dispatcherPid
|
|
10151
11916
|
: null,
|
|
10152
|
-
|
|
10153
|
-
event_log_path: logPath,
|
|
10154
|
-
callback_expected: Boolean(uncertainConversation.gateway_method),
|
|
10155
|
-
reason: "dispatcher_exited_before_submitted_receipt"
|
|
11917
|
+
do_not_retry: true
|
|
10156
11918
|
});
|
|
11919
|
+
conversation = uncertainConversation;
|
|
10157
11920
|
}
|
|
10158
|
-
|
|
10159
|
-
|
|
10160
|
-
|
|
10161
|
-
conversation_id: uncertainConversation.conversation_id,
|
|
10162
|
-
event: "terminal_message_submit_uncertain",
|
|
10163
|
-
message_id: currentMessageId,
|
|
10164
|
-
reason: "dispatcher_exited_before_submitted_receipt",
|
|
10165
|
-
dispatcher_pid: Number.isSafeInteger(dispatcherPid) &&
|
|
10166
|
-
dispatcherPid > 1
|
|
10167
|
-
? dispatcherPid
|
|
10168
|
-
: null,
|
|
10169
|
-
do_not_retry: true
|
|
10170
|
-
});
|
|
10171
|
-
conversation = uncertainConversation;
|
|
11921
|
+
}
|
|
11922
|
+
else {
|
|
11923
|
+
conversation = current;
|
|
10172
11924
|
}
|
|
10173
11925
|
}
|
|
10174
|
-
|
|
10175
|
-
|
|
11926
|
+
finally {
|
|
11927
|
+
releaseStateLock();
|
|
10176
11928
|
}
|
|
10177
|
-
|
|
10178
|
-
|
|
10179
|
-
|
|
10180
|
-
|
|
10181
|
-
|
|
10182
|
-
|
|
10183
|
-
|
|
10184
|
-
|
|
10185
|
-
|
|
10186
|
-
uncertainMessageId: currentMessageId
|
|
10187
|
-
});
|
|
10188
|
-
}
|
|
11929
|
+
if (terminalBridgeSubmission(conversation)?.status === "uncertain") {
|
|
11930
|
+
stallOtherTerminalBridgeConversationsForUncertainDispatch({
|
|
11931
|
+
storeDir: writerStoreDir,
|
|
11932
|
+
terminalControl,
|
|
11933
|
+
currentConversationId: conversation.conversation_id,
|
|
11934
|
+
uncertainMessageId: currentMessageId
|
|
11935
|
+
});
|
|
11936
|
+
}
|
|
11937
|
+
});
|
|
10189
11938
|
}
|
|
10190
11939
|
finally {
|
|
10191
11940
|
releaseTerminalLock();
|
|
10192
11941
|
}
|
|
10193
11942
|
const afterSubmission = terminalBridgeSubmission(conversation);
|
|
10194
11943
|
if (stringValue(afterSubmission?.message_id) === currentMessageId &&
|
|
10195
|
-
afterSubmission?.status
|
|
11944
|
+
["submitted", "agent_accepted"].includes(String(afterSubmission?.status))) {
|
|
10196
11945
|
continue;
|
|
10197
11946
|
}
|
|
10198
11947
|
printJson({
|
|
@@ -10206,22 +11955,31 @@ async function runTerminalBridgeMonitorWithLock(options) {
|
|
|
10206
11955
|
});
|
|
10207
11956
|
return;
|
|
10208
11957
|
}
|
|
10209
|
-
if (submissionStatus === "
|
|
11958
|
+
if (submissionStatus === "not_accepted" ||
|
|
11959
|
+
submissionStatus === "uncertain" ||
|
|
11960
|
+
submissionStatus === "aborted") {
|
|
11961
|
+
const abortedSafeToRetry = submissionStatus === "aborted" &&
|
|
11962
|
+
submission.safe_to_retry === true;
|
|
10210
11963
|
printJson({
|
|
10211
11964
|
conversation,
|
|
10212
11965
|
monitored: true,
|
|
10213
11966
|
terminal_bridge: true,
|
|
10214
11967
|
completed: false,
|
|
10215
11968
|
submission_outcome: submissionStatus,
|
|
10216
|
-
do_not_retry:
|
|
10217
|
-
safe_to_retry:
|
|
10218
|
-
reason: submissionStatus === "
|
|
10219
|
-
? "terminal
|
|
10220
|
-
:
|
|
11969
|
+
do_not_retry: !abortedSafeToRetry,
|
|
11970
|
+
safe_to_retry: abortedSafeToRetry,
|
|
11971
|
+
reason: submissionStatus === "not_accepted"
|
|
11972
|
+
? "the exact terminal draft was not accepted; automatic retry is disabled"
|
|
11973
|
+
: submissionStatus === "uncertain"
|
|
11974
|
+
? "terminal submission outcome is uncertain; automatic completion and approval attribution are disabled"
|
|
11975
|
+
: abortedSafeToRetry
|
|
11976
|
+
? "terminal submission was durably aborted before tmux input"
|
|
11977
|
+
: "terminal submission was aborted but safe retry was not durably proven"
|
|
10221
11978
|
});
|
|
10222
11979
|
return;
|
|
10223
11980
|
}
|
|
10224
|
-
if (submissionStatus !== "submitted"
|
|
11981
|
+
if (submissionStatus !== "submitted" &&
|
|
11982
|
+
submissionStatus !== "agent_accepted") {
|
|
10225
11983
|
printJson({
|
|
10226
11984
|
conversation,
|
|
10227
11985
|
monitored: true,
|
|
@@ -10260,18 +12018,25 @@ async function runTerminalBridgeMonitorWithLock(options) {
|
|
|
10260
12018
|
const expectedStatus = conversation.status;
|
|
10261
12019
|
const releaseTerminalPollLock = acquireFileLock(terminalBridgeSendLockPath(storeDirFromOptions(options), terminalControl), { timeoutMs: 30000 });
|
|
10262
12020
|
try {
|
|
10263
|
-
let dispatchLedger = loadTerminalBridgeDispatchLedger(terminalControl);
|
|
12021
|
+
let dispatchLedger = reconcilePreparedTerminalDispatchLedger(terminalControl, loadTerminalBridgeDispatchLedger(terminalControl));
|
|
10264
12022
|
const durableSubmission = terminalBridgeSubmission(conversation);
|
|
10265
12023
|
if (dispatchLedger?.status === "prepared" &&
|
|
10266
12024
|
stringValue(dispatchLedger.message_id) === currentMessageId &&
|
|
10267
|
-
durableSubmission
|
|
12025
|
+
durableSubmission &&
|
|
12026
|
+
["submitted", "agent_accepted"].includes(String(durableSubmission.status)) &&
|
|
10268
12027
|
stringValue(durableSubmission.message_id) === currentMessageId) {
|
|
10269
|
-
const submittedAt = stringValue(durableSubmission.
|
|
12028
|
+
const submittedAt = stringValue(durableSubmission.agent_accepted_at) ??
|
|
12029
|
+
stringValue(durableSubmission.submitted_at) ??
|
|
10270
12030
|
new Date().toISOString();
|
|
10271
12031
|
saveTerminalBridgeDispatchLedger(terminalControl, {
|
|
10272
12032
|
...dispatchLedger,
|
|
10273
|
-
status:
|
|
10274
|
-
|
|
12033
|
+
status: durableSubmission.status,
|
|
12034
|
+
...(durableSubmission.status === "agent_accepted"
|
|
12035
|
+
? {
|
|
12036
|
+
agent_accepted_at: submittedAt,
|
|
12037
|
+
acceptance_evidence: durableSubmission.acceptance_evidence
|
|
12038
|
+
}
|
|
12039
|
+
: { submitted_at: submittedAt }),
|
|
10275
12040
|
reason: "recovered from the durable conversation submission receipt"
|
|
10276
12041
|
});
|
|
10277
12042
|
dispatchLedger =
|
|
@@ -10280,7 +12045,7 @@ async function runTerminalBridgeMonitorWithLock(options) {
|
|
|
10280
12045
|
if (dispatchLedger) {
|
|
10281
12046
|
const ledgerStatus = stringValue(dispatchLedger.status);
|
|
10282
12047
|
const ledgerMessageId = stringValue(dispatchLedger.message_id);
|
|
10283
|
-
if (ledgerStatus
|
|
12048
|
+
if (!["submitted", "agent_accepted"].includes(ledgerStatus ?? "") ||
|
|
10284
12049
|
ledgerMessageId !== currentMessageId) {
|
|
10285
12050
|
appendEvent(logPath, {
|
|
10286
12051
|
ts: new Date().toISOString(),
|
|
@@ -11060,7 +12825,17 @@ function orphanedTerminalDispatchForRecovery(terminalControl) {
|
|
|
11060
12825
|
const ledgerPanePid = Number(ledgerControl?.pane_pid);
|
|
11061
12826
|
const currentPanePid = Number(terminalControl.panePid);
|
|
11062
12827
|
if (!ledger ||
|
|
11063
|
-
![
|
|
12828
|
+
![
|
|
12829
|
+
"prepared",
|
|
12830
|
+
"text_injected",
|
|
12831
|
+
"enter_dispatched",
|
|
12832
|
+
"dispatching",
|
|
12833
|
+
"agent_accepted",
|
|
12834
|
+
"submitted",
|
|
12835
|
+
"not_accepted",
|
|
12836
|
+
"uncertain",
|
|
12837
|
+
"verified"
|
|
12838
|
+
]
|
|
11064
12839
|
.includes(String(ledger.status)) ||
|
|
11065
12840
|
!recoveryIdentity ||
|
|
11066
12841
|
(!lifecycle &&
|
|
@@ -11155,14 +12930,147 @@ function saveLifecycleTerminalDispatchLedger(terminalControl, ledger, options) {
|
|
|
11155
12930
|
}
|
|
11156
12931
|
saveTerminalBridgeDispatchLedger(terminalControl, ledger);
|
|
11157
12932
|
}
|
|
12933
|
+
function terminalLedgerReceiptHistory(ledger) {
|
|
12934
|
+
if (!ledger) {
|
|
12935
|
+
return [];
|
|
12936
|
+
}
|
|
12937
|
+
const value = ledger.terminal_submission_receipts;
|
|
12938
|
+
if (value !== undefined && !Array.isArray(value)) {
|
|
12939
|
+
throw new Error("terminal dispatch receipt history is malformed");
|
|
12940
|
+
}
|
|
12941
|
+
const receipts = (Array.isArray(value) ? value : []).map((receipt) => {
|
|
12942
|
+
if (!isRecord(receipt) || !stringValue(receipt.message_id)) {
|
|
12943
|
+
throw new Error("terminal dispatch receipt history is malformed");
|
|
12944
|
+
}
|
|
12945
|
+
return receipt;
|
|
12946
|
+
});
|
|
12947
|
+
const ids = new Set();
|
|
12948
|
+
for (const receipt of receipts) {
|
|
12949
|
+
const id = String(receipt.message_id);
|
|
12950
|
+
if (ids.has(id)) {
|
|
12951
|
+
throw new Error(`terminal dispatch receipt ${id} is duplicated`);
|
|
12952
|
+
}
|
|
12953
|
+
ids.add(id);
|
|
12954
|
+
}
|
|
12955
|
+
const current = terminalLedgerReceiptCandidate(ledger);
|
|
12956
|
+
const currentId = stringValue(current?.message_id);
|
|
12957
|
+
if (!current || !currentId) {
|
|
12958
|
+
return receipts;
|
|
12959
|
+
}
|
|
12960
|
+
const previous = receipts.find((receipt) => stringValue(receipt.message_id) === currentId);
|
|
12961
|
+
if (!previous) {
|
|
12962
|
+
return [...receipts, current];
|
|
12963
|
+
}
|
|
12964
|
+
// A resolved top-level ledger may already point at a replacement tmux pane.
|
|
12965
|
+
// Resolution cannot strengthen or rebind an existing submission receipt, so
|
|
12966
|
+
// its append-only historical copy remains authoritative.
|
|
12967
|
+
if (ledger.status === "resolved") {
|
|
12968
|
+
return receipts;
|
|
12969
|
+
}
|
|
12970
|
+
const merged = mergeTerminalLedgerReceipt(previous, current);
|
|
12971
|
+
return receipts.map((receipt) => stringValue(receipt.message_id) === currentId ? merged : receipt);
|
|
12972
|
+
}
|
|
12973
|
+
function terminalLedgerReceiptCandidate(ledger) {
|
|
12974
|
+
const storedStatus = String(ledger.status);
|
|
12975
|
+
const receiptStatus = TERMINAL_LEDGER_RECEIPT_STATUSES.has(storedStatus)
|
|
12976
|
+
? storedStatus
|
|
12977
|
+
: storedStatus === "resolved" && ledger.agent_accepted_at
|
|
12978
|
+
? "agent_accepted"
|
|
12979
|
+
: storedStatus === "resolved" && ledger.uncertain_at
|
|
12980
|
+
? "uncertain"
|
|
12981
|
+
: storedStatus === "resolved" && ledger.not_accepted_at
|
|
12982
|
+
? "not_accepted"
|
|
12983
|
+
: storedStatus === "resolved" && ledger.aborted_at
|
|
12984
|
+
? "aborted"
|
|
12985
|
+
: storedStatus === "resolved" && ledger.enter_dispatched_at
|
|
12986
|
+
? "enter_dispatched"
|
|
12987
|
+
: storedStatus === "resolved" && ledger.submitted_at
|
|
12988
|
+
? "submitted"
|
|
12989
|
+
: undefined;
|
|
12990
|
+
if (terminalDispatchLedgerLooksLifecycle(ledger) ||
|
|
12991
|
+
!receiptStatus ||
|
|
12992
|
+
!stringValue(ledger.message_id)) {
|
|
12993
|
+
return undefined;
|
|
12994
|
+
}
|
|
12995
|
+
const { terminal_submission_receipts: _history, terminal_key: _terminalKey, version: _version, ...receipt } = ledger;
|
|
12996
|
+
return { ...receipt, status: receiptStatus };
|
|
12997
|
+
}
|
|
12998
|
+
function mergeTerminalLedgerReceipt(previous, next) {
|
|
12999
|
+
const messageId = required(stringValue(previous.message_id), "terminal dispatch receipt message id is required");
|
|
13000
|
+
for (const field of TERMINAL_LEDGER_RECEIPT_IMMUTABLE_FIELDS) {
|
|
13001
|
+
const previousValue = previous[field];
|
|
13002
|
+
const nextValue = next[field];
|
|
13003
|
+
if (previousValue !== undefined &&
|
|
13004
|
+
nextValue !== undefined &&
|
|
13005
|
+
canonicalJson(previousValue) !== canonicalJson(nextValue)) {
|
|
13006
|
+
throw new Error(`terminal dispatch receipt ${messageId} changed immutable ${field}`);
|
|
13007
|
+
}
|
|
13008
|
+
}
|
|
13009
|
+
const previousControl = isRecord(previous.terminal_control)
|
|
13010
|
+
? previous.terminal_control
|
|
13011
|
+
: undefined;
|
|
13012
|
+
const nextControl = isRecord(next.terminal_control)
|
|
13013
|
+
? next.terminal_control
|
|
13014
|
+
: undefined;
|
|
13015
|
+
if (previousControl &&
|
|
13016
|
+
nextControl &&
|
|
13017
|
+
(stringValue(previousControl.target) !== stringValue(nextControl.target) ||
|
|
13018
|
+
(stringValue(previousControl.socket_path) ?? undefined) !==
|
|
13019
|
+
(stringValue(nextControl.socket_path) ?? undefined) ||
|
|
13020
|
+
Number(previousControl.pane_pid) !== Number(nextControl.pane_pid))) {
|
|
13021
|
+
throw new Error(`terminal dispatch receipt ${messageId} changed immutable terminal_control`);
|
|
13022
|
+
}
|
|
13023
|
+
if (previous.status === "agent_accepted" &&
|
|
13024
|
+
next.status !== "agent_accepted") {
|
|
13025
|
+
return previous;
|
|
13026
|
+
}
|
|
13027
|
+
const previousIsTerminalFailure = [
|
|
13028
|
+
"not_accepted",
|
|
13029
|
+
"uncertain",
|
|
13030
|
+
"aborted"
|
|
13031
|
+
].includes(String(previous.status)) && previous.safe_to_retry !== true;
|
|
13032
|
+
const nextIsTransportOnly = [
|
|
13033
|
+
"text_injected",
|
|
13034
|
+
"enter_dispatched",
|
|
13035
|
+
"submitted"
|
|
13036
|
+
].includes(String(next.status));
|
|
13037
|
+
if (previousIsTerminalFailure && nextIsTransportOnly) {
|
|
13038
|
+
return previous;
|
|
13039
|
+
}
|
|
13040
|
+
const merged = { ...next };
|
|
13041
|
+
for (const field of TERMINAL_LEDGER_RECEIPT_IMMUTABLE_FIELDS) {
|
|
13042
|
+
if (merged[field] === undefined && previous[field] !== undefined) {
|
|
13043
|
+
merged[field] = previous[field];
|
|
13044
|
+
}
|
|
13045
|
+
}
|
|
13046
|
+
if (!merged.terminal_control && previousControl) {
|
|
13047
|
+
merged.terminal_control = previousControl;
|
|
13048
|
+
}
|
|
13049
|
+
return merged;
|
|
13050
|
+
}
|
|
11158
13051
|
function saveTerminalBridgeDispatchLedger(terminalControl, ledger) {
|
|
11159
13052
|
const ledgerPath = terminalBridgeDispatchLedgerPath(terminalControl);
|
|
11160
13053
|
ensureDir(path.dirname(ledgerPath));
|
|
11161
13054
|
if (fs.existsSync(ledgerPath) && fs.lstatSync(ledgerPath).isSymbolicLink()) {
|
|
11162
13055
|
throw new Error(`terminal dispatch ledger is a symlink: ${ledgerPath}`);
|
|
11163
13056
|
}
|
|
11164
|
-
const
|
|
11165
|
-
|
|
13057
|
+
const previousLedger = fs.existsSync(ledgerPath)
|
|
13058
|
+
? loadTerminalBridgeDispatchLedger(terminalControl)
|
|
13059
|
+
: undefined;
|
|
13060
|
+
let baseReceiptHistory = terminalLedgerReceiptHistory(previousLedger);
|
|
13061
|
+
for (const incomingReceipt of terminalLedgerReceiptHistory(ledger)) {
|
|
13062
|
+
const incomingId = String(incomingReceipt.message_id);
|
|
13063
|
+
const previousReceipt = baseReceiptHistory.find((receipt) => stringValue(receipt.message_id) === incomingId);
|
|
13064
|
+
const merged = previousReceipt
|
|
13065
|
+
? mergeTerminalLedgerReceipt(previousReceipt, incomingReceipt)
|
|
13066
|
+
: incomingReceipt;
|
|
13067
|
+
baseReceiptHistory = previousReceipt
|
|
13068
|
+
? baseReceiptHistory.map((receipt) => stringValue(receipt.message_id) === incomingId ? merged : receipt)
|
|
13069
|
+
: [...baseReceiptHistory, merged];
|
|
13070
|
+
}
|
|
13071
|
+
const { terminal_submission_receipts: _incomingReceiptHistory, ...ledgerWithoutReceiptHistory } = ledger;
|
|
13072
|
+
const nextWithoutHistory = {
|
|
13073
|
+
...ledgerWithoutReceiptHistory,
|
|
11166
13074
|
version: 1,
|
|
11167
13075
|
terminal_key: terminalBridgeRuntimeKey(terminalControl),
|
|
11168
13076
|
terminal_control: {
|
|
@@ -11173,6 +13081,37 @@ function saveTerminalBridgeDispatchLedger(terminalControl, ledger) {
|
|
|
11173
13081
|
current_path: terminalControl.currentPath ?? null
|
|
11174
13082
|
}
|
|
11175
13083
|
};
|
|
13084
|
+
// The top-level ledger follows the current tmux pane incarnation, while an
|
|
13085
|
+
// append-only receipt must retain the pane that actually accepted its
|
|
13086
|
+
// input. Reconciliation may save an old ledger through a newly-created
|
|
13087
|
+
// pane with the same selector, so derive that receipt from the caller's
|
|
13088
|
+
// original control record instead of silently rebinding its proof.
|
|
13089
|
+
const receiptCandidateLedger = isRecord(ledgerWithoutReceiptHistory.terminal_control)
|
|
13090
|
+
? {
|
|
13091
|
+
...nextWithoutHistory,
|
|
13092
|
+
terminal_control: ledgerWithoutReceiptHistory.terminal_control
|
|
13093
|
+
}
|
|
13094
|
+
: nextWithoutHistory;
|
|
13095
|
+
const nextCandidate = terminalLedgerReceiptCandidate(receiptCandidateLedger);
|
|
13096
|
+
let nextReceiptHistory = baseReceiptHistory;
|
|
13097
|
+
if (nextCandidate) {
|
|
13098
|
+
const messageId = String(nextCandidate.message_id);
|
|
13099
|
+
const previousReceipt = baseReceiptHistory.find((receipt) => stringValue(receipt.message_id) === messageId);
|
|
13100
|
+
const nextReceipt = previousReceipt
|
|
13101
|
+
? mergeTerminalLedgerReceipt(previousReceipt, nextCandidate)
|
|
13102
|
+
: nextCandidate;
|
|
13103
|
+
nextReceiptHistory = previousReceipt
|
|
13104
|
+
? baseReceiptHistory.map((receipt) => stringValue(receipt.message_id) === messageId
|
|
13105
|
+
? nextReceipt
|
|
13106
|
+
: receipt)
|
|
13107
|
+
: [...baseReceiptHistory, nextReceipt];
|
|
13108
|
+
}
|
|
13109
|
+
const nextLedger = {
|
|
13110
|
+
...nextWithoutHistory,
|
|
13111
|
+
...(nextReceiptHistory.length > 0
|
|
13112
|
+
? { terminal_submission_receipts: nextReceiptHistory }
|
|
13113
|
+
: {})
|
|
13114
|
+
};
|
|
11176
13115
|
const tempPath = `${ledgerPath}.${process.pid}.${randomUUID()}.tmp`;
|
|
11177
13116
|
let tempFd;
|
|
11178
13117
|
try {
|
|
@@ -11271,7 +13210,8 @@ function assertManagedTerminalDispatchOwner({ conversation, terminalControl, act
|
|
|
11271
13210
|
const messageId = stringValue(nativeTakeover?.terminal_bridge_message_id);
|
|
11272
13211
|
const ledger = loadTerminalBridgeDispatchLedger(terminalControl);
|
|
11273
13212
|
if (!messageId ||
|
|
11274
|
-
ledger
|
|
13213
|
+
!ledger ||
|
|
13214
|
+
!["submitted", "agent_accepted"].includes(String(ledger.status)) ||
|
|
11275
13215
|
stringValue(ledger.conversation_id) !==
|
|
11276
13216
|
conversation.conversation_id ||
|
|
11277
13217
|
stringValue(ledger.message_id) !== messageId ||
|
|
@@ -11297,13 +13237,22 @@ function terminalBindingLedgerFields(conversation) {
|
|
|
11297
13237
|
const nativeThreadId = stringValue(conversation.native_thread_id) ??
|
|
11298
13238
|
stringValue(takeover?.terminal_agent_session_id);
|
|
11299
13239
|
const storeDir = managedSessionStoreDirForConversation(conversation);
|
|
13240
|
+
const submission = terminalBridgeSubmission(conversation);
|
|
13241
|
+
const messageType = stringValue(submission?.message_type);
|
|
13242
|
+
const messageBodyHash = stringValue(submission?.message_body_hash);
|
|
11300
13243
|
return {
|
|
11301
13244
|
...(bindingId ? { binding_id: bindingId } : {}),
|
|
11302
13245
|
...(Number.isSafeInteger(generation)
|
|
11303
13246
|
? { binding_generation: generation }
|
|
11304
13247
|
: {}),
|
|
11305
13248
|
...(nativeThreadId ? { native_thread_id: nativeThreadId } : {}),
|
|
11306
|
-
...(storeDir ? { store_dir: path.resolve(storeDir) } : {})
|
|
13249
|
+
...(storeDir ? { store_dir: path.resolve(storeDir) } : {}),
|
|
13250
|
+
...(messageType ? { message_type: messageType } : {}),
|
|
13251
|
+
...(messageBodyHash ? { message_body_hash: messageBodyHash } : {}),
|
|
13252
|
+
executor_kind: executorForConversation(conversation).kind,
|
|
13253
|
+
...(conversation.openclaw_session
|
|
13254
|
+
? { openclaw_session: conversation.openclaw_session }
|
|
13255
|
+
: {})
|
|
11307
13256
|
};
|
|
11308
13257
|
}
|
|
11309
13258
|
function reconcilePreparedTerminalDispatchLedger(terminalControl, ledger) {
|
|
@@ -11311,7 +13260,7 @@ function reconcilePreparedTerminalDispatchLedger(terminalControl, ledger) {
|
|
|
11311
13260
|
return ledger;
|
|
11312
13261
|
}
|
|
11313
13262
|
if (ledger?.status !== "prepared") {
|
|
11314
|
-
return ledger;
|
|
13263
|
+
return reconcileLaggingTerminalDispatchLedger(terminalControl, ledger);
|
|
11315
13264
|
}
|
|
11316
13265
|
const dispatcherPid = Number(ledger.dispatcher_pid);
|
|
11317
13266
|
if (Number.isSafeInteger(dispatcherPid) &&
|
|
@@ -11358,14 +13307,32 @@ function reconcilePreparedTerminalDispatchLedger(terminalControl, ledger) {
|
|
|
11358
13307
|
}
|
|
11359
13308
|
if (storedMessageId === messageId &&
|
|
11360
13309
|
stringValue(submission?.message_id) === messageId) {
|
|
11361
|
-
if (submission
|
|
11362
|
-
|
|
13310
|
+
if (submission &&
|
|
13311
|
+
["submitted", "text_injected", "enter_dispatched", "agent_accepted"]
|
|
13312
|
+
.includes(String(submission.status))) {
|
|
13313
|
+
const submittedAt = stringValue(submission.agent_accepted_at) ??
|
|
13314
|
+
stringValue(submission.enter_dispatched_at) ??
|
|
13315
|
+
stringValue(submission.text_injected_at) ??
|
|
13316
|
+
stringValue(submission.submitted_at) ??
|
|
11363
13317
|
stringValue(conversation.updated_at) ??
|
|
11364
13318
|
new Date().toISOString();
|
|
11365
13319
|
saveTerminalBridgeDispatchLedger(terminalControl, {
|
|
11366
13320
|
...ledger,
|
|
11367
|
-
status:
|
|
11368
|
-
|
|
13321
|
+
status: submission.status,
|
|
13322
|
+
...(submission.text_injected_at
|
|
13323
|
+
? { text_injected_at: submission.text_injected_at }
|
|
13324
|
+
: {}),
|
|
13325
|
+
...(submission.enter_dispatched_at
|
|
13326
|
+
? { enter_dispatched_at: submission.enter_dispatched_at }
|
|
13327
|
+
: {}),
|
|
13328
|
+
...(submission.status === "agent_accepted"
|
|
13329
|
+
? {
|
|
13330
|
+
agent_accepted_at: submittedAt,
|
|
13331
|
+
acceptance_evidence: submission.acceptance_evidence
|
|
13332
|
+
}
|
|
13333
|
+
: submission.status === "submitted"
|
|
13334
|
+
? { submitted_at: submittedAt }
|
|
13335
|
+
: {}),
|
|
11369
13336
|
reason: "recovered from the durable conversation submission receipt"
|
|
11370
13337
|
});
|
|
11371
13338
|
}
|
|
@@ -11380,22 +13347,34 @@ function reconcilePreparedTerminalDispatchLedger(terminalControl, ledger) {
|
|
|
11380
13347
|
return loadTerminalBridgeDispatchLedger(terminalControl);
|
|
11381
13348
|
}
|
|
11382
13349
|
if (storedMessageId &&
|
|
11383
|
-
submission
|
|
13350
|
+
submission &&
|
|
13351
|
+
["submitted", "agent_accepted"].includes(String(submission.status)) &&
|
|
11384
13352
|
stringValue(submission.message_id) === storedMessageId) {
|
|
11385
13353
|
const requestText = String(nativeTakeover?.terminal_bridge_request_text ??
|
|
11386
13354
|
conversation.user_request ??
|
|
11387
13355
|
"");
|
|
11388
13356
|
saveTerminalBridgeDispatchLedger(terminalControl, {
|
|
11389
13357
|
...terminalBindingLedgerFields(conversation),
|
|
11390
|
-
status:
|
|
13358
|
+
status: submission.status,
|
|
11391
13359
|
generation_id: storedMessageId,
|
|
11392
13360
|
conversation_id: conversation.conversation_id,
|
|
11393
13361
|
message_id: storedMessageId,
|
|
13362
|
+
...(stringValue(submission.message_type)
|
|
13363
|
+
? { message_type: stringValue(submission.message_type) }
|
|
13364
|
+
: {}),
|
|
11394
13365
|
request_hash: terminalBridgeRequestFingerprint(requestText),
|
|
11395
13366
|
prepared_at: stringValue(submission.prepared_at) ??
|
|
11396
13367
|
stringValue(conversation.updated_at),
|
|
11397
|
-
|
|
11398
|
-
|
|
13368
|
+
...(submission.status === "agent_accepted"
|
|
13369
|
+
? {
|
|
13370
|
+
agent_accepted_at: stringValue(submission.agent_accepted_at) ??
|
|
13371
|
+
stringValue(conversation.updated_at),
|
|
13372
|
+
acceptance_evidence: submission.acceptance_evidence
|
|
13373
|
+
}
|
|
13374
|
+
: {
|
|
13375
|
+
submitted_at: stringValue(submission.submitted_at) ??
|
|
13376
|
+
stringValue(conversation.updated_at)
|
|
13377
|
+
}),
|
|
11399
13378
|
dispatcher_pid: null,
|
|
11400
13379
|
state_path: statePath,
|
|
11401
13380
|
event_log_path: stringValue(ledger.event_log_path) ??
|
|
@@ -11414,6 +13393,216 @@ function reconcilePreparedTerminalDispatchLedger(terminalControl, ledger) {
|
|
|
11414
13393
|
}
|
|
11415
13394
|
return loadTerminalBridgeDispatchLedger(terminalControl);
|
|
11416
13395
|
}
|
|
13396
|
+
function reconcileLaggingTerminalDispatchLedger(terminalControl, ledger) {
|
|
13397
|
+
if (!ledger ||
|
|
13398
|
+
terminalDispatchLedgerLooksLifecycle(ledger) ||
|
|
13399
|
+
![
|
|
13400
|
+
"text_injected",
|
|
13401
|
+
"enter_dispatched",
|
|
13402
|
+
"submitted",
|
|
13403
|
+
"agent_accepted",
|
|
13404
|
+
"not_accepted",
|
|
13405
|
+
"uncertain"
|
|
13406
|
+
].includes(String(ledger.status))) {
|
|
13407
|
+
return ledger;
|
|
13408
|
+
}
|
|
13409
|
+
const statePath = stringValue(ledger.state_path);
|
|
13410
|
+
const messageId = stringValue(ledger.message_id);
|
|
13411
|
+
if (!statePath || !messageId) {
|
|
13412
|
+
return ledger;
|
|
13413
|
+
}
|
|
13414
|
+
let conversation;
|
|
13415
|
+
try {
|
|
13416
|
+
conversation = loadState(statePath);
|
|
13417
|
+
}
|
|
13418
|
+
catch {
|
|
13419
|
+
return ledger;
|
|
13420
|
+
}
|
|
13421
|
+
const nativeTakeover = isRecord(conversation.native_session_takeover)
|
|
13422
|
+
? conversation.native_session_takeover
|
|
13423
|
+
: undefined;
|
|
13424
|
+
const submission = terminalBridgeSubmission(conversation);
|
|
13425
|
+
const storedControl = terminalControlFromTakeover(nativeTakeover);
|
|
13426
|
+
if (conversation.conversation_id !== stringValue(ledger.conversation_id) ||
|
|
13427
|
+
stringValue(nativeTakeover?.terminal_bridge_message_id) !== messageId ||
|
|
13428
|
+
stringValue(submission?.message_id) !== messageId ||
|
|
13429
|
+
!storedControl ||
|
|
13430
|
+
terminalBridgeRuntimeKey(storedControl) !==
|
|
13431
|
+
terminalBridgeRuntimeKey(terminalControl)) {
|
|
13432
|
+
return ledger;
|
|
13433
|
+
}
|
|
13434
|
+
const requestText = String(nativeTakeover?.terminal_bridge_request_text ?? conversation.user_request ?? "");
|
|
13435
|
+
const requestHash = terminalBridgeRequestFingerprint(requestText);
|
|
13436
|
+
if (stringValue(ledger.request_hash) !== requestHash ||
|
|
13437
|
+
stringValue(submission?.request_hash) !== requestHash) {
|
|
13438
|
+
return ledger;
|
|
13439
|
+
}
|
|
13440
|
+
const bindingFields = terminalBindingLedgerFields(conversation);
|
|
13441
|
+
for (const key of [
|
|
13442
|
+
"binding_id",
|
|
13443
|
+
"binding_generation",
|
|
13444
|
+
"native_thread_id",
|
|
13445
|
+
"store_dir"
|
|
13446
|
+
]) {
|
|
13447
|
+
if (ledger[key] !== undefined &&
|
|
13448
|
+
bindingFields[key] !== undefined &&
|
|
13449
|
+
String(ledger[key]) !== String(bindingFields[key])) {
|
|
13450
|
+
return ledger;
|
|
13451
|
+
}
|
|
13452
|
+
}
|
|
13453
|
+
const stateStatus = String(submission?.status ?? "");
|
|
13454
|
+
let stateAcceptanceEvidence;
|
|
13455
|
+
if (stateStatus === "agent_accepted") {
|
|
13456
|
+
try {
|
|
13457
|
+
stateAcceptanceEvidence = terminalAcceptanceEvidenceForConversation(conversation, requestText, submission?.acceptance_evidence);
|
|
13458
|
+
}
|
|
13459
|
+
catch {
|
|
13460
|
+
stateAcceptanceEvidence = undefined;
|
|
13461
|
+
}
|
|
13462
|
+
}
|
|
13463
|
+
let ledgerAcceptanceEvidence;
|
|
13464
|
+
let ledgerAcceptanceError;
|
|
13465
|
+
if (ledger.status === "agent_accepted") {
|
|
13466
|
+
try {
|
|
13467
|
+
ledgerAcceptanceEvidence = terminalAcceptanceEvidenceForConversation(conversation, requestText, ledger.acceptance_evidence);
|
|
13468
|
+
}
|
|
13469
|
+
catch (error) {
|
|
13470
|
+
ledgerAcceptanceError = error instanceof Error
|
|
13471
|
+
? error.message
|
|
13472
|
+
: String(error);
|
|
13473
|
+
}
|
|
13474
|
+
}
|
|
13475
|
+
if (ledgerAcceptanceEvidence) {
|
|
13476
|
+
if (!stateAcceptanceEvidence) {
|
|
13477
|
+
const acceptedAt = stringValue(ledger.agent_accepted_at) ?? new Date().toISOString();
|
|
13478
|
+
const acceptedConversation = withTerminalBridgeSubmission({
|
|
13479
|
+
conversation,
|
|
13480
|
+
messageId,
|
|
13481
|
+
requestText,
|
|
13482
|
+
status: "agent_accepted",
|
|
13483
|
+
preparedAt: stringValue(submission?.prepared_at) ??
|
|
13484
|
+
stringValue(ledger.prepared_at) ??
|
|
13485
|
+
acceptedAt,
|
|
13486
|
+
textInjectedAt: stringValue(submission?.text_injected_at) ??
|
|
13487
|
+
stringValue(ledger.text_injected_at),
|
|
13488
|
+
enterDispatchedAt: stringValue(submission?.enter_dispatched_at) ??
|
|
13489
|
+
stringValue(ledger.enter_dispatched_at),
|
|
13490
|
+
agentAcceptedAt: acceptedAt,
|
|
13491
|
+
acceptanceEvidence: ledgerAcceptanceEvidence
|
|
13492
|
+
});
|
|
13493
|
+
saveState(statePath, acceptedConversation);
|
|
13494
|
+
}
|
|
13495
|
+
return ledger;
|
|
13496
|
+
}
|
|
13497
|
+
if (stateAcceptanceEvidence) {
|
|
13498
|
+
const acceptedAt = stringValue(submission?.agent_accepted_at) ?? new Date().toISOString();
|
|
13499
|
+
saveTerminalBridgeDispatchLedger(terminalControl, {
|
|
13500
|
+
...ledger,
|
|
13501
|
+
...bindingFields,
|
|
13502
|
+
status: "agent_accepted",
|
|
13503
|
+
text_injected_at: submission?.text_injected_at,
|
|
13504
|
+
enter_dispatched_at: submission?.enter_dispatched_at,
|
|
13505
|
+
agent_accepted_at: acceptedAt,
|
|
13506
|
+
acceptance_evidence: stateAcceptanceEvidence,
|
|
13507
|
+
dispatcher_pid: null,
|
|
13508
|
+
reason: "recovered the strongest durable native acceptance receipt"
|
|
13509
|
+
});
|
|
13510
|
+
return loadTerminalBridgeDispatchLedger(terminalControl);
|
|
13511
|
+
}
|
|
13512
|
+
if (ledger.status === "agent_accepted") {
|
|
13513
|
+
const uncertainAt = new Date().toISOString();
|
|
13514
|
+
const uncertainConversation = withTerminalBridgeSubmission({
|
|
13515
|
+
conversation: {
|
|
13516
|
+
...conversation,
|
|
13517
|
+
status: "stalled",
|
|
13518
|
+
stalled_at: uncertainAt,
|
|
13519
|
+
stalled_reason: "stored native acceptance evidence is invalid",
|
|
13520
|
+
updated_at: uncertainAt
|
|
13521
|
+
},
|
|
13522
|
+
messageId,
|
|
13523
|
+
requestText,
|
|
13524
|
+
status: "uncertain",
|
|
13525
|
+
preparedAt: stringValue(submission?.prepared_at) ??
|
|
13526
|
+
stringValue(ledger.prepared_at) ??
|
|
13527
|
+
uncertainAt,
|
|
13528
|
+
textInjectedAt: stringValue(submission?.text_injected_at) ??
|
|
13529
|
+
stringValue(ledger.text_injected_at),
|
|
13530
|
+
enterDispatchedAt: stringValue(submission?.enter_dispatched_at) ??
|
|
13531
|
+
stringValue(ledger.enter_dispatched_at),
|
|
13532
|
+
uncertainAt,
|
|
13533
|
+
error: ledgerAcceptanceError ??
|
|
13534
|
+
"stored native acceptance evidence is invalid",
|
|
13535
|
+
lastProvenStage: "enter_dispatched"
|
|
13536
|
+
});
|
|
13537
|
+
saveState(statePath, uncertainConversation);
|
|
13538
|
+
saveTerminalBridgeDispatchLedger(terminalControl, {
|
|
13539
|
+
...ledger,
|
|
13540
|
+
...bindingFields,
|
|
13541
|
+
status: "uncertain",
|
|
13542
|
+
uncertain_at: uncertainAt,
|
|
13543
|
+
dispatcher_pid: null,
|
|
13544
|
+
reason: "stored native acceptance evidence is invalid"
|
|
13545
|
+
});
|
|
13546
|
+
return loadTerminalBridgeDispatchLedger(terminalControl);
|
|
13547
|
+
}
|
|
13548
|
+
const stateRank = terminalSubmissionProofRank(stateStatus, stringValue(submission?.last_proven_stage));
|
|
13549
|
+
const ledgerRank = terminalSubmissionProofRank(String(ledger.status), stringValue(ledger.last_proven_stage));
|
|
13550
|
+
const stateIsTerminal = ["not_accepted", "uncertain", "aborted"]
|
|
13551
|
+
.includes(stateStatus);
|
|
13552
|
+
if (!stateIsTerminal && stateRank <= ledgerRank) {
|
|
13553
|
+
return ledger;
|
|
13554
|
+
}
|
|
13555
|
+
if (![
|
|
13556
|
+
"text_injected",
|
|
13557
|
+
"enter_dispatched",
|
|
13558
|
+
"submitted",
|
|
13559
|
+
"not_accepted",
|
|
13560
|
+
"uncertain",
|
|
13561
|
+
"aborted"
|
|
13562
|
+
].includes(stateStatus)) {
|
|
13563
|
+
return ledger;
|
|
13564
|
+
}
|
|
13565
|
+
saveTerminalBridgeDispatchLedger(terminalControl, {
|
|
13566
|
+
...ledger,
|
|
13567
|
+
...bindingFields,
|
|
13568
|
+
status: stateStatus,
|
|
13569
|
+
...(submission?.text_injected_at
|
|
13570
|
+
? { text_injected_at: submission.text_injected_at }
|
|
13571
|
+
: {}),
|
|
13572
|
+
...(submission?.enter_dispatched_at
|
|
13573
|
+
? { enter_dispatched_at: submission.enter_dispatched_at }
|
|
13574
|
+
: {}),
|
|
13575
|
+
...(submission?.submitted_at
|
|
13576
|
+
? { submitted_at: submission.submitted_at }
|
|
13577
|
+
: {}),
|
|
13578
|
+
...(submission?.not_accepted_at
|
|
13579
|
+
? { not_accepted_at: submission.not_accepted_at }
|
|
13580
|
+
: {}),
|
|
13581
|
+
...(submission?.uncertain_at
|
|
13582
|
+
? { uncertain_at: submission.uncertain_at }
|
|
13583
|
+
: {}),
|
|
13584
|
+
...(submission?.aborted_at
|
|
13585
|
+
? { aborted_at: submission.aborted_at }
|
|
13586
|
+
: {}),
|
|
13587
|
+
last_proven_stage: submission?.last_proven_stage,
|
|
13588
|
+
...(stateIsTerminal ? { dispatcher_pid: null } : {}),
|
|
13589
|
+
reason: "recovered the strongest durable conversation proof level"
|
|
13590
|
+
});
|
|
13591
|
+
return loadTerminalBridgeDispatchLedger(terminalControl);
|
|
13592
|
+
}
|
|
13593
|
+
function terminalSubmissionProofRank(status, lastProven) {
|
|
13594
|
+
if (status === "agent_accepted" || lastProven === "agent_accepted") {
|
|
13595
|
+
return 3;
|
|
13596
|
+
}
|
|
13597
|
+
if (["enter_dispatched", "submitted", "not_accepted"].includes(status) ||
|
|
13598
|
+
lastProven === "enter_dispatched") {
|
|
13599
|
+
return 2;
|
|
13600
|
+
}
|
|
13601
|
+
if (status === "text_injected" || lastProven === "text_injected") {
|
|
13602
|
+
return 1;
|
|
13603
|
+
}
|
|
13604
|
+
return 0;
|
|
13605
|
+
}
|
|
11417
13606
|
async function recoverLifecycleFenceBeforeMutation({ options, terminal }) {
|
|
11418
13607
|
const ledger = loadTerminalBridgeDispatchLedger(terminal.terminalControl);
|
|
11419
13608
|
if (!ledger || ledger.status === "resolved") {
|
|
@@ -15428,6 +17617,8 @@ function redactCliOutput(value) {
|
|
|
15428
17617
|
}
|
|
15429
17618
|
if (key === "claude_transcript_anchor" ||
|
|
15430
17619
|
key === "claudeTranscriptAnchor" ||
|
|
17620
|
+
key === "codex_rollout_acceptance_anchor" ||
|
|
17621
|
+
key === "codexRolloutAcceptanceAnchor" ||
|
|
15431
17622
|
key === "claude_home" ||
|
|
15432
17623
|
key === "claudeHome") {
|
|
15433
17624
|
return [];
|