dsh-live-compact 1.1.3 → 1.1.5

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "dsh-live-compact",
3
- "version": "1.1.3",
3
+ "version": "1.1.5",
4
4
  "description": "In-place newest-turn compaction for DeepSeek Harness. Compress while the task runs and keep the prefix.",
5
5
  "license": "MIT",
6
6
  "author": "MerkyorLynn <268568828+MerkyorLynn@users.noreply.github.com>",
@@ -17,6 +17,7 @@
17
17
  "./shadow-search": "./shadow-search.js"
18
18
  },
19
19
  "files": [
20
+ "CHANGELOG.md",
20
21
  "shell.js",
21
22
  "adapter.js",
22
23
  "settings.js",
@@ -26,6 +27,7 @@
26
27
  "plan.js",
27
28
  "raw.js",
28
29
  "shadow.js",
30
+ "shadow-save-worker.mjs",
29
31
  "shadow-search.js",
30
32
  "cooldown.js",
31
33
  "thunder.js",
@@ -41,7 +43,8 @@
41
43
  "compaction"
42
44
  ],
43
45
  "scripts": {
44
- "test:gate": "node test/p0-gate.mjs && node test/p0-blockers.mjs && node test/excerpt.mjs && node --test test/public-exports.test.mjs test/cooldown.test.mjs test/gate-config.test.mjs test/drain-stopline.mjs test/scan-once.mjs test/issue-19.mjs test/issue-20.mjs test/windows-acl.test.mjs test/issue-27.mjs test/issue-29.mjs test/issue-29-notice.mjs test/client-settings.test.mjs test/settings-integration.test.mjs test/runtime-lifecycle.mjs test/shadow-lifecycle.mjs test/finalize-over-32000.mjs",
46
+ "test:gate": "node test/p0-gate.mjs && node test/p0-blockers.mjs && node test/excerpt.mjs && node test/shadow-worker.mjs && node test/shadow-worker-pool.mjs && node test/shadow-worker-fuse.mjs && node --test test/failed-diagnostics.test.mjs test/public-exports.test.mjs test/cooldown.test.mjs test/gate-config.test.mjs test/drain-stopline.mjs test/scan-once.mjs test/issue-19.mjs test/issue-20.mjs test/issue-34.mjs test/windows-acl.test.mjs test/issue-27.mjs test/issue-29.mjs test/issue-29-notice.mjs test/client-settings.test.mjs test/settings-integration.test.mjs test/runtime-lifecycle.mjs test/shadow-lifecycle.mjs test/finalize-over-32000.mjs",
47
+ "test:compat41": "node test/compat-cordis41.mjs",
45
48
  "test:live-gate": "python3 -B -m unittest discover -s test/live-fill -p test_*.py"
46
49
  },
