@scotthuang/agent-knock-knock 0.12.4 → 0.12.6
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 +33 -0
- package/README.md +15 -9
- package/dist/src/agent-session-provider.d.ts +34 -0
- package/dist/src/cli-core.js +5655 -1555
- package/dist/src/cli-core.js.map +1 -1
- package/dist/src/codex-local-session-provider.d.ts +3 -1
- package/dist/src/codex-local-session-provider.js +6 -0
- package/dist/src/codex-local-session-provider.js.map +1 -1
- package/dist/src/codex-store-adapter.d.ts +18 -1
- package/dist/src/codex-store-adapter.js +135 -16
- package/dist/src/codex-store-adapter.js.map +1 -1
- package/dist/src/deferred-foreground-transfer.d.ts +127 -0
- package/dist/src/deferred-foreground-transfer.js +1135 -0
- package/dist/src/deferred-foreground-transfer.js.map +1 -0
- package/dist/src/openclaw-plugin.js +22 -4
- package/dist/src/openclaw-plugin.js.map +1 -1
- package/dist/src/session-store.js +7 -5
- package/dist/src/session-store.js.map +1 -1
- package/dist/src/store.d.ts +3 -1
- package/dist/src/store.js +114 -14
- package/dist/src/store.js.map +1 -1
- package/dist/src/terminal-submission-acceptance.d.ts +51 -1
- package/dist/src/terminal-submission-acceptance.js +361 -71
- package/dist/src/terminal-submission-acceptance.js.map +1 -1
- package/package.json +1 -1
- package/templates/openclaw-skills/agent-knock-knock/SKILL.md +11 -8
|
@@ -0,0 +1,1135 @@
|
|
|
1
|
+
import { randomUUID } from "node:crypto";
|
|
2
|
+
import fs from "node:fs";
|
|
3
|
+
import path from "node:path";
|
|
4
|
+
import { assertManagedSessionId, assertManagedSessionState, isExactNativeThreadId, managedSessionBindingToken } from "./managed-session.js";
|
|
5
|
+
import { terminalEndpointIdentityFromEvidence } from "./terminal-control-ref.js";
|
|
6
|
+
import { assertStoreReadable, ensureDir, STORE_DEFERRED_FOREGROUND_TRANSFERS_DIRECTORY, withStoreWriterLease } from "./store.js";
|
|
7
|
+
export const DEFERRED_FOREGROUND_TRANSFER_SCHEMA = "agent-knock-knock/deferred-foreground-transfer";
|
|
8
|
+
export const DEFERRED_FOREGROUND_TRANSFER_VERSION = 2;
|
|
9
|
+
export const DEFERRED_FOREGROUND_TRANSFER_LEGACY_VERSION = 1;
|
|
10
|
+
const TRANSFER_STATE_FILE = "state.json";
|
|
11
|
+
const PRIVATE_FILE_MODE = 0o600;
|
|
12
|
+
const NO_FOLLOW_FLAG = typeof fs.constants.O_NOFOLLOW === "number"
|
|
13
|
+
? fs.constants.O_NOFOLLOW
|
|
14
|
+
: 0;
|
|
15
|
+
export class DeferredForegroundTransferConflictError extends Error {
|
|
16
|
+
code = "AKK_DEFERRED_FOREGROUND_TRANSFER_CONFLICT";
|
|
17
|
+
transferId;
|
|
18
|
+
expectedRevision;
|
|
19
|
+
actualRevision;
|
|
20
|
+
constructor(transferId, expectedRevision, actualRevision, detail) {
|
|
21
|
+
super(`deferred foreground transfer ${transferId} changed concurrently` +
|
|
22
|
+
` (expected revision ${String(expectedRevision)}, actual ` +
|
|
23
|
+
`${String(actualRevision)})` +
|
|
24
|
+
(detail ? `: ${detail}` : ""));
|
|
25
|
+
this.name = "DeferredForegroundTransferConflictError";
|
|
26
|
+
this.transferId = transferId;
|
|
27
|
+
this.expectedRevision = expectedRevision;
|
|
28
|
+
this.actualRevision = actualRevision;
|
|
29
|
+
}
|
|
30
|
+
}
|
|
31
|
+
export function createDeferredForegroundTransferId() {
|
|
32
|
+
return `deferred-transfer-${randomUUID()}`;
|
|
33
|
+
}
|
|
34
|
+
export function deferredForegroundTransfersDir(storeDir) {
|
|
35
|
+
return path.join(storeDir, STORE_DEFERRED_FOREGROUND_TRANSFERS_DIRECTORY);
|
|
36
|
+
}
|
|
37
|
+
export function pathsForDeferredForegroundTransfer(transferId, storeDir) {
|
|
38
|
+
assertRecordId(transferId, "deferred foreground transfer id");
|
|
39
|
+
const root = deferredForegroundTransfersDir(storeDir);
|
|
40
|
+
const directory = path.join(root, transferId);
|
|
41
|
+
assertContained(directory, root, "deferred foreground transfer directory");
|
|
42
|
+
return { directory, statePath: path.join(directory, TRANSFER_STATE_FILE) };
|
|
43
|
+
}
|
|
44
|
+
export function assertDeferredForegroundTransfer(value, expectedTransferId, options = {}) {
|
|
45
|
+
if (!isRecord(value)) {
|
|
46
|
+
throw new Error("deferred foreground transfer must be an object");
|
|
47
|
+
}
|
|
48
|
+
assertOnlyKeys(value, [
|
|
49
|
+
"schema",
|
|
50
|
+
"version",
|
|
51
|
+
"transfer_id",
|
|
52
|
+
"revision",
|
|
53
|
+
"status",
|
|
54
|
+
"input_stage",
|
|
55
|
+
"terminal_id",
|
|
56
|
+
"terminal_endpoint",
|
|
57
|
+
"process_pid",
|
|
58
|
+
"process_uuid",
|
|
59
|
+
"process_birth",
|
|
60
|
+
"workspace",
|
|
61
|
+
"source_session_id",
|
|
62
|
+
"source_expected_revision",
|
|
63
|
+
"source_binding_token",
|
|
64
|
+
"source_previous_last_transition_id",
|
|
65
|
+
"source_before_binding",
|
|
66
|
+
"source_kind",
|
|
67
|
+
"source_turn_history",
|
|
68
|
+
"target_session_id",
|
|
69
|
+
"target_expected_revision",
|
|
70
|
+
"previous_dispatch_status",
|
|
71
|
+
"previous_dispatch_fingerprint",
|
|
72
|
+
"target_prepared_revision",
|
|
73
|
+
"target_prepared_status",
|
|
74
|
+
"target_prepared_last_transition_id",
|
|
75
|
+
"target_prepared_binding_token",
|
|
76
|
+
"target_before_binding",
|
|
77
|
+
"request_hash",
|
|
78
|
+
"dispatcher_pid",
|
|
79
|
+
"prepared_at",
|
|
80
|
+
"source_reserved_at",
|
|
81
|
+
"target_prepared_at",
|
|
82
|
+
"dispatch_started_at",
|
|
83
|
+
"text_injected_at",
|
|
84
|
+
"enter_dispatched_at",
|
|
85
|
+
"agent_accepted_at",
|
|
86
|
+
"message_id",
|
|
87
|
+
"turn_id",
|
|
88
|
+
"state_path",
|
|
89
|
+
"target_native_thread_id",
|
|
90
|
+
"target_accepted_revision",
|
|
91
|
+
"target_accepted_status",
|
|
92
|
+
"target_accepted_binding_token",
|
|
93
|
+
"target_accepted_binding",
|
|
94
|
+
"source_pre_retirement_revision",
|
|
95
|
+
"source_pre_retirement_status",
|
|
96
|
+
"source_pre_retirement_binding_token",
|
|
97
|
+
"source_pre_retirement_binding",
|
|
98
|
+
"source_retirement",
|
|
99
|
+
"committed_at",
|
|
100
|
+
"target_after_revision",
|
|
101
|
+
"target_after_status",
|
|
102
|
+
"target_after_binding_token",
|
|
103
|
+
"source_after_revision",
|
|
104
|
+
"source_after_binding",
|
|
105
|
+
"source_after_binding_token",
|
|
106
|
+
"source_after_status",
|
|
107
|
+
"resolved_at",
|
|
108
|
+
"aborted_at",
|
|
109
|
+
"terminal_input_not_started_at",
|
|
110
|
+
"abort_cleanup_completed_at",
|
|
111
|
+
"abort_source_after_revision",
|
|
112
|
+
"abort_source_after_status",
|
|
113
|
+
"abort_source_after_binding_token",
|
|
114
|
+
"abort_source_after_binding",
|
|
115
|
+
"abort_target_after_status",
|
|
116
|
+
"abort_target_after_revision",
|
|
117
|
+
"abort_target_after_binding_token",
|
|
118
|
+
"abort_target_after_binding",
|
|
119
|
+
"uncertain_at",
|
|
120
|
+
"recovered_at",
|
|
121
|
+
"error",
|
|
122
|
+
"do_not_retry"
|
|
123
|
+
], "deferred foreground transfer");
|
|
124
|
+
if (value.schema !== DEFERRED_FOREGROUND_TRANSFER_SCHEMA ||
|
|
125
|
+
![
|
|
126
|
+
DEFERRED_FOREGROUND_TRANSFER_LEGACY_VERSION,
|
|
127
|
+
DEFERRED_FOREGROUND_TRANSFER_VERSION
|
|
128
|
+
].includes(value.version)) {
|
|
129
|
+
throw new Error("deferred foreground transfer has an unsupported schema or version");
|
|
130
|
+
}
|
|
131
|
+
assertRecordId(value.transfer_id, "deferred foreground transfer id");
|
|
132
|
+
if (expectedTransferId !== undefined &&
|
|
133
|
+
value.transfer_id !== expectedTransferId) {
|
|
134
|
+
throw new Error(`deferred foreground transfer id ${String(value.transfer_id)} does not ` +
|
|
135
|
+
`match ${expectedTransferId}`);
|
|
136
|
+
}
|
|
137
|
+
if (value.revision === undefined
|
|
138
|
+
? !options.allowMissingRevision
|
|
139
|
+
: !isPositiveSafeInteger(value.revision)) {
|
|
140
|
+
throw new Error("deferred foreground transfer revision must be a positive safe integer");
|
|
141
|
+
}
|
|
142
|
+
if (!TRANSFER_STATUSES.has(value.status)) {
|
|
143
|
+
throw new Error("deferred foreground transfer status is invalid");
|
|
144
|
+
}
|
|
145
|
+
if (!INPUT_STAGES.has(value.input_stage)) {
|
|
146
|
+
throw new Error("deferred foreground transfer input_stage is invalid");
|
|
147
|
+
}
|
|
148
|
+
assertNonEmptyString(value.terminal_id, "deferred transfer terminal_id");
|
|
149
|
+
if (!terminalEndpointIdentityFromEvidence(value.terminal_endpoint)) {
|
|
150
|
+
throw new Error("deferred transfer terminal_endpoint is invalid");
|
|
151
|
+
}
|
|
152
|
+
if (!isPositiveSafeInteger(value.process_pid)) {
|
|
153
|
+
throw new Error("deferred transfer process_pid must be positive");
|
|
154
|
+
}
|
|
155
|
+
assertNonEmptyString(value.process_uuid, "deferred transfer process_uuid");
|
|
156
|
+
assertNonEmptyString(value.process_birth, "deferred transfer process_birth");
|
|
157
|
+
assertAbsolutePath(value.workspace, "deferred transfer workspace");
|
|
158
|
+
assertManagedSessionId(value.source_session_id);
|
|
159
|
+
if (!isPositiveSafeInteger(value.source_expected_revision)) {
|
|
160
|
+
throw new Error("deferred transfer source_expected_revision must be positive");
|
|
161
|
+
}
|
|
162
|
+
assertSha256(value.source_binding_token, "deferred transfer source binding token");
|
|
163
|
+
assertOptionalNonEmptyString(value.source_previous_last_transition_id, "deferred transfer source previous transition");
|
|
164
|
+
assertBinding(value.source_before_binding, "deferred transfer source binding");
|
|
165
|
+
const sourceKind = value.version === DEFERRED_FOREGROUND_TRANSFER_LEGACY_VERSION
|
|
166
|
+
? "status_card_only"
|
|
167
|
+
: value.source_kind;
|
|
168
|
+
if (value.version === DEFERRED_FOREGROUND_TRANSFER_LEGACY_VERSION
|
|
169
|
+
? value.source_kind !== undefined || value.source_turn_history !== undefined
|
|
170
|
+
: !["status_card_only", "candidate_rollout_quiescent"].includes(String(sourceKind))) {
|
|
171
|
+
throw new Error("deferred transfer source kind is invalid");
|
|
172
|
+
}
|
|
173
|
+
const sourceTurnHistory = value.source_turn_history;
|
|
174
|
+
if (sourceKind === "candidate_rollout_quiescent") {
|
|
175
|
+
if (!Array.isArray(sourceTurnHistory) ||
|
|
176
|
+
sourceTurnHistory.length > 128) {
|
|
177
|
+
throw new Error("candidate-rollout deferred source requires exact Turn history authority");
|
|
178
|
+
}
|
|
179
|
+
const seenTurnIds = new Set();
|
|
180
|
+
for (const turn of sourceTurnHistory) {
|
|
181
|
+
if (!isRecord(turn) ||
|
|
182
|
+
Object.keys(turn).sort().join(",") !== [
|
|
183
|
+
"binding_generation",
|
|
184
|
+
"binding_id",
|
|
185
|
+
"native_thread_id",
|
|
186
|
+
"status",
|
|
187
|
+
"turn_fingerprint",
|
|
188
|
+
"turn_id",
|
|
189
|
+
"updated_at"
|
|
190
|
+
].join(",") ||
|
|
191
|
+
!isSafeRecordId(turn.turn_id) ||
|
|
192
|
+
!["idle", "failed", "closed", "cancelled"].includes(String(turn.status)) ||
|
|
193
|
+
!isValidTimestamp(turn.updated_at) ||
|
|
194
|
+
!isSafeRecordId(turn.binding_id) ||
|
|
195
|
+
!isPositiveSafeInteger(turn.binding_generation) ||
|
|
196
|
+
!isExactNativeThreadId(turn.native_thread_id) ||
|
|
197
|
+
turn.binding_id !== value.source_before_binding.binding_id ||
|
|
198
|
+
turn.binding_generation !== value.source_before_binding.generation ||
|
|
199
|
+
turn.native_thread_id !== value.source_before_binding.native_thread_id ||
|
|
200
|
+
typeof turn.turn_fingerprint !== "string" ||
|
|
201
|
+
!/^[0-9a-f]{64}$/u.test(turn.turn_fingerprint) ||
|
|
202
|
+
seenTurnIds.has(String(turn.turn_id))) {
|
|
203
|
+
throw new Error("candidate-rollout deferred source Turn history is invalid");
|
|
204
|
+
}
|
|
205
|
+
seenTurnIds.add(String(turn.turn_id));
|
|
206
|
+
}
|
|
207
|
+
}
|
|
208
|
+
else if (sourceTurnHistory !== undefined) {
|
|
209
|
+
throw new Error("status-card deferred source cannot carry Turn history authority");
|
|
210
|
+
}
|
|
211
|
+
const statusCardSource = sourceKind === "status_card_only";
|
|
212
|
+
const candidateRolloutSource = sourceKind === "candidate_rollout_quiescent";
|
|
213
|
+
if (value.source_before_binding.native_process.pid !== value.process_pid ||
|
|
214
|
+
value.source_before_binding.native_process.process_uuid !==
|
|
215
|
+
value.process_uuid ||
|
|
216
|
+
value.source_before_binding.native_process.process_birth !==
|
|
217
|
+
value.process_birth ||
|
|
218
|
+
value.source_before_binding.terminal_id !== value.terminal_id ||
|
|
219
|
+
JSON.stringify(value.source_before_binding.terminal_endpoint) !==
|
|
220
|
+
JSON.stringify(value.terminal_endpoint) ||
|
|
221
|
+
!isExactNativeThreadId(value.source_before_binding.native_thread_id) ||
|
|
222
|
+
(statusCardSource &&
|
|
223
|
+
(value.source_before_binding.native_process.rollout !== undefined ||
|
|
224
|
+
!value.source_before_binding.native_process.evidence.includes("codex_status_card"))) ||
|
|
225
|
+
(candidateRolloutSource &&
|
|
226
|
+
value.source_before_binding.native_process.rollout === undefined) ||
|
|
227
|
+
(statusCardSource &&
|
|
228
|
+
value.source_previous_last_transition_id !== undefined) ||
|
|
229
|
+
managedSessionBindingToken({
|
|
230
|
+
session_id: value.source_session_id,
|
|
231
|
+
status: "bound",
|
|
232
|
+
binding: value.source_before_binding
|
|
233
|
+
}) !== value.source_binding_token) {
|
|
234
|
+
throw new Error("deferred transfer source binding disagrees with terminal/process authority");
|
|
235
|
+
}
|
|
236
|
+
assertManagedSessionId(value.target_session_id);
|
|
237
|
+
if (value.target_session_id === value.source_session_id) {
|
|
238
|
+
throw new Error("deferred transfer source and target Sessions must differ");
|
|
239
|
+
}
|
|
240
|
+
if (value.target_expected_revision !== null) {
|
|
241
|
+
throw new Error("deferred transfer target_expected_revision must be null");
|
|
242
|
+
}
|
|
243
|
+
if (!["none", "resolved"].includes(value.previous_dispatch_status)) {
|
|
244
|
+
throw new Error("deferred transfer previous_dispatch_status must be none or resolved");
|
|
245
|
+
}
|
|
246
|
+
assertSha256(value.previous_dispatch_fingerprint, "deferred transfer previous dispatch fingerprint");
|
|
247
|
+
if (value.target_prepared_revision !== undefined &&
|
|
248
|
+
!isPositiveSafeInteger(value.target_prepared_revision)) {
|
|
249
|
+
throw new Error("deferred transfer target_prepared_revision must be positive");
|
|
250
|
+
}
|
|
251
|
+
if (value.target_prepared_status !== undefined &&
|
|
252
|
+
value.target_prepared_status !== "transitioning") {
|
|
253
|
+
throw new Error("deferred transfer target_prepared_status must be transitioning");
|
|
254
|
+
}
|
|
255
|
+
assertOptionalNonEmptyString(value.target_prepared_last_transition_id, "deferred transfer target prepared last transition");
|
|
256
|
+
if (value.target_prepared_binding_token !== undefined) {
|
|
257
|
+
assertSha256(value.target_prepared_binding_token, "deferred transfer target prepared binding token");
|
|
258
|
+
}
|
|
259
|
+
if (value.target_before_binding !== undefined) {
|
|
260
|
+
assertBinding(value.target_before_binding, "deferred transfer target before binding");
|
|
261
|
+
if (value.target_before_binding.native_thread_id !== undefined ||
|
|
262
|
+
value.target_before_binding.native_process.rollout !== undefined ||
|
|
263
|
+
value.target_before_binding.native_process.pid !== value.process_pid ||
|
|
264
|
+
value.target_before_binding.native_process.process_uuid !==
|
|
265
|
+
value.process_uuid ||
|
|
266
|
+
value.target_before_binding.native_process.process_birth !==
|
|
267
|
+
value.process_birth ||
|
|
268
|
+
value.target_before_binding.terminal_id !== value.terminal_id ||
|
|
269
|
+
JSON.stringify(value.target_before_binding.terminal_endpoint) !==
|
|
270
|
+
JSON.stringify(value.terminal_endpoint) ||
|
|
271
|
+
managedSessionBindingToken({
|
|
272
|
+
session_id: value.target_session_id,
|
|
273
|
+
status: "transitioning",
|
|
274
|
+
binding: value.target_before_binding
|
|
275
|
+
}) !== value.target_prepared_binding_token) {
|
|
276
|
+
throw new Error("deferred transfer provisional target binding is not exact zero-UUID authority");
|
|
277
|
+
}
|
|
278
|
+
}
|
|
279
|
+
if (value.target_prepared_last_transition_id !== undefined &&
|
|
280
|
+
value.target_prepared_last_transition_id !== value.transfer_id) {
|
|
281
|
+
throw new Error("deferred transfer provisional target must be fenced by its transfer id");
|
|
282
|
+
}
|
|
283
|
+
assertSha256(value.request_hash, "deferred transfer request_hash");
|
|
284
|
+
if (!isPositiveSafeInteger(value.dispatcher_pid)) {
|
|
285
|
+
throw new Error("deferred transfer dispatcher_pid must be positive");
|
|
286
|
+
}
|
|
287
|
+
assertTimestamp(value.prepared_at, "deferred transfer prepared_at");
|
|
288
|
+
for (const field of [
|
|
289
|
+
"source_reserved_at",
|
|
290
|
+
"target_prepared_at",
|
|
291
|
+
"dispatch_started_at",
|
|
292
|
+
"text_injected_at",
|
|
293
|
+
"enter_dispatched_at",
|
|
294
|
+
"agent_accepted_at",
|
|
295
|
+
"committed_at",
|
|
296
|
+
"resolved_at",
|
|
297
|
+
"aborted_at",
|
|
298
|
+
"terminal_input_not_started_at",
|
|
299
|
+
"abort_cleanup_completed_at",
|
|
300
|
+
"uncertain_at",
|
|
301
|
+
"recovered_at"
|
|
302
|
+
]) {
|
|
303
|
+
assertOptionalTimestamp(value[field], `deferred transfer ${field}`);
|
|
304
|
+
}
|
|
305
|
+
assertOptionalNonEmptyString(value.message_id, "deferred transfer message_id");
|
|
306
|
+
assertOptionalNonEmptyString(value.turn_id, "deferred transfer turn_id");
|
|
307
|
+
if (value.state_path !== undefined) {
|
|
308
|
+
assertAbsolutePath(value.state_path, "deferred transfer state_path");
|
|
309
|
+
}
|
|
310
|
+
assertOptionalNonEmptyString(value.error, "deferred transfer error");
|
|
311
|
+
if (value.do_not_retry !== undefined && typeof value.do_not_retry !== "boolean") {
|
|
312
|
+
throw new Error("deferred transfer do_not_retry must be boolean");
|
|
313
|
+
}
|
|
314
|
+
const requiresTargetPrepared = [
|
|
315
|
+
"target_prepared",
|
|
316
|
+
"dispatch_started",
|
|
317
|
+
"committed",
|
|
318
|
+
"resolved",
|
|
319
|
+
"uncertain"
|
|
320
|
+
].includes(String(value.status));
|
|
321
|
+
const targetPreparedFields = [
|
|
322
|
+
value.target_prepared_at,
|
|
323
|
+
value.target_prepared_revision,
|
|
324
|
+
value.target_prepared_status,
|
|
325
|
+
value.target_prepared_last_transition_id,
|
|
326
|
+
value.target_prepared_binding_token,
|
|
327
|
+
value.target_before_binding,
|
|
328
|
+
value.message_id,
|
|
329
|
+
value.turn_id,
|
|
330
|
+
value.state_path
|
|
331
|
+
];
|
|
332
|
+
const hasAnyTargetPreparedField = targetPreparedFields.some((field) => field !== undefined);
|
|
333
|
+
const hasAllTargetPreparedFields = targetPreparedFields.every((field) => field !== undefined);
|
|
334
|
+
if (hasAnyTargetPreparedField !== hasAllTargetPreparedFields ||
|
|
335
|
+
(requiresTargetPrepared && !hasAllTargetPreparedFields)) {
|
|
336
|
+
throw new Error(`${String(value.status)} deferred transfer requires its target/Turn identity`);
|
|
337
|
+
}
|
|
338
|
+
if (hasAllTargetPreparedFields) {
|
|
339
|
+
if (!value.source_reserved_at ||
|
|
340
|
+
value.target_prepared_revision !== 1 ||
|
|
341
|
+
value.target_prepared_status !== "transitioning" ||
|
|
342
|
+
value.target_prepared_last_transition_id !== value.transfer_id) {
|
|
343
|
+
throw new Error("deferred transfer provisional target requires exact create-only fence evidence");
|
|
344
|
+
}
|
|
345
|
+
}
|
|
346
|
+
if ([
|
|
347
|
+
"source_reserved",
|
|
348
|
+
"target_prepared",
|
|
349
|
+
"dispatch_started",
|
|
350
|
+
"committed",
|
|
351
|
+
"resolved",
|
|
352
|
+
"uncertain"
|
|
353
|
+
]
|
|
354
|
+
.includes(String(value.status)) &&
|
|
355
|
+
!value.source_reserved_at) {
|
|
356
|
+
throw new Error(`${String(value.status)} deferred transfer requires source_reserved_at`);
|
|
357
|
+
}
|
|
358
|
+
if (value.status === "prepared" &&
|
|
359
|
+
(value.source_reserved_at !== undefined || hasAnyTargetPreparedField)) {
|
|
360
|
+
throw new Error("prepared deferred transfer cannot carry reservation evidence");
|
|
361
|
+
}
|
|
362
|
+
if (value.status === "source_reserved" && hasAnyTargetPreparedField) {
|
|
363
|
+
throw new Error("source_reserved deferred transfer cannot carry target evidence");
|
|
364
|
+
}
|
|
365
|
+
if (["dispatch_started", "committed", "resolved", "uncertain"].includes(String(value.status)) &&
|
|
366
|
+
(!value.dispatch_started_at || value.input_stage === "none")) {
|
|
367
|
+
throw new Error(`${String(value.status)} deferred transfer requires dispatch-start evidence`);
|
|
368
|
+
}
|
|
369
|
+
assertInputStageEvidence(value);
|
|
370
|
+
if ([
|
|
371
|
+
"prepared",
|
|
372
|
+
"source_reserved",
|
|
373
|
+
"target_prepared"
|
|
374
|
+
].includes(String(value.status)) &&
|
|
375
|
+
value.input_stage !== "none") {
|
|
376
|
+
throw new Error(`${String(value.status)} deferred transfer cannot carry input evidence`);
|
|
377
|
+
}
|
|
378
|
+
const provedTerminalInputNotStarted = ["aborted", "abort_resolved"].includes(String(value.status)) &&
|
|
379
|
+
value.input_stage === "dispatch_started" &&
|
|
380
|
+
value.terminal_input_not_started_at !== undefined;
|
|
381
|
+
if (["aborted", "abort_resolved"].includes(String(value.status)) &&
|
|
382
|
+
value.input_stage !== "none" &&
|
|
383
|
+
!provedTerminalInputNotStarted) {
|
|
384
|
+
throw new Error(`${String(value.status)} deferred transfer requires exact no-input proof`);
|
|
385
|
+
}
|
|
386
|
+
assertTimestampOrder(value);
|
|
387
|
+
const committed = ["committed", "resolved"].includes(String(value.status));
|
|
388
|
+
const committedFields = [
|
|
389
|
+
value.target_native_thread_id,
|
|
390
|
+
value.target_accepted_revision,
|
|
391
|
+
value.target_accepted_status,
|
|
392
|
+
value.target_accepted_binding_token,
|
|
393
|
+
value.target_accepted_binding,
|
|
394
|
+
value.source_pre_retirement_revision,
|
|
395
|
+
value.source_pre_retirement_status,
|
|
396
|
+
value.source_pre_retirement_binding_token,
|
|
397
|
+
value.source_pre_retirement_binding,
|
|
398
|
+
value.source_retirement,
|
|
399
|
+
value.committed_at
|
|
400
|
+
];
|
|
401
|
+
const hasAnyCommittedField = committedFields.some((field) => field !== undefined);
|
|
402
|
+
const hasAllCommittedFields = committedFields.every((field) => field !== undefined);
|
|
403
|
+
if (committed
|
|
404
|
+
? !hasAllCommittedFields ||
|
|
405
|
+
value.input_stage !== "agent_accepted" ||
|
|
406
|
+
!value.agent_accepted_at
|
|
407
|
+
: hasAnyCommittedField) {
|
|
408
|
+
throw new Error("deferred transfer target identity is allowed only on a fully accepted commit");
|
|
409
|
+
}
|
|
410
|
+
if (committed) {
|
|
411
|
+
if (!isPositiveSafeInteger(value.target_accepted_revision) ||
|
|
412
|
+
value.target_accepted_revision !==
|
|
413
|
+
Number(value.target_prepared_revision) + 1 ||
|
|
414
|
+
value.target_accepted_status !== "transitioning" ||
|
|
415
|
+
!isPositiveSafeInteger(value.source_pre_retirement_revision) ||
|
|
416
|
+
value.source_pre_retirement_revision !==
|
|
417
|
+
value.source_expected_revision + (value.source_retirement === "binding_scrubbed_same_native_thread"
|
|
418
|
+
? 2
|
|
419
|
+
: 1) ||
|
|
420
|
+
value.source_pre_retirement_status !== "transitioning" ||
|
|
421
|
+
!isExactNativeThreadId(value.target_native_thread_id)) {
|
|
422
|
+
throw new Error("deferred transfer commit lacks exact transitioning Session evidence");
|
|
423
|
+
}
|
|
424
|
+
assertSha256(value.target_accepted_binding_token, "deferred transfer target accepted binding token");
|
|
425
|
+
assertSha256(value.source_pre_retirement_binding_token, "deferred transfer source pre-retirement binding token");
|
|
426
|
+
assertBinding(value.target_accepted_binding, "deferred transfer target accepted binding");
|
|
427
|
+
assertBinding(value.source_pre_retirement_binding, "deferred transfer source pre-retirement binding");
|
|
428
|
+
if (value.target_accepted_binding.native_thread_id !==
|
|
429
|
+
value.target_native_thread_id ||
|
|
430
|
+
value.target_accepted_binding.terminal_id !== value.terminal_id ||
|
|
431
|
+
value.target_accepted_binding.native_process.pid !== value.process_pid ||
|
|
432
|
+
value.target_accepted_binding.native_process.process_uuid !==
|
|
433
|
+
value.process_uuid ||
|
|
434
|
+
value.target_accepted_binding.native_process.process_birth !==
|
|
435
|
+
value.process_birth ||
|
|
436
|
+
JSON.stringify(value.target_accepted_binding.terminal_endpoint) !==
|
|
437
|
+
JSON.stringify(value.terminal_endpoint) ||
|
|
438
|
+
!bindingIsMonotonicTargetRefinement(value.target_before_binding, value.target_accepted_binding) ||
|
|
439
|
+
managedSessionBindingToken({
|
|
440
|
+
session_id: value.target_session_id,
|
|
441
|
+
status: "transitioning",
|
|
442
|
+
binding: value.target_accepted_binding
|
|
443
|
+
}) !== value.target_accepted_binding_token ||
|
|
444
|
+
(value.target_native_thread_id ===
|
|
445
|
+
value.source_before_binding.native_thread_id
|
|
446
|
+
? value.source_retirement !==
|
|
447
|
+
"binding_scrubbed_same_native_thread" ||
|
|
448
|
+
!bindingIsScrubbedSourceReplacement(value.source_before_binding, value.source_pre_retirement_binding)
|
|
449
|
+
: value.source_retirement !== "binding_retained"
|
|
450
|
+
|| JSON.stringify(value.source_pre_retirement_binding) !==
|
|
451
|
+
JSON.stringify(value.source_before_binding)) ||
|
|
452
|
+
managedSessionBindingToken({
|
|
453
|
+
session_id: value.source_session_id,
|
|
454
|
+
status: "transitioning",
|
|
455
|
+
binding: value.source_pre_retirement_binding
|
|
456
|
+
}) !== value.source_pre_retirement_binding_token) {
|
|
457
|
+
throw new Error("deferred transfer binding disagrees with committed authority");
|
|
458
|
+
}
|
|
459
|
+
}
|
|
460
|
+
const resolvedFields = [
|
|
461
|
+
value.target_after_revision,
|
|
462
|
+
value.target_after_status,
|
|
463
|
+
value.target_after_binding_token,
|
|
464
|
+
value.source_after_revision,
|
|
465
|
+
value.source_after_binding,
|
|
466
|
+
value.source_after_binding_token,
|
|
467
|
+
value.source_after_status,
|
|
468
|
+
value.resolved_at
|
|
469
|
+
];
|
|
470
|
+
const hasAnyResolvedField = resolvedFields.some((field) => field !== undefined);
|
|
471
|
+
const hasAllResolvedFields = resolvedFields.every((field) => field !== undefined);
|
|
472
|
+
if (value.status === "resolved"
|
|
473
|
+
? !hasAllResolvedFields
|
|
474
|
+
: hasAnyResolvedField) {
|
|
475
|
+
throw new Error("resolved deferred transfer requires exact final Session evidence");
|
|
476
|
+
}
|
|
477
|
+
if (value.status === "resolved") {
|
|
478
|
+
if (!isPositiveSafeInteger(value.target_after_revision) ||
|
|
479
|
+
value.target_after_revision !==
|
|
480
|
+
Number(value.target_accepted_revision) + 1 ||
|
|
481
|
+
value.target_after_status !== "bound" ||
|
|
482
|
+
!isPositiveSafeInteger(value.source_after_revision) ||
|
|
483
|
+
value.source_after_revision !==
|
|
484
|
+
Number(value.source_pre_retirement_revision) + 1 ||
|
|
485
|
+
value.source_after_status !== "detached" ||
|
|
486
|
+
JSON.stringify(value.source_after_binding) !==
|
|
487
|
+
JSON.stringify(value.source_pre_retirement_binding)) {
|
|
488
|
+
throw new Error("resolved deferred transfer revision/status evidence is invalid");
|
|
489
|
+
}
|
|
490
|
+
assertBinding(value.source_after_binding, "deferred transfer retired source binding");
|
|
491
|
+
assertSha256(value.target_after_binding_token, "deferred transfer target final binding token");
|
|
492
|
+
assertSha256(value.source_after_binding_token, "deferred transfer retired source binding token");
|
|
493
|
+
if (managedSessionBindingToken({
|
|
494
|
+
session_id: value.target_session_id,
|
|
495
|
+
status: "bound",
|
|
496
|
+
binding: value.target_accepted_binding
|
|
497
|
+
}) !== value.target_after_binding_token ||
|
|
498
|
+
managedSessionBindingToken({
|
|
499
|
+
session_id: value.source_session_id,
|
|
500
|
+
status: "detached",
|
|
501
|
+
binding: value.source_after_binding
|
|
502
|
+
}) !== value.source_after_binding_token) {
|
|
503
|
+
throw new Error("resolved deferred transfer final binding tokens are inconsistent");
|
|
504
|
+
}
|
|
505
|
+
}
|
|
506
|
+
if (["aborted", "abort_resolved"].includes(String(value.status))
|
|
507
|
+
? !value.aborted_at || !(value.input_stage === "none" || provedTerminalInputNotStarted)
|
|
508
|
+
: value.aborted_at !== undefined) {
|
|
509
|
+
throw new Error("only a proven zero-input deferred transfer can carry an aborted receipt");
|
|
510
|
+
}
|
|
511
|
+
if (value.terminal_input_not_started_at !== undefined
|
|
512
|
+
? !provedTerminalInputNotStarted ||
|
|
513
|
+
value.text_injected_at !== undefined ||
|
|
514
|
+
value.enter_dispatched_at !== undefined ||
|
|
515
|
+
value.agent_accepted_at !== undefined
|
|
516
|
+
: value.input_stage === "dispatch_started" &&
|
|
517
|
+
["aborted", "abort_resolved"].includes(String(value.status))) {
|
|
518
|
+
throw new Error("terminal-input-not-started proof must fence only a dispatch intent");
|
|
519
|
+
}
|
|
520
|
+
const abortCleanupSourceFields = [
|
|
521
|
+
value.abort_cleanup_completed_at,
|
|
522
|
+
value.abort_source_after_revision,
|
|
523
|
+
value.abort_source_after_status,
|
|
524
|
+
value.abort_source_after_binding_token,
|
|
525
|
+
value.abort_source_after_binding,
|
|
526
|
+
value.abort_target_after_status
|
|
527
|
+
];
|
|
528
|
+
const abortCleanupTargetFields = [
|
|
529
|
+
value.abort_target_after_revision,
|
|
530
|
+
value.abort_target_after_binding_token,
|
|
531
|
+
value.abort_target_after_binding
|
|
532
|
+
];
|
|
533
|
+
const hasAnyAbortCleanupField = [
|
|
534
|
+
...abortCleanupSourceFields,
|
|
535
|
+
...abortCleanupTargetFields
|
|
536
|
+
].some((field) => field !== undefined);
|
|
537
|
+
if (value.status === "abort_resolved") {
|
|
538
|
+
if (abortCleanupSourceFields.some((field) => field === undefined)) {
|
|
539
|
+
throw new Error("resolved deferred abort requires exact source cleanup evidence");
|
|
540
|
+
}
|
|
541
|
+
const allowedSourceRevisions = value.source_reserved_at
|
|
542
|
+
? [value.source_expected_revision + 2]
|
|
543
|
+
: [
|
|
544
|
+
value.source_expected_revision,
|
|
545
|
+
// The source reservation is the first Store mutation. A crash after
|
|
546
|
+
// that Session CAS but before source_reserved_at is published leaves
|
|
547
|
+
// the prepared transfer able to prove the exact +2 restore receipt.
|
|
548
|
+
value.source_expected_revision + 2
|
|
549
|
+
];
|
|
550
|
+
if (!isPositiveSafeInteger(value.abort_source_after_revision) ||
|
|
551
|
+
!allowedSourceRevisions.includes(value.abort_source_after_revision) ||
|
|
552
|
+
value.abort_source_after_status !== "bound" ||
|
|
553
|
+
JSON.stringify(value.abort_source_after_binding) !==
|
|
554
|
+
JSON.stringify(value.source_before_binding)) {
|
|
555
|
+
throw new Error("resolved deferred abort source cleanup evidence is invalid");
|
|
556
|
+
}
|
|
557
|
+
assertSha256(value.abort_source_after_binding_token, "resolved deferred abort source binding token");
|
|
558
|
+
assertBinding(value.abort_source_after_binding, "resolved deferred abort source binding");
|
|
559
|
+
if (managedSessionBindingToken({
|
|
560
|
+
session_id: value.source_session_id,
|
|
561
|
+
status: "bound",
|
|
562
|
+
binding: value.abort_source_after_binding
|
|
563
|
+
}) !== value.abort_source_after_binding_token) {
|
|
564
|
+
throw new Error("resolved deferred abort source binding token is inconsistent");
|
|
565
|
+
}
|
|
566
|
+
if (value.abort_target_after_status === "absent") {
|
|
567
|
+
if (abortCleanupTargetFields.some((field) => field !== undefined)) {
|
|
568
|
+
throw new Error("absent deferred abort target cannot carry Session cleanup evidence");
|
|
569
|
+
}
|
|
570
|
+
}
|
|
571
|
+
else if (value.abort_target_after_status === "detached") {
|
|
572
|
+
if (abortCleanupTargetFields.some((field) => field === undefined) ||
|
|
573
|
+
!value.target_before_binding ||
|
|
574
|
+
!isPositiveSafeInteger(value.abort_target_after_revision) ||
|
|
575
|
+
value.abort_target_after_revision !==
|
|
576
|
+
Number(value.target_prepared_revision) + 1 ||
|
|
577
|
+
JSON.stringify(value.abort_target_after_binding) !==
|
|
578
|
+
JSON.stringify(value.target_before_binding)) {
|
|
579
|
+
throw new Error("resolved deferred abort target cleanup evidence is invalid");
|
|
580
|
+
}
|
|
581
|
+
assertSha256(value.abort_target_after_binding_token, "resolved deferred abort target binding token");
|
|
582
|
+
assertBinding(value.abort_target_after_binding, "resolved deferred abort target binding");
|
|
583
|
+
if (managedSessionBindingToken({
|
|
584
|
+
session_id: value.target_session_id,
|
|
585
|
+
status: "detached",
|
|
586
|
+
binding: value.abort_target_after_binding
|
|
587
|
+
}) !== value.abort_target_after_binding_token) {
|
|
588
|
+
throw new Error("resolved deferred abort target binding token is inconsistent");
|
|
589
|
+
}
|
|
590
|
+
}
|
|
591
|
+
}
|
|
592
|
+
else if (hasAnyAbortCleanupField) {
|
|
593
|
+
throw new Error("abort cleanup evidence is allowed only on an abort_resolved receipt");
|
|
594
|
+
}
|
|
595
|
+
if (value.status === "uncertain"
|
|
596
|
+
? !value.uncertain_at || value.do_not_retry !== true ||
|
|
597
|
+
value.input_stage === "none"
|
|
598
|
+
: ["committed", "resolved"].includes(String(value.status)) &&
|
|
599
|
+
value.uncertain_at !== undefined
|
|
600
|
+
? !value.recovered_at || value.do_not_retry !== true
|
|
601
|
+
: value.uncertain_at !== undefined ||
|
|
602
|
+
value.recovered_at !== undefined ||
|
|
603
|
+
value.do_not_retry !== undefined) {
|
|
604
|
+
throw new Error("uncertain deferred transfer requires possible input and do_not_retry=true");
|
|
605
|
+
}
|
|
606
|
+
if (value.error !== undefined &&
|
|
607
|
+
![
|
|
608
|
+
"aborted",
|
|
609
|
+
"abort_resolved",
|
|
610
|
+
"uncertain",
|
|
611
|
+
...(value.recovered_at ? ["committed", "resolved"] : [])
|
|
612
|
+
].includes(String(value.status))) {
|
|
613
|
+
throw new Error("deferred transfer error is allowed only on failure/recovery receipts");
|
|
614
|
+
}
|
|
615
|
+
}
|
|
616
|
+
export function saveDeferredForegroundTransfer(storeDir, value, options) {
|
|
617
|
+
assertDeferredForegroundTransfer(value, undefined, {
|
|
618
|
+
allowMissingRevision: true
|
|
619
|
+
});
|
|
620
|
+
assertExpectedRevision(options?.expectedRevision);
|
|
621
|
+
const paths = pathsForDeferredForegroundTransfer(value.transfer_id, storeDir);
|
|
622
|
+
return withStoreWriterLease(storeDir, () => {
|
|
623
|
+
const current = tryLoadDeferredForegroundTransfer(storeDir, value.transfer_id);
|
|
624
|
+
const actualRevision = current?.revision ?? null;
|
|
625
|
+
if (actualRevision !== options.expectedRevision) {
|
|
626
|
+
throw new DeferredForegroundTransferConflictError(value.transfer_id, options.expectedRevision, actualRevision);
|
|
627
|
+
}
|
|
628
|
+
if (value.revision !== undefined &&
|
|
629
|
+
value.revision !== (options.expectedRevision ?? 1)) {
|
|
630
|
+
throw new DeferredForegroundTransferConflictError(value.transfer_id, options.expectedRevision, actualRevision, `candidate carries revision ${value.revision}`);
|
|
631
|
+
}
|
|
632
|
+
if (current) {
|
|
633
|
+
assertTransferAdvance(current, value);
|
|
634
|
+
}
|
|
635
|
+
else if (value.status !== "prepared" || value.input_stage !== "none") {
|
|
636
|
+
throw new Error("a deferred foreground transfer must be created as zero-input prepared");
|
|
637
|
+
}
|
|
638
|
+
const next = {
|
|
639
|
+
...value,
|
|
640
|
+
revision: (actualRevision ?? 0) + 1
|
|
641
|
+
};
|
|
642
|
+
assertDeferredForegroundTransfer(next);
|
|
643
|
+
atomicSaveJson(paths.statePath, next);
|
|
644
|
+
return next;
|
|
645
|
+
});
|
|
646
|
+
}
|
|
647
|
+
export function loadDeferredForegroundTransfer(storeDir, transferId) {
|
|
648
|
+
assertStoreReadable(storeDir);
|
|
649
|
+
const paths = pathsForDeferredForegroundTransfer(transferId, storeDir);
|
|
650
|
+
const parsed = readJsonFile(paths.statePath, "deferred foreground transfer");
|
|
651
|
+
assertDeferredForegroundTransfer(parsed, transferId);
|
|
652
|
+
return parsed;
|
|
653
|
+
}
|
|
654
|
+
export function tryLoadDeferredForegroundTransfer(storeDir, transferId) {
|
|
655
|
+
try {
|
|
656
|
+
return loadDeferredForegroundTransfer(storeDir, transferId);
|
|
657
|
+
}
|
|
658
|
+
catch (error) {
|
|
659
|
+
if (isNodeError(error, "ENOENT")) {
|
|
660
|
+
return undefined;
|
|
661
|
+
}
|
|
662
|
+
throw error;
|
|
663
|
+
}
|
|
664
|
+
}
|
|
665
|
+
export function listDeferredForegroundTransfers(storeDir) {
|
|
666
|
+
if (!fs.existsSync(storeDir)) {
|
|
667
|
+
return [];
|
|
668
|
+
}
|
|
669
|
+
assertStoreReadable(storeDir);
|
|
670
|
+
const root = deferredForegroundTransfersDir(storeDir);
|
|
671
|
+
if (!fs.existsSync(root)) {
|
|
672
|
+
return [];
|
|
673
|
+
}
|
|
674
|
+
assertRealDirectory(root, "deferred foreground transfer root");
|
|
675
|
+
return fs.readdirSync(root, { withFileTypes: true })
|
|
676
|
+
.map((entry) => {
|
|
677
|
+
if (entry.isSymbolicLink() || !entry.isDirectory()) {
|
|
678
|
+
throw new Error("deferred foreground transfer root may contain only real record " +
|
|
679
|
+
`directories: ${entry.name}`);
|
|
680
|
+
}
|
|
681
|
+
return loadDeferredForegroundTransfer(storeDir, entry.name);
|
|
682
|
+
})
|
|
683
|
+
.sort((left, right) => left.transfer_id.localeCompare(right.transfer_id));
|
|
684
|
+
}
|
|
685
|
+
function assertTransferAdvance(current, candidate) {
|
|
686
|
+
const immutable = [
|
|
687
|
+
"schema",
|
|
688
|
+
"version",
|
|
689
|
+
"transfer_id",
|
|
690
|
+
"terminal_id",
|
|
691
|
+
"terminal_endpoint",
|
|
692
|
+
"process_pid",
|
|
693
|
+
"process_uuid",
|
|
694
|
+
"process_birth",
|
|
695
|
+
"workspace",
|
|
696
|
+
"source_session_id",
|
|
697
|
+
"source_expected_revision",
|
|
698
|
+
"source_binding_token",
|
|
699
|
+
"source_previous_last_transition_id",
|
|
700
|
+
"source_before_binding",
|
|
701
|
+
"source_kind",
|
|
702
|
+
"source_turn_history",
|
|
703
|
+
"target_session_id",
|
|
704
|
+
"target_expected_revision",
|
|
705
|
+
"previous_dispatch_status",
|
|
706
|
+
"previous_dispatch_fingerprint",
|
|
707
|
+
"request_hash",
|
|
708
|
+
"dispatcher_pid",
|
|
709
|
+
"prepared_at"
|
|
710
|
+
];
|
|
711
|
+
const changed = immutable.find((field) => JSON.stringify(current[field]) !== JSON.stringify(candidate[field]));
|
|
712
|
+
if (changed) {
|
|
713
|
+
throw new Error(`deferred foreground transfer ${current.transfer_id} cannot change ` +
|
|
714
|
+
`immutable ${changed}`);
|
|
715
|
+
}
|
|
716
|
+
const allowed = {
|
|
717
|
+
prepared: ["source_reserved", "aborted"],
|
|
718
|
+
source_reserved: ["target_prepared", "aborted"],
|
|
719
|
+
target_prepared: ["dispatch_started", "uncertain", "aborted"],
|
|
720
|
+
dispatch_started: [
|
|
721
|
+
"dispatch_started",
|
|
722
|
+
"committed",
|
|
723
|
+
"uncertain",
|
|
724
|
+
"aborted"
|
|
725
|
+
],
|
|
726
|
+
committed: ["resolved"],
|
|
727
|
+
resolved: [],
|
|
728
|
+
aborted: ["abort_resolved"],
|
|
729
|
+
abort_resolved: [],
|
|
730
|
+
uncertain: ["committed"]
|
|
731
|
+
};
|
|
732
|
+
if (!allowed[current.status].includes(candidate.status)) {
|
|
733
|
+
throw new Error(`deferred foreground transfer cannot move from ${current.status} to ` +
|
|
734
|
+
candidate.status);
|
|
735
|
+
}
|
|
736
|
+
if (INPUT_STAGE_RANK[candidate.input_stage] <
|
|
737
|
+
INPUT_STAGE_RANK[current.input_stage]) {
|
|
738
|
+
throw new Error("deferred foreground transfer input proof cannot regress");
|
|
739
|
+
}
|
|
740
|
+
for (const field of [
|
|
741
|
+
"target_prepared_revision",
|
|
742
|
+
"target_prepared_status",
|
|
743
|
+
"target_prepared_last_transition_id",
|
|
744
|
+
"target_prepared_binding_token",
|
|
745
|
+
"target_before_binding",
|
|
746
|
+
"message_id",
|
|
747
|
+
"turn_id",
|
|
748
|
+
"state_path"
|
|
749
|
+
]) {
|
|
750
|
+
if (current[field] !== undefined &&
|
|
751
|
+
JSON.stringify(current[field]) !== JSON.stringify(candidate[field])) {
|
|
752
|
+
throw new Error(`deferred foreground transfer ${current.transfer_id} cannot change ${field}`);
|
|
753
|
+
}
|
|
754
|
+
}
|
|
755
|
+
for (const field of [
|
|
756
|
+
"source_reserved_at",
|
|
757
|
+
"target_prepared_at",
|
|
758
|
+
"dispatch_started_at",
|
|
759
|
+
"text_injected_at",
|
|
760
|
+
"enter_dispatched_at",
|
|
761
|
+
"agent_accepted_at",
|
|
762
|
+
"target_native_thread_id",
|
|
763
|
+
"target_accepted_revision",
|
|
764
|
+
"target_accepted_status",
|
|
765
|
+
"target_accepted_binding_token",
|
|
766
|
+
"target_accepted_binding",
|
|
767
|
+
"source_pre_retirement_revision",
|
|
768
|
+
"source_pre_retirement_status",
|
|
769
|
+
"source_pre_retirement_binding_token",
|
|
770
|
+
"source_pre_retirement_binding",
|
|
771
|
+
"source_retirement",
|
|
772
|
+
"committed_at",
|
|
773
|
+
"target_after_revision",
|
|
774
|
+
"target_after_status",
|
|
775
|
+
"target_after_binding_token",
|
|
776
|
+
"source_after_revision",
|
|
777
|
+
"source_after_binding",
|
|
778
|
+
"source_after_binding_token",
|
|
779
|
+
"source_after_status",
|
|
780
|
+
"resolved_at",
|
|
781
|
+
"aborted_at",
|
|
782
|
+
"terminal_input_not_started_at",
|
|
783
|
+
"abort_cleanup_completed_at",
|
|
784
|
+
"abort_source_after_revision",
|
|
785
|
+
"abort_source_after_status",
|
|
786
|
+
"abort_source_after_binding_token",
|
|
787
|
+
"abort_source_after_binding",
|
|
788
|
+
"abort_target_after_status",
|
|
789
|
+
"abort_target_after_revision",
|
|
790
|
+
"abort_target_after_binding_token",
|
|
791
|
+
"abort_target_after_binding",
|
|
792
|
+
"uncertain_at",
|
|
793
|
+
"recovered_at",
|
|
794
|
+
"error",
|
|
795
|
+
"do_not_retry"
|
|
796
|
+
]) {
|
|
797
|
+
if (current[field] !== undefined &&
|
|
798
|
+
JSON.stringify(current[field]) !== JSON.stringify(candidate[field])) {
|
|
799
|
+
throw new Error(`deferred foreground transfer ${current.transfer_id} cannot change ${field}`);
|
|
800
|
+
}
|
|
801
|
+
}
|
|
802
|
+
if (candidate.status === "aborted" &&
|
|
803
|
+
current.input_stage !== "none" &&
|
|
804
|
+
!(current.input_stage === "dispatch_started" &&
|
|
805
|
+
candidate.input_stage === "dispatch_started" &&
|
|
806
|
+
candidate.terminal_input_not_started_at !== undefined)) {
|
|
807
|
+
throw new Error("deferred foreground transfer cannot abort after input may start");
|
|
808
|
+
}
|
|
809
|
+
}
|
|
810
|
+
function bindingIsMonotonicTargetRefinement(before, after) {
|
|
811
|
+
return before.binding_id === after.binding_id &&
|
|
812
|
+
before.generation === after.generation &&
|
|
813
|
+
before.terminal_id === after.terminal_id &&
|
|
814
|
+
JSON.stringify(before.terminal_control) ===
|
|
815
|
+
JSON.stringify(after.terminal_control) &&
|
|
816
|
+
JSON.stringify(before.terminal_endpoint) ===
|
|
817
|
+
JSON.stringify(after.terminal_endpoint) &&
|
|
818
|
+
before.native_process.pid === after.native_process.pid &&
|
|
819
|
+
before.native_process.process_uuid === after.native_process.process_uuid &&
|
|
820
|
+
before.native_process.process_birth === after.native_process.process_birth &&
|
|
821
|
+
before.bound_at === after.bound_at &&
|
|
822
|
+
before.native_thread_id === undefined &&
|
|
823
|
+
before.native_process.rollout === undefined &&
|
|
824
|
+
isExactNativeThreadId(after.native_thread_id) &&
|
|
825
|
+
after.native_process.rollout !== undefined;
|
|
826
|
+
}
|
|
827
|
+
function bindingIsScrubbedSourceReplacement(before, after) {
|
|
828
|
+
return before.binding_id !== after.binding_id &&
|
|
829
|
+
after.generation === before.generation + 1 &&
|
|
830
|
+
before.terminal_id === after.terminal_id &&
|
|
831
|
+
JSON.stringify(before.terminal_control) ===
|
|
832
|
+
JSON.stringify(after.terminal_control) &&
|
|
833
|
+
JSON.stringify(before.terminal_endpoint) ===
|
|
834
|
+
JSON.stringify(after.terminal_endpoint) &&
|
|
835
|
+
before.native_process.pid === after.native_process.pid &&
|
|
836
|
+
before.native_process.process_uuid === after.native_process.process_uuid &&
|
|
837
|
+
before.native_process.process_birth === after.native_process.process_birth &&
|
|
838
|
+
after.native_thread_id === undefined &&
|
|
839
|
+
after.native_process.rollout === undefined;
|
|
840
|
+
}
|
|
841
|
+
function assertInputStageEvidence(value) {
|
|
842
|
+
const stage = INPUT_STAGE_RANK[value.input_stage];
|
|
843
|
+
for (const [field, minimum] of [
|
|
844
|
+
["dispatch_started_at", 1],
|
|
845
|
+
["text_injected_at", 2],
|
|
846
|
+
["enter_dispatched_at", 3],
|
|
847
|
+
["agent_accepted_at", 4]
|
|
848
|
+
]) {
|
|
849
|
+
if ((value[field] !== undefined) !== (stage >= minimum)) {
|
|
850
|
+
throw new Error(`deferred transfer ${value.input_stage} has inconsistent ${field}`);
|
|
851
|
+
}
|
|
852
|
+
}
|
|
853
|
+
}
|
|
854
|
+
function assertTimestampOrder(value) {
|
|
855
|
+
const inputTimeline = [
|
|
856
|
+
value.prepared_at,
|
|
857
|
+
value.source_reserved_at,
|
|
858
|
+
value.target_prepared_at,
|
|
859
|
+
value.dispatch_started_at,
|
|
860
|
+
value.text_injected_at,
|
|
861
|
+
value.enter_dispatched_at,
|
|
862
|
+
value.agent_accepted_at
|
|
863
|
+
].filter((candidate) => candidate !== undefined);
|
|
864
|
+
assertMonotonicTimestamps(inputTimeline);
|
|
865
|
+
const latestInputEvidence = inputTimeline[inputTimeline.length - 1];
|
|
866
|
+
if (value.aborted_at) {
|
|
867
|
+
assertTimestampNotBefore(value.aborted_at, latestInputEvidence);
|
|
868
|
+
}
|
|
869
|
+
if (value.terminal_input_not_started_at) {
|
|
870
|
+
assertTimestampNotBefore(value.terminal_input_not_started_at, value.dispatch_started_at);
|
|
871
|
+
assertTimestampNotBefore(value.aborted_at, value.terminal_input_not_started_at);
|
|
872
|
+
}
|
|
873
|
+
if (value.abort_cleanup_completed_at) {
|
|
874
|
+
assertTimestampNotBefore(value.abort_cleanup_completed_at, value.aborted_at);
|
|
875
|
+
}
|
|
876
|
+
if (value.uncertain_at) {
|
|
877
|
+
// Recovery may prove acceptance after uncertainty was recorded, so the
|
|
878
|
+
// uncertainty timestamp is ordered after the evidence known at its own
|
|
879
|
+
// stage, not unconditionally after a later agent_accepted_at.
|
|
880
|
+
const latestPossibleDispatch = value.enter_dispatched_at ??
|
|
881
|
+
value.text_injected_at ?? value.dispatch_started_at ??
|
|
882
|
+
value.target_prepared_at ?? value.source_reserved_at ?? value.prepared_at;
|
|
883
|
+
assertTimestampNotBefore(value.uncertain_at, latestPossibleDispatch);
|
|
884
|
+
if (value.status === "uncertain" && value.input_stage === "agent_accepted") {
|
|
885
|
+
assertTimestampNotBefore(value.uncertain_at, value.agent_accepted_at);
|
|
886
|
+
}
|
|
887
|
+
}
|
|
888
|
+
if (value.committed_at) {
|
|
889
|
+
assertTimestampNotBefore(value.committed_at, value.agent_accepted_at);
|
|
890
|
+
assertTimestampNotBefore(value.committed_at, value.uncertain_at);
|
|
891
|
+
}
|
|
892
|
+
if (value.recovered_at) {
|
|
893
|
+
assertTimestampNotBefore(value.recovered_at, value.uncertain_at);
|
|
894
|
+
assertTimestampNotBefore(value.recovered_at, value.committed_at);
|
|
895
|
+
}
|
|
896
|
+
if (value.resolved_at) {
|
|
897
|
+
assertTimestampNotBefore(value.resolved_at, value.committed_at);
|
|
898
|
+
assertTimestampNotBefore(value.resolved_at, value.recovered_at);
|
|
899
|
+
}
|
|
900
|
+
}
|
|
901
|
+
function assertMonotonicTimestamps(values) {
|
|
902
|
+
for (let index = 1; index < values.length; index += 1) {
|
|
903
|
+
assertTimestampNotBefore(values[index], values[index - 1]);
|
|
904
|
+
}
|
|
905
|
+
}
|
|
906
|
+
function assertTimestampNotBefore(candidate, boundary) {
|
|
907
|
+
if (candidate !== undefined &&
|
|
908
|
+
boundary !== undefined &&
|
|
909
|
+
Date.parse(candidate) < Date.parse(boundary)) {
|
|
910
|
+
throw new Error("deferred foreground transfer timestamps must be monotonic");
|
|
911
|
+
}
|
|
912
|
+
}
|
|
913
|
+
function assertBinding(value, label) {
|
|
914
|
+
const now = new Date().toISOString();
|
|
915
|
+
assertManagedSessionState({
|
|
916
|
+
schema: "agent-knock-knock/session",
|
|
917
|
+
version: 1,
|
|
918
|
+
session_id: "session-deferred-binding-validation",
|
|
919
|
+
revision: 1,
|
|
920
|
+
agent: "codex",
|
|
921
|
+
workspace: "/deferred-binding-validation",
|
|
922
|
+
status: "bound",
|
|
923
|
+
binding: value,
|
|
924
|
+
lineage: { created_by: "attach" },
|
|
925
|
+
created_at: now,
|
|
926
|
+
updated_at: now
|
|
927
|
+
});
|
|
928
|
+
}
|
|
929
|
+
function atomicSaveJson(filePath, value) {
|
|
930
|
+
const root = path.dirname(path.dirname(filePath));
|
|
931
|
+
const directory = path.dirname(filePath);
|
|
932
|
+
ensureDir(root);
|
|
933
|
+
assertRealDirectory(root, "deferred foreground transfer root");
|
|
934
|
+
ensureDir(directory);
|
|
935
|
+
assertRealDirectory(directory, "deferred foreground transfer directory");
|
|
936
|
+
assertRegularOrAbsent(filePath, "deferred foreground transfer state");
|
|
937
|
+
const tempPath = path.join(directory, `.${path.basename(filePath)}.${process.pid}.${randomUUID()}.tmp`);
|
|
938
|
+
let fd;
|
|
939
|
+
try {
|
|
940
|
+
fd = fs.openSync(tempPath, fs.constants.O_CREAT |
|
|
941
|
+
fs.constants.O_EXCL |
|
|
942
|
+
fs.constants.O_WRONLY |
|
|
943
|
+
NO_FOLLOW_FLAG, PRIVATE_FILE_MODE);
|
|
944
|
+
fs.fchmodSync(fd, PRIVATE_FILE_MODE);
|
|
945
|
+
fs.writeFileSync(fd, `${JSON.stringify(value, null, 2)}\n`, "utf8");
|
|
946
|
+
fs.fsyncSync(fd);
|
|
947
|
+
fs.closeSync(fd);
|
|
948
|
+
fd = undefined;
|
|
949
|
+
assertRegularOrAbsent(filePath, "deferred foreground transfer state");
|
|
950
|
+
fs.renameSync(tempPath, filePath);
|
|
951
|
+
fs.chmodSync(filePath, PRIVATE_FILE_MODE);
|
|
952
|
+
fsyncDirectory(directory);
|
|
953
|
+
}
|
|
954
|
+
finally {
|
|
955
|
+
if (fd !== undefined) {
|
|
956
|
+
fs.closeSync(fd);
|
|
957
|
+
}
|
|
958
|
+
try {
|
|
959
|
+
fs.unlinkSync(tempPath);
|
|
960
|
+
}
|
|
961
|
+
catch (error) {
|
|
962
|
+
if (!isNodeError(error, "ENOENT")) {
|
|
963
|
+
throw error;
|
|
964
|
+
}
|
|
965
|
+
}
|
|
966
|
+
}
|
|
967
|
+
}
|
|
968
|
+
function readJsonFile(filePath, label) {
|
|
969
|
+
assertRealDirectory(path.dirname(path.dirname(filePath)), `${label} root`);
|
|
970
|
+
assertRealDirectory(path.dirname(filePath), `${label} directory`);
|
|
971
|
+
const stat = fs.lstatSync(filePath);
|
|
972
|
+
if (stat.isSymbolicLink() || !stat.isFile()) {
|
|
973
|
+
throw new Error(`${label} must be a regular file: ${filePath}`);
|
|
974
|
+
}
|
|
975
|
+
const fd = fs.openSync(filePath, fs.constants.O_RDONLY | NO_FOLLOW_FLAG);
|
|
976
|
+
try {
|
|
977
|
+
if (!fs.fstatSync(fd).isFile()) {
|
|
978
|
+
throw new Error(`${label} must be a regular file: ${filePath}`);
|
|
979
|
+
}
|
|
980
|
+
return JSON.parse(fs.readFileSync(fd, "utf8"));
|
|
981
|
+
}
|
|
982
|
+
finally {
|
|
983
|
+
fs.closeSync(fd);
|
|
984
|
+
}
|
|
985
|
+
}
|
|
986
|
+
function assertExpectedRevision(value) {
|
|
987
|
+
if (value !== null && !isPositiveSafeInteger(value)) {
|
|
988
|
+
throw new Error("expectedRevision must be null or a positive safe integer");
|
|
989
|
+
}
|
|
990
|
+
}
|
|
991
|
+
function assertRecordId(value, label) {
|
|
992
|
+
if (typeof value !== "string" ||
|
|
993
|
+
!value ||
|
|
994
|
+
value === "." ||
|
|
995
|
+
value === ".." ||
|
|
996
|
+
value.includes("\0") ||
|
|
997
|
+
path.posix.basename(value) !== value ||
|
|
998
|
+
path.win32.basename(value) !== value ||
|
|
999
|
+
path.isAbsolute(value)) {
|
|
1000
|
+
throw new Error(`${label} is not safe for storage: ${String(value)}`);
|
|
1001
|
+
}
|
|
1002
|
+
}
|
|
1003
|
+
function isSafeRecordId(value) {
|
|
1004
|
+
try {
|
|
1005
|
+
assertRecordId(value, "record id");
|
|
1006
|
+
return true;
|
|
1007
|
+
}
|
|
1008
|
+
catch {
|
|
1009
|
+
return false;
|
|
1010
|
+
}
|
|
1011
|
+
}
|
|
1012
|
+
function isValidTimestamp(value) {
|
|
1013
|
+
return typeof value === "string" && Number.isFinite(Date.parse(value));
|
|
1014
|
+
}
|
|
1015
|
+
function assertContained(candidate, root, label) {
|
|
1016
|
+
const relative = path.relative(path.resolve(root), path.resolve(candidate));
|
|
1017
|
+
if (!relative || relative.startsWith("..") || path.isAbsolute(relative)) {
|
|
1018
|
+
throw new Error(`${label} escapes its Store root`);
|
|
1019
|
+
}
|
|
1020
|
+
}
|
|
1021
|
+
function assertRealDirectory(directory, label) {
|
|
1022
|
+
const stat = fs.lstatSync(directory);
|
|
1023
|
+
if (stat.isSymbolicLink() || !stat.isDirectory()) {
|
|
1024
|
+
throw new Error(`${label} must be a real directory: ${directory}`);
|
|
1025
|
+
}
|
|
1026
|
+
}
|
|
1027
|
+
function assertRegularOrAbsent(filePath, label) {
|
|
1028
|
+
try {
|
|
1029
|
+
const stat = fs.lstatSync(filePath);
|
|
1030
|
+
if (stat.isSymbolicLink() || !stat.isFile()) {
|
|
1031
|
+
throw new Error(`${label} must be a regular file: ${filePath}`);
|
|
1032
|
+
}
|
|
1033
|
+
}
|
|
1034
|
+
catch (error) {
|
|
1035
|
+
if (!isNodeError(error, "ENOENT")) {
|
|
1036
|
+
throw error;
|
|
1037
|
+
}
|
|
1038
|
+
}
|
|
1039
|
+
}
|
|
1040
|
+
function fsyncDirectory(directory) {
|
|
1041
|
+
let fd;
|
|
1042
|
+
try {
|
|
1043
|
+
fd = fs.openSync(directory, fs.constants.O_RDONLY | NO_FOLLOW_FLAG);
|
|
1044
|
+
fs.fsyncSync(fd);
|
|
1045
|
+
}
|
|
1046
|
+
catch (error) {
|
|
1047
|
+
const code = error instanceof Error
|
|
1048
|
+
? error.code
|
|
1049
|
+
: undefined;
|
|
1050
|
+
if (!["EINVAL", "ENOTSUP", "EPERM", "EISDIR"].includes(String(code))) {
|
|
1051
|
+
throw error;
|
|
1052
|
+
}
|
|
1053
|
+
}
|
|
1054
|
+
finally {
|
|
1055
|
+
if (fd !== undefined) {
|
|
1056
|
+
fs.closeSync(fd);
|
|
1057
|
+
}
|
|
1058
|
+
}
|
|
1059
|
+
}
|
|
1060
|
+
function assertOnlyKeys(value, allowed, label) {
|
|
1061
|
+
const known = new Set(allowed);
|
|
1062
|
+
const unsupported = Object.keys(value).find((key) => !known.has(key));
|
|
1063
|
+
if (unsupported !== undefined) {
|
|
1064
|
+
throw new Error(`${label} contains unsupported field ${unsupported}`);
|
|
1065
|
+
}
|
|
1066
|
+
}
|
|
1067
|
+
function assertNonEmptyString(value, label) {
|
|
1068
|
+
if (typeof value !== "string" || value.trim() === "" || value.includes("\0")) {
|
|
1069
|
+
throw new Error(`${label} must be a non-empty string`);
|
|
1070
|
+
}
|
|
1071
|
+
}
|
|
1072
|
+
function assertOptionalNonEmptyString(value, label) {
|
|
1073
|
+
if (value !== undefined) {
|
|
1074
|
+
assertNonEmptyString(value, label);
|
|
1075
|
+
}
|
|
1076
|
+
}
|
|
1077
|
+
function assertAbsolutePath(value, label) {
|
|
1078
|
+
assertNonEmptyString(value, label);
|
|
1079
|
+
if (!path.isAbsolute(value)) {
|
|
1080
|
+
throw new Error(`${label} must be absolute`);
|
|
1081
|
+
}
|
|
1082
|
+
}
|
|
1083
|
+
function assertSha256(value, label) {
|
|
1084
|
+
if (typeof value !== "string" || !/^[0-9a-f]{64}$/u.test(value)) {
|
|
1085
|
+
throw new Error(`${label} must be SHA-256 hex`);
|
|
1086
|
+
}
|
|
1087
|
+
}
|
|
1088
|
+
function assertTimestamp(value, label) {
|
|
1089
|
+
if (typeof value !== "string" ||
|
|
1090
|
+
value.length === 0 ||
|
|
1091
|
+
!Number.isFinite(Date.parse(value))) {
|
|
1092
|
+
throw new Error(`${label} must be a valid timestamp`);
|
|
1093
|
+
}
|
|
1094
|
+
}
|
|
1095
|
+
function assertOptionalTimestamp(value, label) {
|
|
1096
|
+
if (value !== undefined) {
|
|
1097
|
+
assertTimestamp(value, label);
|
|
1098
|
+
}
|
|
1099
|
+
}
|
|
1100
|
+
function isPositiveSafeInteger(value) {
|
|
1101
|
+
return typeof value === "number" && Number.isSafeInteger(value) && value > 0;
|
|
1102
|
+
}
|
|
1103
|
+
function isRecord(value) {
|
|
1104
|
+
return value !== null && typeof value === "object" && !Array.isArray(value);
|
|
1105
|
+
}
|
|
1106
|
+
function isNodeError(error, code) {
|
|
1107
|
+
return error instanceof Error &&
|
|
1108
|
+
error.code === code;
|
|
1109
|
+
}
|
|
1110
|
+
const TRANSFER_STATUSES = new Set([
|
|
1111
|
+
"prepared",
|
|
1112
|
+
"source_reserved",
|
|
1113
|
+
"target_prepared",
|
|
1114
|
+
"dispatch_started",
|
|
1115
|
+
"committed",
|
|
1116
|
+
"resolved",
|
|
1117
|
+
"aborted",
|
|
1118
|
+
"abort_resolved",
|
|
1119
|
+
"uncertain"
|
|
1120
|
+
]);
|
|
1121
|
+
const INPUT_STAGES = new Set([
|
|
1122
|
+
"none",
|
|
1123
|
+
"dispatch_started",
|
|
1124
|
+
"text_injected",
|
|
1125
|
+
"enter_dispatched",
|
|
1126
|
+
"agent_accepted"
|
|
1127
|
+
]);
|
|
1128
|
+
const INPUT_STAGE_RANK = {
|
|
1129
|
+
none: 0,
|
|
1130
|
+
dispatch_started: 1,
|
|
1131
|
+
text_injected: 2,
|
|
1132
|
+
enter_dispatched: 3,
|
|
1133
|
+
agent_accepted: 4
|
|
1134
|
+
};
|
|
1135
|
+
//# sourceMappingURL=deferred-foreground-transfer.js.map
|