47
50
  "engines": {
package/plan.js CHANGED
@@ -241,6 +241,8 @@ function failureBody(text) {
241
241
  if (raw.startsWith("{")) {
242
242
  try {
243
243
  const parsed = JSON.parse(raw);
244
+ // A bounded failed result keeps every field, not only the two streams.
245
+ if (raw.length <= TOOL_BUDGET) return raw;
244
246
  const streams = [];
245
247
  for (const [label, value] of [["stderr", parsed?.stderr?.text], ["stdout", parsed?.stdout?.text]]) {
246
248
  if (typeof value === "string" && value.trim()) {
@@ -257,8 +259,9 @@ function failureBody(text) {
257
259
 
258
260
  export function failureKeptLines(text) {
259
261
  const useful = usefulLines(failureBody(text));
260
- if (useful.join("\n").length <= TOOL_BUDGET) return useful.map(shorten);
261
- const marked = useful.filter((line) => FAILURE_LINE.test(line)).slice(-8).map(shorten);
262
+ if (useful.join("\n").length <= TOOL_BUDGET) return useful;
263
+ const markedLines = useful.filter((line) => FAILURE_LINE.test(line));
264
+ const marked = (markedLines.length > 8 ? [markedLines[0], ...markedLines.slice(-7)] : markedLines).map(shorten);
262
265
  const causes = useful.filter((line) => /(?:root|stderr|stdout)[ _-]*cause\b|CAUSE\b/i.test(line)).slice(-4).map(shorten);
263
266
  const edges = [...useful.slice(0, 1), ...useful.slice(-2)].map(shorten);
264
267
  const picked = [];
@@ -538,9 +541,10 @@ export function roundHasLongRead(events, round) {
538
541
  return false;
539
542
  }
540
543
 
541
- export function keptToolRounds(events, limit = 3) {
544
+ export function keptToolRounds(events, limit = 3, afterIndex = -1) {
542
545
  const count = Number.isInteger(limit) && limit > 0 ? limit : 3;
543
- const rounds = finishedToolRounds(events).filter((round) => !roundHasLongRead(events, round));
546
+ const floor = Number.isInteger(afterIndex) ? afterIndex : -1;
547
+ const rounds = finishedToolRounds(events).filter((round) => round.start > floor && !roundHasLongRead(events, round));
544
548
  return rounds.slice(-count);
545
549
  }
546
550
 
@@ -767,7 +771,8 @@ export function resolveProviderBaseURL({
767
771
  if (fromConfig) return fromConfig;
768
772
  const fromPatch = providerURLFromPatch(patchText, provider);
769
773
  if (fromPatch) return fromPatch;
770
- if (provider === "deepseek-account" && launchURL) return String(launchURL);
774
+ const launch = String(launchURL || "").trim();
775
+ if (provider === "deepseek-account" && launch) return launch;
771
776
  const fallback = fallbackProviderURL(provider);
772
777
  if (fallback) return fallback;
773
778
  return catalogProviderURL(provider, catalog);
@@ -947,7 +952,7 @@ export function pointerOnlyText(pieces) {
947
952
  const lines = tools.map((piece) => {
948
953
  const cmd = piece.command || piece.name || "tool";
949
954
  const call = Number.isInteger(piece.callSeq) ? piece.callSeq : "?";
950
- const flag = piece.isError ? " error" : "";
955
+ const flag = isToolFailure(piece.text, piece.isError) ? " error" : "";
951
956
  return `* ${piece.name || "tool"} "${cmd}" (seq ${call} -> result ${piece.seq}${flag})`;
952
957
  });
953
958
  const userBlock = users.length
@@ -1033,9 +1038,15 @@ function toolBlock(span) {
1033
1038
  if (command) prefix.push(command);
1034
1039
  const exitCode = failed ? exitCodeOf(text) : null;
1035
1040
  if (exitCode !== null) prefix.push(`[exit code: ${exitCode}]`);
1036
- if (small) return { block: [...prefix, ...lines.map((line) => `│ ${line}`)].join("\n"), failed, needsBackup: false, hardLines, ordinaryLines };
1037
- if (failed) return { block: [...prefix, ...failureKeptLines(text).map((line) => `│ ${line}`)].join("\n"), failed, needsBackup: true, hardLines, ordinaryLines };
1038
- return { block: [...prefix, ...unknownKeptLines(text).map((line) => `│ ${line}`)].join("\n"), failed, needsBackup: true, hardLines, ordinaryLines };
1041
+ if (small) return { block: [...prefix, ...lines.map((line) => `│ ${line}`)].join("\n"), failed, failureDiagnosticsComplete: true, needsBackup: false, hardLines, ordinaryLines };
1042
+ if (failed) {
1043
+ const original = text.trim();
1044
+ const diagnostic = usefulLines(original.startsWith("{") ? original : failureBody(text));
1045
+ const kept = failureKeptLines(text);
1046
+ const failureDiagnosticsComplete = diagnostic.length === kept.length && diagnostic.every((line, index) => line === kept[index]);
1047
+ return { block: [...prefix, ...kept.map((line) => `│ ${line}`)].join("\n"), failed, failureDiagnosticsComplete, needsBackup: true, hardLines, ordinaryLines };
1048
+ }
1049
+ return { block: [...prefix, ...unknownKeptLines(text).map((line) => `│ ${line}`)].join("\n"), failed, failureDiagnosticsComplete: true, needsBackup: true, hardLines, ordinaryLines };
1039
1050
  }
1040
1051
 
1041
1052
  export function fitFailure(block, budget) {
@@ -1051,7 +1062,7 @@ export function fitFailure(block, budget) {
1051
1062
 
1052
1063
  export function toolExcerpt(spans, backupId = "") {
1053
1064
  const tools = (spans || []).filter((span) => sourceRole(span.role) === "tool" && String(span.text || "").trim().length > 0);
1054
- if (tools.length === 0) return { text: "", omitted: 0, needsBackup: false, excerpts: [], stats: [] };
1065
+ if (tools.length === 0) return { text: "", omitted: 0, needsBackup: false, failureDiagnosticsComplete: true, incompleteFailures: [], excerpts: [], stats: [] };
1055
1066
  let lastFailure = -1;
1056
1067
  tools.forEach((span, index) => {
1057
1068
  if (isToolFailure(span.text, span.isError)) lastFailure = index;
@@ -1067,15 +1078,22 @@ export function toolExcerpt(spans, backupId = "") {
1067
1078
  });
1068
1079
  const chosen = [];
1069
1080
  const stats = [];
1081
+ const incompleteFailures = new Map();
1070
1082
  let used = 0;
1071
1083
  let needsBackup = false;
1072
1084
  for (const item of ranked) {
1073
1085
  const built = toolBlock(item.span);
1086
+ const incomplete = (reason) => {
1087
+ if (built.failed && !incompleteFailures.has(item.index)) incompleteFailures.set(item.index, { sourceId: item.span.id, reason });
1088
+ };
1089
+ if (!built.failureDiagnosticsComplete) incomplete("failed-tool-lines-truncated");
1074
1090
  let block = built.block;
1075
1091
  if (item.index === lastFailure && used + block.length > TOOL_BUDGET) {
1092
+ incomplete("failed-tool-budget-truncated");
1076
1093
  block = fitFailure(block, Math.max(0, TOOL_BUDGET - used));
1077
1094
  }
1078
1095
  if (used + block.length > TOOL_BUDGET) {
1096
+ incomplete("failed-tool-budget-omitted");
1079
1097
  needsBackup = true;
1080
1098
  stats.push({ sourceId: item.span.id, hardLines: built.hardLines, omittedOrdinaryToolLines: built.ordinaryLines.length });
1081
1099
  continue;
@@ -1107,6 +1125,8 @@ export function toolExcerpt(spans, backupId = "") {
1107
1125
  text: parts.join("\n"),
1108
1126
  omitted: skipped,
1109
1127
  needsBackup,
1128
+ failureDiagnosticsComplete: incompleteFailures.size === 0,
1129
+ incompleteFailures: [...incompleteFailures.values()],
1110
1130
  excerpts: chosen.map(({ sourceId, text }) => ({ sourceId, text })),
1111
1131
  stats,
1112
1132
  };
@@ -1151,7 +1171,7 @@ function pointerLines(spans) {
1151
1171
  if (Number.isInteger(span.seq)) {
1152
1172
  const cmd = span.command || span.name || "tool";
1153
1173
  const call = Number.isInteger(span.callSeq) ? span.callSeq : "?";
1154
- const flag = span.isError ? " error" : "";
1174
+ const flag = isToolFailure(span.text, span.isError) ? " error" : "";
1155
1175
  lines.push(`* ${span.name || "tool"} "${cmd}" (seq ${call} -> result ${span.seq}${flag})`);
1156
1176
  continue;
1157
1177
  }
@@ -1281,6 +1301,15 @@ export function buildCard(
1281
1301
  requiredLines: hard,
1282
1302
  keptLines: kept.map((item) => ({ sourceId: item.sourceId, text: item.line })),
1283
1303
  });
1304
+ const incompleteFailures = tools.incompleteFailures.filter((item) => sourceById.has(item.sourceId));
1305
+ for (const item of incompleteFailures) {
1306
+ coverage.ok = false;
1307
+ coverage.missing.push({
1308
+ sourceId: item.sourceId,
1309
+ text: `failed-tool-diagnostics-not-retained (seq ${sourceById.get(item.sourceId).seq ?? "?"})`,
1310
+ reason: "failed-tool-diagnostics-not-retained",
1311
+ });
1312
+ }
1284
1313
 
1285
1314
  // 保留既有助手决策否决;助手正文既不进 Kept,也不能为其他来源提供证据。
1286
1315
  for (const source of sources) {
@@ -1341,6 +1370,7 @@ export function buildCard(
1341
1370
 
1342
1371
  return {
1343
1372
  ok: coverage.ok,
1373
+ failureDiagnosticsComplete: incompleteFailures.length === 0,
1344
1374
  scope: TRUNK_SCOPE,
1345
1375
  hardLines: hard.map((item) => item.line),
1346
1376
  hardCount: hard.length,
package/raw.js CHANGED
@@ -62,12 +62,14 @@ function readExisting(file, toolCallId) {
62
62
 
63
63
  // Restart recovery is read-only: never manufacture an original from the surface.
64
64
  // Resolve only the requested ID, with the same permissions/meta checks as capture.
65
- export function readRawToolResult({ dir, toolCallId, access = {} }) {
65
+ export function readRawToolResult({ dir, toolCallId, access = {}, tighten = true }) {
66
66
  const file = join(dir, rawName(toolCallId));
67
67
  if (!existsSync(file)) return { ok: false, reason: "missing-file", toolCallId };
68
- ensureShadowDir(dir, access);
69
- tightenShadow(file, "file", access);
70
- if (existsSync(`${file}.meta.json`)) tightenShadow(`${file}.meta.json`, "file", access);
68
+ if (tighten) {
69
+ ensureShadowDir(dir, access);
70
+ tightenShadow(file, "file", access);
71
+ if (existsSync(`${file}.meta.json`)) tightenShadow(`${file}.meta.json`, "file", access);
72
+ }
71
73
  return readExisting(file, toolCallId);
72
74
  }
73
75
 
@@ -0,0 +1,24 @@
1
+ import { parentPort, workerData } from "node:worker_threads";
2
+ import { attachShadowAccessFuse, saveShadowTransaction } from "./shadow.js";
3
+
4
+ const managed = workerData?.spineWorker === 1;
5
+ if (managed) attachShadowAccessFuse(workerData.accessFuse);
6
+
7
+ async function handle(id, payload) {
8
+ try {
9
+ const result = saveShadowTransaction(payload);
10
+ parentPort.postMessage({ id, ok: true, result });
11
+ } catch (error) {
12
+ parentPort.postMessage({ id, ok: false, error: {
13
+ name: error?.name || "Error",
14
+ message: error?.message || String(error),
15
+ code: error?.code,
16
+ } });
17
+ }
18
+ }
19
+
20
+ let queue = Promise.resolve();
21
+ if (workerData && !managed) handle(0, workerData);
22
+ parentPort.on("message", ({ id, payload }) => {
23
+ queue = queue.then(() => handle(id, payload));
24
+ });
package/shadow.js CHANGED
@@ -38,7 +38,7 @@ const PLUGIN_NAME = /^(?:(?:cut|official|shadow)-\d+-[0-9a-f]{8}|raw-[0-9a-f]{16
38
38
  let cachedSid = "";
39
39
  const lockedDirs = new Map();
40
40
  const lockedFiles = new Map();
41
- let shadowFuse = false;
41
+ let shadowFuseState = new Int32Array(new SharedArrayBuffer(Int32Array.BYTES_PER_ELEMENT));
42
42
  const shadowWarnings = new Set();
43
43
 
44
44
  // No shared byte ledger. A positively dead holder leaves an immutable fence:
@@ -89,17 +89,78 @@ function touchExclusive(file, body = "") {
89
89
  }
90
90
 
91
91
  function readRotationOwner(file) {
92
+ if (!lstatSync(file).isFile()) throw new Error("shadow owner is not a regular file");
92
93
  const fd = openSync(file, "r");
93
94
  try {
94
- const buffer = Buffer.alloc(49);
95
+ const info = fstatSync(fd, { bigint: true });
96
+ if (!info.isFile() || info.size <= 0n || info.size > 256n) throw new Error("invalid shadow owner size");
97
+ const buffer = Buffer.alloc(257);
95
98
  const count = readSync(fd, buffer, 0, buffer.length, 0);
96
- const token = buffer.subarray(0, count).toString("utf8");
97
- return /^[0-9a-f]{48}$/.test(token) ? token : "";
99
+ const text = buffer.subarray(0, count).toString("utf8");
100
+ const record = JSON.parse(text);
101
+ if (count !== Number(info.size) || record?.v !== 1 ||
102
+ !Number.isSafeInteger(record.pid) || record.pid <= 0 || record.pid > 0x7fffffff ||
103
+ !/^[0-9a-f]{48}$/.test(record.id) || `${JSON.stringify({ v: 1, pid: record.pid, id: record.id })}\n` !== text) {
104
+ throw new Error("invalid shadow owner record");
105
+ }
106
+ return record;
98
107
  } finally {
99
108
  closeSync(fd);
100
109
  }
101
110
  }
102
111
 
112
+ function rotationOwnerText(record) {
113
+ return `${JSON.stringify({ v: 1, pid: record.pid, id: record.id })}\n`;
114
+ }
115
+
116
+ function publishRotationOwner(file, record) {
117
+ const temp = `${file}.${randomBytes(24).toString("hex")}.tmp`;
118
+ let fd;
119
+ try {
120
+ fd = openSync(temp, "wx", 0o600);
121
+ writeFileSync(fd, rotationOwnerText(record));
122
+ fsyncSync(fd);
123
+ closeSync(fd);
124
+ fd = undefined;
125
+ // The rotation lock serializes owner claims. Remove only the stale owner
126
+ // after its PID was proven dead, then publish the complete replacement.
127
+ try { unlinkSync(file); } catch (error) { if (error?.code !== "ENOENT") throw error; }
128
+ renameSync(temp, file);
129
+ } finally {
130
+ if (fd !== undefined) closeSync(fd);
131
+ try { unlinkSync(temp); } catch {}
132
+ }
133
+ }
134
+
135
+ function ownerProcessAlive(record) {
136
+ try {
137
+ process.kill(record.pid, 0);
138
+ return true;
139
+ } catch (error) {
140
+ return error?.code !== "ESRCH";
141
+ }
142
+ }
143
+
144
+ function claimRotationOwner(file) {
145
+ const next = { pid: process.pid, id: rotationOwner };
146
+ let current;
147
+ try {
148
+ current = readRotationOwner(file);
149
+ } catch (error) {
150
+ if (error?.code !== "ENOENT") return false;
151
+ }
152
+ if (current) {
153
+ if (current.pid === next.pid && current.id === next.id) return true;
154
+ if (ownerProcessAlive(current)) return false;
155
+ }
156
+ try {
157
+ publishRotationOwner(file, next);
158
+ return readRotationOwner(file).pid === next.pid && readRotationOwner(file).id === next.id;
159
+ } catch {
160
+ return false;
161
+ }
162
+ }
163
+
103
164
  function rotationRecordText(record) {
104
165
  return `${JSON.stringify({ v: 1, pid: record.pid, id: record.id })}\n`;
105
166
  }
@@ -390,12 +451,18 @@ export function withShadowRoot(dir, action) {
390
451
  const optedIn = guard.owned && dedicatedRoot(root, identity);
391
452
  const owner = join(root, ROTATION_OWNER);
392
453
  let token = "";
393
- if (optedIn && !existsSync(owner) && !existsSync(shared)) {
454
+ let currentOwner;
455
+ try { currentOwner = readRotationOwner(owner); } catch {}
456
+ const ownsCurrent = currentOwner?.pid === process.pid && currentOwner?.id === rotationOwner;
457
+ if (optedIn && !existsSync(shared) && !ownsCurrent) {
394
458
  shadowUsage = null;
395
- touchExclusive(owner, rotationOwner);
459
+ if (!claimRotationOwner(owner)) touchExclusive(shared);
396
460
  }
397
461
  if (existsSync(owner)) {
398
- try { token = readRotationOwner(owner); } catch {}
462
+ try {
463
+ const record = readRotationOwner(owner);
464
+ token = record.pid === process.pid && record.id === rotationOwner ? record.id : "";
465
+ } catch {}
399
466
  if (token !== rotationOwner || !optedIn) touchExclusive(shared);
400
467
  }
401
468
  const exclusive = optedIn && token === rotationOwner && !existsSync(shared);
@@ -420,16 +487,31 @@ export function resetShadowAccessState() {
420
487
  cachedSid = "";
421
488
  lockedDirs.clear();
422
489
  lockedFiles.clear();
423
- shadowFuse = false;
490
+ // Test/restart helper only: callers must have no in-flight workers. Keep the
491
+ // same shared buffer so idle worker retirement never creates a new fuse domain.
492
+ Atomics.store(shadowFuseState, 0, 0);
424
493
  shadowWarnings.clear();
425
494
  }
426
495
 
427
496
  export function shadowAccessFused() {
428
- return shadowFuse;
497
+ return Atomics.load(shadowFuseState, 0) !== 0;
498
+ }
499
+
500
+ export function shadowAccessFuseBuffer() {
501
+ return shadowFuseState.buffer;
502
+ }
503
+
504
+ export function attachShadowAccessFuse(buffer) {
505
+ if (!(buffer instanceof SharedArrayBuffer) || buffer.byteLength !== Int32Array.BYTES_PER_ELEMENT) {
506
+ throw new TypeError("spine: invalid shared shadow access fuse");
507
+ }
508
+ const alreadyFused = shadowAccessFused();
509
+ shadowFuseState = new Int32Array(buffer);
510
+ if (alreadyFused) Atomics.store(shadowFuseState, 0, 1);
429
511
  }
430
512
 
431
513
  function tripFuse() {
432
- shadowFuse = true;
514
+ Atomics.store(shadowFuseState, 0, 1);
433
515
  }
434
516
 
435
517
  function envValue(env, name) {
@@ -766,7 +848,7 @@ export function defaultShadowAccessRunner({
766
848
  return true;
767
849
  }
768
850
 
769
- if (shadowFuse) {
851
+ if (shadowAccessFused()) {
770
852
  const error = new Error("spine: shadow permission already failed");
771
853
  error.code = "ESHADOWACCESS";
772
854
  throw error;
@@ -846,7 +928,7 @@ export function defaultShadowAccessRunner({
846
928
  export function tightenShadow(path, kind = "dir", access = {}) {
847
929
  const platform = access.platform ?? process.platform;
848
930
  const windows = platform === "win32" && !access.runner;
849
- if (windows && shadowFuse) {
931
+ if (windows && shadowAccessFused()) {
850
932
  const error = new Error("spine: shadow permission action failed; backup refused");
851
933
  error.code = "ESHADOWACCESS";
852
934
  throw error;
@@ -891,7 +973,7 @@ export function ensureShadowDir(dir, access = {}) {
891
973
  }
892
974
 
893
975
  export function tightenCreatedShadow(file, fd, access, { remember = true } = {}) {
894
- if ((access.platform ?? process.platform) === "win32" && !access.runner && !shadowFuse) {
976
+ if ((access.platform ?? process.platform) === "win32" && !access.runner && !shadowAccessFused()) {
895
977
  try {
896
978
  if (shadowDirLocked(dirname(file))) {
897
979
  const entry = accessEntry(file, "file");
@@ -1049,7 +1131,12 @@ function readSessionKey(file) {
1049
1131
  }
1050
1132
  }
1051
1133
 
1052
- export function listPluginShadows(dir, { readdir = readdirSync, stat = statSync, onError } = {}) {
1134
+ export function listPluginShadows(dir, {
1135
+ readdir = readdirSync,
1136
+ stat = statSync,
1137
+ onError,
1138
+ includeSessionKey = true,
1139
+ } = {}) {
1053
1140
  let names = [];
1054
1141
  try {
1055
1142
  names = readdir(dir);
@@ -1069,7 +1156,7 @@ export function listPluginShadows(dir, { readdir = readdirSync, stat = statSync,
1069
1156
  path,
1070
1157
  size: info.size,
1071
1158
  mtimeMs: info.mtimeMs,
1072
- sessionKey: readSessionKey(path),
1159
+ sessionKey: includeSessionKey ? readSessionKey(path) : "",
1073
1160
  });
1074
1161
  } catch { onError?.(); }
1075
1162
  }
@@ -1080,7 +1167,12 @@ function byAge(left, right) {
1080
1167
  return left.mtimeMs - right.mtimeMs || left.name.localeCompare(right.name);
1081
1168
  }
1082
1169
 
1083
- export function listShadowEntries(dir, alive = processAlive, { readdir = readdirSync, stat = statSync, onError } = {}) {
1170
+ export function listShadowEntries(dir, alive = processAlive, {
1171
+ readdir = readdirSync,
1172
+ stat = statSync,
1173
+ onError,
1174
+ includeSessionKey = false,
1175
+ } = {}) {
1084
1176
  const entries = [];
1085
1177
  let names = [];
1086
1178
  try {
@@ -1113,7 +1205,7 @@ export function listShadowEntries(dir, alive = processAlive, { readdir = readdir
1113
1205
  if (owner > 0) {
1114
1206
  try { live = alive(owner) !== false; } catch { live = true; }
1115
1207
  }
1116
- for (const file of listPluginShadows(path, { readdir, stat, onError })) {
1208
+ for (const file of listPluginShadows(path, { readdir, stat, onError, includeSessionKey })) {
1117
1209
  // The directory and its owner define deletion authority. Raw tool output
1118
1210
  // is untrusted data and may itself start with a spine-session marker.
1119
1211
  entries.push({ ...file, legacy: false, live, sessionKey: entry.name });
@@ -1130,6 +1222,65 @@ export function rotateShadow(options) {
1130
1222
  return withShadowRoot(options.dir, () => rotateShadowLocked(options));
1131
1223
  }
1132
1224
 
1225
+ // Complete backup transaction used by both the synchronous compatibility API
1226
+ // and the optional root-writer worker. The rotation lock stays held from the
1227
+ // full reconcile through ACL-protected body publication; callers must not move
1228
+ // only the scan to a worker and release the lock before the write.
1229
+ export function saveShadowTransaction({
1230
+ shadow,
1231
+ file,
1232
+ body,
1233
+ required = false,
1234
+ protectPaths = [],
1235
+ sessionKey = "default",
1236
+ cap,
1237
+ access = {},
1238
+ rotationIO = {},
1239
+ } = {}) {
1240
+ ensureShadowDir(shadow, access);
1241
+ const dir = sessionDir(shadow, sessionKey);
1242
+ ensureShadowDir(dir, access);
1243
+ return withShadowRoot(shadow, () => {
1244
+ const owner = join(dir, "owner");
1245
+ const ownerExisted = existsSync(owner);
1246
+ let ownerWritten = false;
1247
+ try {
1248
+ writeShadowOwner(dir, access, shadow);
1249
+ ownerWritten = true;
1250
+ const incoming = Buffer.byteLength(body);
1251
+ const rotation = rotateShadow({
1252
+ ...rotationIO,
1253
+ dir: shadow,
1254
+ incomingBytes: incoming,
1255
+ protectPath: file,
1256
+ protectPaths,
1257
+ sessionKey,
1258
+ cap,
1259
+ });
1260
+ if (!rotation.ok) {
1261
+ if (rotation.reason) {
1262
+ const incomplete = rotation.reason === "scan-incomplete";
1263
+ const error = Object.assign(new Error(incomplete
1264
+ ? "spine: shadow capacity scan is incomplete; backup refused; leaving the span"
1265
+ : "spine: shadow capacity measurement is invalid; backup refused; leaving the span"), {
1266
+ code: incomplete ? "ESHADOWSCAN" : "ESHADOWMETER",
1267
+ });
1268
+ if (required) throw error;
1269
+ return { ok: false, skipped: true, reason: rotation.reason, rotation };
1270
+ }
1271
+ if (required) throw new Error("spine: shadow backup is full; leaving the span");
1272
+ return { ok: false, skipped: true, reason: "full", rotation };
1273
+ }
1274
+ writeShadowFile(file, body, access, { root: shadow });
1275
+ if (!existsSync(file)) throw new Error("spine: shadow backup was not written");
1276
+ return { ok: true, file, rotation };
1277
+ } catch (error) {
1278
+ if (ownerWritten && !ownerExisted) discardShadowFiles([owner], error);
1279
+ throw error;
1280
+ }
1281
+ });
1282
+ }
1283
+
1133
1284
  function rotationTime(now) {
1134
1285
  try {
1135
1286
  const value = now();
package/shell.js CHANGED
@@ -68,9 +68,11 @@ function releaseCompaction(ctx, previous) {
68
68
  const store = ctx?.reflect?.store;
69
69
  const key = ctx?.[Symbol.for("cordis.isolate")]?.compaction;
70
70
  const current = compactionRecord(ctx);
71
- if (!current || current === previous || current.fiber !== ctx.fiber) return false;
71
+ const currentFiber = current?.fiber ?? current?.ctx?.fiber;
72
+ const contextFiber = ctx?.fiber;
73
+ if (!current || current === previous || currentFiber !== contextFiber) return false;
72
74
  delete store[key];
73
- if (ctx.fiber?.store?.compaction === current) delete ctx.fiber.store.compaction;
75
+ if (contextFiber?.store?.compaction === current) delete contextFiber.store.compaction;
74
76
  return true;
75
77
  }
76
78