dsh-context-compression-improved 0.5.0 → 0.5.1
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/CHANGELOG.ja.md +15 -0
- package/CHANGELOG.ko.md +15 -0
- package/CHANGELOG.md +17 -0
- package/CHANGELOG.zh.md +13 -0
- package/package.json +1 -1
- package/packages/selector/lib/index.js +49 -1
- package/packages/selector/src/preset-overlay.ts +60 -1
- package/packages/selector/tests/built/client-artifact.spec.ts +9 -5
- package/packages/selector/tests/standing-generation.host.spec.ts +54 -5
- package/scripts/packed-components-smoke.mjs +30 -8
- package/scripts/packed-install-e2e.mjs +69 -15
package/CHANGELOG.ja.md
CHANGED
|
@@ -2,6 +2,21 @@
|
|
|
2
2
|
|
|
3
3
|
> 完全な履歴(アップストリーム 0.1.0 以前を含む)は [CHANGELOG.md](CHANGELOG.md) を参照。このファイルはフォークの追加エントリーのみを翻訳したものです。 · [English](CHANGELOG.md) · [中文](CHANGELOG.zh.md) · [한국어](CHANGELOG.ko.md)
|
|
4
4
|
|
|
5
|
+
## 0.5.1 - 2026-09-20
|
|
6
|
+
|
|
7
|
+
### Fixed(修正)
|
|
8
|
+
|
|
9
|
+
- 同一 identity を並行して compose する preset-overlay が Windows で失敗しなくなりました。
|
|
10
|
+
公開は宛先パスごとに直列化し、それでも原子的 rename が競合に敗れた場合は、宛先が
|
|
11
|
+
このステージングファイルと同じ `{mtimeMs, size}` standing key を既に持っていることを
|
|
12
|
+
確認してから成功と見なします。Windows の `MoveFileEx` はこの競合を `EPERM`/`EBUSY` と
|
|
13
|
+
して報告しますが、POSIX の `rename` は単に置き換えます——これが並行セッション開始時に
|
|
14
|
+
`standingKeyFor()` を投げさせていました。一致しない宛先はこれまで通り明確に失敗し、
|
|
15
|
+
静かな世代の再利用は禁止されたままです。
|
|
16
|
+
- この問題と他の既存レッドゲートを覆い隠していたリリースゲート/テストの修正
|
|
17
|
+
(packed smoke の陳旧な識別子と廃止済み audit reason、古い client inject 期待値、
|
|
18
|
+
Windows 限定の spawn トラップ)。
|
|
19
|
+
|
|
5
20
|
## 0.5.0 - 2026-09-20
|
|
6
21
|
|
|
7
22
|
### Added(追加)
|
package/CHANGELOG.ko.md
CHANGED
|
@@ -2,6 +2,21 @@
|
|
|
2
2
|
|
|
3
3
|
> 전체 히스토리(업스트림 0.1.0 이전 포함)는 [CHANGELOG.md](CHANGELOG.md)를 참고하세요. 이 파일은 포크의 추가 항목만 번역한 것입니다. · [English](CHANGELOG.md) · [中文](CHANGELOG.zh.md) · [日本語](CHANGELOG.ja.md)
|
|
4
4
|
|
|
5
|
+
## 0.5.1 - 2026-09-20
|
|
6
|
+
|
|
7
|
+
### Fixed(수정)
|
|
8
|
+
|
|
9
|
+
- 동일 identity를 동시에 compose하는 preset-overlay가 Windows에서 더 이상 실패하지
|
|
10
|
+
않습니다. 게시는 대상 경로별로 직렬화되며, 그래도 원자적 rename이 경쟁에서 지면 대상이
|
|
11
|
+
이미 이 스테이징 파일과 같은 `{mtimeMs, size}` standing key를 가지고 있는지 확인한 뒤에만
|
|
12
|
+
성공으로 간주합니다. Windows의 `MoveFileEx`는 이 경쟁을 `EPERM`/`EBUSY`로 보고하지만
|
|
13
|
+
POSIX `rename`은 대상을 그대로 교체합니다——이 때문에 동시 세션 시작 시
|
|
14
|
+
`standingKeyFor()`가 예외를 던졌습니다. 일치하지 않는 대상은 여전히 명확히 실패하며,
|
|
15
|
+
조용한 세대 재사용은 계속 금지됩니다.
|
|
16
|
+
- 이 문제와 다른 기존 레드 게이트를 가리던 릴리스 게이트/테스트 수정
|
|
17
|
+
(packed smoke의 오래된 식별자와 폐기된 audit reason, 낡은 client inject 기대값,
|
|
18
|
+
Windows 전용 spawn 함정).
|
|
19
|
+
|
|
5
20
|
## 0.5.0 - 2026-09-20
|
|
6
21
|
|
|
7
22
|
### Added(추가)
|
package/CHANGELOG.md
CHANGED
|
@@ -2,6 +2,23 @@
|
|
|
2
2
|
|
|
3
3
|
All notable changes use this file. The project follows semantic versioning after `0.1.0`.
|
|
4
4
|
|
|
5
|
+
## 0.5.1 - 2026-09-20
|
|
6
|
+
|
|
7
|
+
### Fixed
|
|
8
|
+
|
|
9
|
+
- Concurrent preset-overlay composition of one identity no longer fails on Windows.
|
|
10
|
+
Publication is now serialized per destination path, and when the atomic rename still
|
|
11
|
+
loses the race the destination is confirmed to already carry this staging file's
|
|
12
|
+
`{mtimeMs, size}` standing key before the publish reports success. Windows `MoveFileEx`
|
|
13
|
+
reports that lost race as `EPERM`/`EBUSY` where POSIX `rename` simply replaces the
|
|
14
|
+
destination, which made `standingKeyFor()` throw during concurrent session start. A
|
|
15
|
+
destination that does not match still fails loudly, so a silently reused generation
|
|
16
|
+
stays forbidden.
|
|
17
|
+
- Release-gate and test repairs that had been hiding this and other pre-existing red
|
|
18
|
+
gates: stale identifiers and a retired audit reason in the packed smokes, a stale
|
|
19
|
+
client-inject expectation, and Windows-only spawn traps (`git` and multi-line
|
|
20
|
+
`node -e` scripts were routed through `cmd.exe`, which rewrote their arguments).
|
|
21
|
+
|
|
5
22
|
## 0.5.0 - 2026-09-20
|
|
6
23
|
|
|
7
24
|
### Added
|
package/CHANGELOG.zh.md
CHANGED
|
@@ -2,6 +2,19 @@
|
|
|
2
2
|
|
|
3
3
|
> 完整历史(含上游 0.1.0 及更早版本)见 [CHANGELOG.md](CHANGELOG.md)。本文件只翻译本 fork 的新增条目。 · [English](CHANGELOG.md) · [日本語](CHANGELOG.ja.md) · [한국어](CHANGELOG.ko.md)
|
|
4
4
|
|
|
5
|
+
## 0.5.1 - 2026-09-20
|
|
6
|
+
|
|
7
|
+
### Fixed
|
|
8
|
+
|
|
9
|
+
- 同一 identity 的并发 preset-overlay 组装在 Windows 上不再失败:发布改为按目标路径串行,
|
|
10
|
+
且当原子 rename 仍然竞争失败时,会先确认目标文件已带有本 staging 文件的
|
|
11
|
+
`{mtimeMs, size}` standing key 才判定发布成功。Windows 的 `MoveFileEx` 会把这种竞争
|
|
12
|
+
失败报成 `EPERM`/`EBUSY`,而 POSIX `rename` 只是覆盖目标——这曾导致并发启动会话时
|
|
13
|
+
`standingKeyFor()` 抛错。目标不匹配时仍会明确失败,静默复用世代依旧被禁止。
|
|
14
|
+
- 一并修复了长期掩盖该问题及其它预存红灯的发布门禁与测试:packed smoke 中陈旧的标识符与
|
|
15
|
+
已退役的审计 reason、过期的客户端 inject 断言,以及仅 Windows 触发的 spawn 陷阱
|
|
16
|
+
(`git` 与多行 `node -e` 脚本经 `cmd.exe` 转发会被改写参数)。
|
|
17
|
+
|
|
5
18
|
## 0.5.0 - 2026-09-20
|
|
6
19
|
|
|
7
20
|
### Added
|
package/package.json
CHANGED
|
@@ -118,6 +118,15 @@ function standingStampMsAtWindow(identity, windowIndex) {
|
|
|
118
118
|
const subSecond = parseInt(identity.slice(start + 8, start + STANDING_MTIME_WINDOW_HEX).padEnd(3, "0"), 16) % 1e3;
|
|
119
119
|
return seconds * 1e3 + subSecond;
|
|
120
120
|
}
|
|
121
|
+
/**
|
|
122
|
+
* True for the filesystem errors a concurrent publish can raise on Windows:
|
|
123
|
+
* `MoveFileEx` reports a lost race — or a reader holding the destination open —
|
|
124
|
+
* as EPERM/EBUSY/EACCES, where POSIX `rename` simply replaces the destination.
|
|
125
|
+
*/
|
|
126
|
+
function isPublishRace(error) {
|
|
127
|
+
const code = error?.code;
|
|
128
|
+
return code === "EPERM" || code === "EBUSY" || code === "EACCES" || code === "EEXIST";
|
|
129
|
+
}
|
|
121
130
|
const COMPRESSION_IDS = /* @__PURE__ */ new Set([
|
|
122
131
|
"compaction",
|
|
123
132
|
"compaction-basic",
|
|
@@ -191,7 +200,7 @@ var PresetOverlayStore = class {
|
|
|
191
200
|
});
|
|
192
201
|
await chmod(staging, 384);
|
|
193
202
|
await this.disambiguateStamp(staging, identity);
|
|
194
|
-
await
|
|
203
|
+
await this.publish(staging, path);
|
|
195
204
|
} catch (error) {
|
|
196
205
|
try {
|
|
197
206
|
await rm(staging, { force: true });
|
|
@@ -203,6 +212,45 @@ var PresetOverlayStore = class {
|
|
|
203
212
|
path
|
|
204
213
|
};
|
|
205
214
|
}
|
|
215
|
+
/** In-flight publish chains, one per destination path. */
|
|
216
|
+
publishChains = /* @__PURE__ */ new Map();
|
|
217
|
+
/**
|
|
218
|
+
* Publish one stamped staging file to its final path, serialized per path.
|
|
219
|
+
*
|
|
220
|
+
* POSIX `rename` replaces an existing destination atomically, so concurrent
|
|
221
|
+
* composers of one identity are naturally idempotent there. Windows
|
|
222
|
+
* `MoveFileEx` instead fails the loser of that race with EPERM/EBUSY, which
|
|
223
|
+
* surfaced as `standingKeyFor()` throwing during concurrent composition.
|
|
224
|
+
* Every composer of one identity writes identical bytes and re-derives the
|
|
225
|
+
* same deterministic identity stamp, so a destination that already observes
|
|
226
|
+
* this staging file's {mtimeMs, size} key IS the intended end state: confirm
|
|
227
|
+
* it and treat the lost race as success. Every other outcome still throws, so
|
|
228
|
+
* a silently reused generation stays forbidden, and the caller still removes
|
|
229
|
+
* the staging file when this rejects.
|
|
230
|
+
*/
|
|
231
|
+
publish(staging, path) {
|
|
232
|
+
const tracked = (this.publishChains.get(path) ?? Promise.resolve()).catch(() => void 0).then(async () => {
|
|
233
|
+
try {
|
|
234
|
+
await rename(staging, path);
|
|
235
|
+
return;
|
|
236
|
+
} catch (error) {
|
|
237
|
+
if (!isPublishRace(error)) throw error;
|
|
238
|
+
try {
|
|
239
|
+
const intended = await this.metadataIo.read(staging);
|
|
240
|
+
const published = await this.metadataIo.read(path);
|
|
241
|
+
if (published.size === intended.size && Math.abs(published.mtimeMs - intended.mtimeMs) < 2) {
|
|
242
|
+
await rm(staging, { force: true });
|
|
243
|
+
return;
|
|
244
|
+
}
|
|
245
|
+
} catch {}
|
|
246
|
+
throw error;
|
|
247
|
+
}
|
|
248
|
+
}).finally(() => {
|
|
249
|
+
if (this.publishChains.get(path) === tracked) this.publishChains.delete(path);
|
|
250
|
+
});
|
|
251
|
+
this.publishChains.set(path, tracked);
|
|
252
|
+
return tracked;
|
|
253
|
+
}
|
|
206
254
|
/** Observed {mtimeMs,size} keys published by this store, per identity. */
|
|
207
255
|
standingKeys = /* @__PURE__ */ new Map();
|
|
208
256
|
/**
|
|
@@ -116,6 +116,16 @@ export function standingStampMs(identity: string): number {
|
|
|
116
116
|
return standingStampMsAtWindow(identity, 0)
|
|
117
117
|
}
|
|
118
118
|
|
|
119
|
+
/**
|
|
120
|
+
* True for the filesystem errors a concurrent publish can raise on Windows:
|
|
121
|
+
* `MoveFileEx` reports a lost race — or a reader holding the destination open —
|
|
122
|
+
* as EPERM/EBUSY/EACCES, where POSIX `rename` simply replaces the destination.
|
|
123
|
+
*/
|
|
124
|
+
function isPublishRace(error: unknown): boolean {
|
|
125
|
+
const code = (error as { code?: unknown } | null)?.code
|
|
126
|
+
return code === 'EPERM' || code === 'EBUSY' || code === 'EACCES' || code === 'EEXIST'
|
|
127
|
+
}
|
|
128
|
+
|
|
119
129
|
const COMPRESSION_IDS = new Set([
|
|
120
130
|
'compaction',
|
|
121
131
|
'compaction-basic',
|
|
@@ -224,7 +234,7 @@ class PresetOverlayStore {
|
|
|
224
234
|
// still private. No reader can observe a colliding {mtimeMs,size}
|
|
225
235
|
// between rename and a later corrective utimes call.
|
|
226
236
|
await this.disambiguateStamp(staging, identity)
|
|
227
|
-
await
|
|
237
|
+
await this.publish(staging, path)
|
|
228
238
|
} catch (error) {
|
|
229
239
|
// A failed publish must not leak its staging file into the store; the
|
|
230
240
|
// cleanup must never mask the original failure either.
|
|
@@ -238,6 +248,55 @@ class PresetOverlayStore {
|
|
|
238
248
|
return { ...preset, path }
|
|
239
249
|
}
|
|
240
250
|
|
|
251
|
+
/** In-flight publish chains, one per destination path. */
|
|
252
|
+
private readonly publishChains = new Map<string, Promise<void>>()
|
|
253
|
+
|
|
254
|
+
/**
|
|
255
|
+
* Publish one stamped staging file to its final path, serialized per path.
|
|
256
|
+
*
|
|
257
|
+
* POSIX `rename` replaces an existing destination atomically, so concurrent
|
|
258
|
+
* composers of one identity are naturally idempotent there. Windows
|
|
259
|
+
* `MoveFileEx` instead fails the loser of that race with EPERM/EBUSY, which
|
|
260
|
+
* surfaced as `standingKeyFor()` throwing during concurrent composition.
|
|
261
|
+
* Every composer of one identity writes identical bytes and re-derives the
|
|
262
|
+
* same deterministic identity stamp, so a destination that already observes
|
|
263
|
+
* this staging file's {mtimeMs, size} key IS the intended end state: confirm
|
|
264
|
+
* it and treat the lost race as success. Every other outcome still throws, so
|
|
265
|
+
* a silently reused generation stays forbidden, and the caller still removes
|
|
266
|
+
* the staging file when this rejects.
|
|
267
|
+
*/
|
|
268
|
+
private publish(staging: string, path: string): Promise<void> {
|
|
269
|
+
const previous = this.publishChains.get(path) ?? Promise.resolve()
|
|
270
|
+
const queued = previous.catch(() => undefined).then(async () => {
|
|
271
|
+
try {
|
|
272
|
+
await rename(staging, path)
|
|
273
|
+
return
|
|
274
|
+
} catch (error) {
|
|
275
|
+
if (!isPublishRace(error)) throw error
|
|
276
|
+
try {
|
|
277
|
+
const intended = await this.metadataIo.read(staging)
|
|
278
|
+
const published = await this.metadataIo.read(path)
|
|
279
|
+
// Sub-second precision survives a wrapped seconds field, but NTFS
|
|
280
|
+
// rounds it to 100ns, so compare the standing key within that
|
|
281
|
+
// rounding rather than by exact float equality.
|
|
282
|
+
if (published.size === intended.size
|
|
283
|
+
&& Math.abs(published.mtimeMs - intended.mtimeMs) < 2) {
|
|
284
|
+
await rm(staging, { force: true })
|
|
285
|
+
return
|
|
286
|
+
}
|
|
287
|
+
} catch {
|
|
288
|
+
// The destination is unreadable: report the original publish failure.
|
|
289
|
+
}
|
|
290
|
+
throw error
|
|
291
|
+
}
|
|
292
|
+
})
|
|
293
|
+
const tracked = queued.finally(() => {
|
|
294
|
+
if (this.publishChains.get(path) === tracked) this.publishChains.delete(path)
|
|
295
|
+
})
|
|
296
|
+
this.publishChains.set(path, tracked)
|
|
297
|
+
return tracked
|
|
298
|
+
}
|
|
299
|
+
|
|
241
300
|
/** Observed {mtimeMs,size} keys published by this store, per identity. */
|
|
242
301
|
private readonly standingKeys = new Map<string, string>()
|
|
243
302
|
|
|
@@ -34,11 +34,15 @@ describe('built Harness client artifact', () => {
|
|
|
34
34
|
}) as { apply?: unknown, inject?: unknown }
|
|
35
35
|
|
|
36
36
|
expect(exported.apply).toBeTypeOf('function')
|
|
37
|
-
//
|
|
38
|
-
//
|
|
39
|
-
//
|
|
40
|
-
//
|
|
41
|
-
|
|
37
|
+
// Declare every consumed service: cordis holds apply until each one is
|
|
38
|
+
// provided, which is the official client-plugin pattern on this 0.1.5 host
|
|
39
|
+
// line (dsh-thinking-levels does the same). The earlier lazy alternative —
|
|
40
|
+
// resolving `locale` / `settingsScope` through ctx.get() inside apply —
|
|
41
|
+
// raced the settings client's activation; on a loss the apply
|
|
42
|
+
// early-returned and EVERY settings entry (the standalone section, the
|
|
43
|
+
// plugins-tab card, the item card) silently vanished. See the rationale
|
|
44
|
+
// recorded in src/client/index.ts.
|
|
45
|
+
expect(exported.inject).toEqual(['slots', 'locale', 'settingsScope'])
|
|
42
46
|
const style = document.querySelector<HTMLStyleElement>(
|
|
43
47
|
'style[data-plugin-css="dsh-context-compression-improved/CompressionProfileSelector.module.css"]',
|
|
44
48
|
)
|
|
@@ -101,6 +101,37 @@ async function overlayFiles(sourceMarker: string): Promise<{ path: string, rende
|
|
|
101
101
|
return files
|
|
102
102
|
}
|
|
103
103
|
|
|
104
|
+
/**
|
|
105
|
+
* The mtime this platform actually persists for one given stamp.
|
|
106
|
+
*
|
|
107
|
+
* `node:fs` `utimes` writes the seconds component through a 32-bit field on
|
|
108
|
+
* Windows: on node v22 win32, `utimes(file, 4677832452 s)` reads back as
|
|
109
|
+
* `382865156 s` — exactly `seconds >>> 0` — while sub-second precision
|
|
110
|
+
* survives. The standing stamp deliberately parks identities around year 2162
|
|
111
|
+
* (`STANDING_MTIME_EPOCH_SECONDS + 0xffffffff`, src/preset-overlay.ts), so its
|
|
112
|
+
* seconds component sits past that 2^32-second horizon (max year 2106) and the
|
|
113
|
+
* persisted literal differs on that platform. Deterministically so: the wrap is
|
|
114
|
+
* a pure function of the identity stamp, which is why identity → mtime stays
|
|
115
|
+
* stable and the collision escalation still absorbs any merge.
|
|
116
|
+
*
|
|
117
|
+
* Instead of hardcoding the platform or weakening the assertion, calibrate:
|
|
118
|
+
* ask the filesystem what it stores for exactly this stamp, then require the
|
|
119
|
+
* overlay to persist that same value. On a platform with a full-width seconds
|
|
120
|
+
* field this collapses to the stamp itself, so the assertion stays strict.
|
|
121
|
+
*/
|
|
122
|
+
async function platformPersistedMs(stampMs: number): Promise<number> {
|
|
123
|
+
const directory = await mkdtemp(join(tmpdir(), 'dsh-selector-mtime-probe-'))
|
|
124
|
+
const probe = join(directory, 'probe')
|
|
125
|
+
try {
|
|
126
|
+
await writeFile(probe, 'probe')
|
|
127
|
+
const stamp = new Date(stampMs)
|
|
128
|
+
await utimes(probe, stamp, stamp)
|
|
129
|
+
return (await stat(probe)).mtimeMs
|
|
130
|
+
} finally {
|
|
131
|
+
await rm(directory, { recursive: true, force: true })
|
|
132
|
+
}
|
|
133
|
+
}
|
|
134
|
+
|
|
104
135
|
let sourceRoot: string | undefined
|
|
105
136
|
|
|
106
137
|
/** Rewrite the source preset to an equal-length marker module. */
|
|
@@ -329,8 +360,17 @@ describe('real AgentPresets standing generations with the overlay threshold', ()
|
|
|
329
360
|
expect(firstStat.size).toBe(secondStat.size)
|
|
330
361
|
expect(firstStat.mtimeMs % 1000).toBe(0)
|
|
331
362
|
expect(secondStat.mtimeMs % 1000).toBe(0)
|
|
332
|
-
|
|
333
|
-
|
|
363
|
+
// The coarse metadata surface floors both stamps to a whole second, and the
|
|
364
|
+
// platform may additionally wrap the seconds field (platformPersistedMs),
|
|
365
|
+
// so require exactly what the filesystem stores for those identity stamps.
|
|
366
|
+
const firstStamp = await platformPersistedMs(
|
|
367
|
+
Math.floor(standingStampMsAtWindow(collision.firstIdentity, 0) / 1000) * 1000,
|
|
368
|
+
)
|
|
369
|
+
const secondStamp = await platformPersistedMs(
|
|
370
|
+
Math.floor(standingStampMsAtWindow(collision.secondIdentity, 1) / 1000) * 1000,
|
|
371
|
+
)
|
|
372
|
+
expect(Math.floor(firstStat.mtimeMs / 1000)).toBe(Math.floor(firstStamp / 1000))
|
|
373
|
+
expect(Math.floor(secondStat.mtimeMs / 1000)).toBe(Math.floor(secondStamp / 1000))
|
|
334
374
|
|
|
335
375
|
await installation.dispose()
|
|
336
376
|
})
|
|
@@ -382,11 +422,19 @@ describe('real AgentPresets standing generations with the overlay threshold', ()
|
|
|
382
422
|
expect(files.length).toBeGreaterThanOrEqual(3)
|
|
383
423
|
const buckets = await Promise.all(files.map(async file => Math.floor((await stat(file.path)).mtimeMs / 1000)))
|
|
384
424
|
expect(new Set(buckets).size).toBe(buckets.length)
|
|
385
|
-
// No staging leftovers survive a publish.
|
|
425
|
+
// No staging leftovers survive a publish. A spec file running in parallel
|
|
426
|
+
// disposes its own store while this scan runs, so a vanished directory means
|
|
427
|
+
// "no leftovers to find" rather than a failure (mirrors overlayFiles).
|
|
386
428
|
const storeDirs = await Promise.all(
|
|
387
429
|
(await readdir(tmpdir(), { withFileTypes: true }))
|
|
388
430
|
.filter(entry => entry.isDirectory() && entry.name.startsWith('dsh-context-compression-presets-'))
|
|
389
|
-
.map(async entry =>
|
|
431
|
+
.map(async entry => {
|
|
432
|
+
try {
|
|
433
|
+
return await readdir(join(tmpdir(), entry.name))
|
|
434
|
+
} catch {
|
|
435
|
+
return []
|
|
436
|
+
}
|
|
437
|
+
}),
|
|
390
438
|
)
|
|
391
439
|
expect(storeDirs.flat().some(name => name.endsWith('.tmp'))).toBe(false)
|
|
392
440
|
|
|
@@ -451,8 +499,9 @@ describe('real AgentPresets standing generations with the overlay threshold', ()
|
|
|
451
499
|
? /standard-([0-9a-f]+)\.agent\.cordis\.yml$/u.exec(files[0]!.path)?.[1]
|
|
452
500
|
: undefined
|
|
453
501
|
expect(identity).toBeDefined()
|
|
502
|
+
const persistedStamp = await platformPersistedMs(standingStampMs(identity as string))
|
|
454
503
|
expect(Math.floor(details.mtimeMs / 1000))
|
|
455
|
-
.toBe(Math.floor(
|
|
504
|
+
.toBe(Math.floor(persistedStamp / 1000))
|
|
456
505
|
const observedSubSecond = details.mtimeMs % 1000
|
|
457
506
|
const expectedSubSecond = standingStampMs(identity as string) % 1000
|
|
458
507
|
expect(observedSubSecond === 0 || Math.abs(observedSubSecond - expectedSubSecond) < 1).toBe(true)
|
|
@@ -561,13 +561,25 @@ try {
|
|
|
561
561
|
&& JSON.stringify(originalImage).includes('packed-image-800x600')
|
|
562
562
|
&& JSON.stringify(originalImage).includes('"type":"image"'),
|
|
563
563
|
'packed vision image-bearing result is no longer intact on the surface')
|
|
564
|
+
// The character basis retired the `exact-tokenizer-unavailable` skip: a
|
|
565
|
+
// History planning skip can no longer refuse for a missing exact count (see
|
|
566
|
+
// the HistoryPlanOutcome note in src/pruner/types.ts). A vision session
|
|
567
|
+
// without the exact tokenizer is therefore judged on characters and its
|
|
568
|
+
// skip carries a real reason (observed: fresh/aggregate `at-or-below-
|
|
569
|
+
// trigger`, history `below-profile-trigger`) — assert that observable
|
|
570
|
+
// contract instead of the removed literal.
|
|
564
571
|
for (const component of ['fresh', 'history']) {
|
|
565
|
-
|
|
566
|
-
&&
|
|
567
|
-
&&
|
|
568
|
-
|
|
569
|
-
|
|
570
|
-
|
|
572
|
+
const record = visionAudit.find(entry => entry.kind === 'component-evaluation'
|
|
573
|
+
&& entry.sessionId === String(visionImage.id)
|
|
574
|
+
&& entry.component === component)
|
|
575
|
+
assert(record !== undefined,
|
|
576
|
+
`packed vision image session lacks the ${component} component-evaluation audit`)
|
|
577
|
+
assert(record.status === 'skipped',
|
|
578
|
+
`packed vision ${component} status is ${String(record.status)}, expected skipped`)
|
|
579
|
+
assert(record.measurementKind === 'characters',
|
|
580
|
+
`packed vision ${component} was decided on ${String(record.measurementKind)}, expected characters`)
|
|
581
|
+
assert(typeof record.reason === 'string' && record.reason.length > 0,
|
|
582
|
+
`packed vision ${component} skip carries no diagnostic reason`)
|
|
571
583
|
}
|
|
572
584
|
|
|
573
585
|
console.info(`PACKED_VISION_E2E ${JSON.stringify({
|
|
@@ -651,9 +663,19 @@ try {
|
|
|
651
663
|
&& firstIndex('history') < firstIndex('tail-trim'),
|
|
652
664
|
'installed component rewrite order is wrong')
|
|
653
665
|
const pipelineRewrites = rewrites.filter(record => record.sessionId === String(session.id))
|
|
666
|
+
// The character basis decides on Unicode code points, so a committed rewrite
|
|
667
|
+
// may report an unchanged token figure while still reclaiming characters
|
|
668
|
+
// (observed once here: history `historical-tool-result-aging` landed at
|
|
669
|
+
// 129 -> 129 with tokensRemoved 0). The rewrite audit carries no character
|
|
670
|
+
// field yet, so assert what the basis can actually guarantee: integer
|
|
671
|
+
// accounting, a consistent removal figure, and never an increase. A strict
|
|
672
|
+
// token decrease would re-encode the retired exact-token authority — see the
|
|
673
|
+
// measurementBasis note in src/runtime/audit.ts.
|
|
654
674
|
assert(pipelineRewrites.every(record => Number.isSafeInteger(record.tokensBefore)
|
|
655
|
-
&& Number.isSafeInteger(record.tokensAfter)
|
|
656
|
-
|
|
675
|
+
&& Number.isSafeInteger(record.tokensAfter)
|
|
676
|
+
&& record.tokensBefore >= record.tokensAfter
|
|
677
|
+
&& record.tokensRemoved === record.tokensBefore - record.tokensAfter),
|
|
678
|
+
'installed rewrite token accounting is inconsistent or increasing')
|
|
657
679
|
assert(audit.some(record => record.kind === 'native-auto-compact'
|
|
658
680
|
&& record.sessionId === String(session.id)),
|
|
659
681
|
'installed Runtime did not audit official Native summary')
|
|
@@ -2,7 +2,7 @@ import { createHash } from 'node:crypto'
|
|
|
2
2
|
import { spawn } from 'node:child_process'
|
|
3
3
|
import { createReadStream } from 'node:fs'
|
|
4
4
|
import { copyFile, mkdir, mkdtemp, readFile, readdir, realpath, rm, stat, writeFile } from 'node:fs/promises'
|
|
5
|
-
import { readFileSync } from 'node:fs'
|
|
5
|
+
import { readFileSync, readdirSync } from 'node:fs'
|
|
6
6
|
import { createServer } from 'node:http'
|
|
7
7
|
import { createRequire } from 'node:module'
|
|
8
8
|
import { tmpdir } from 'node:os'
|
|
@@ -27,8 +27,17 @@ const officialHostPackages = Object.entries(rootManifest.devDependencies)
|
|
|
27
27
|
.filter(([name]) => name.startsWith('@deepseek-ai/'))
|
|
28
28
|
.map(([name, version]) => `${name}@${version}`)
|
|
29
29
|
|
|
30
|
+
// Only the package-manager shims need a shell on Windows; every real executable
|
|
31
|
+
// must be spawned directly, because cmd.exe re-parses arguments: `^` is its
|
|
32
|
+
// escape character, so `HEAD^{tree}` reached git as `HEAD{tree}`, and a
|
|
33
|
+
// multi-line `node -e` settings script was cut at its first newline into
|
|
34
|
+
// `[eval]:1 const` / `SyntaxError: Unexpected end of input`. Both killed the
|
|
35
|
+
// official-clone leg for reasons unrelated to what it asserts.
|
|
36
|
+
const SHELL_COMMANDS = new Set(['npm', 'npm.cmd', 'pnpm', 'pnpm.cmd'])
|
|
37
|
+
const usesShell = (command) => process.platform === 'win32' && SHELL_COMMANDS.has(command)
|
|
38
|
+
|
|
30
39
|
const run = (command, args, options = {}) => new Promise((resolve, reject) => {
|
|
31
|
-
const child = spawn(command, args, { stdio: 'inherit', shell:
|
|
40
|
+
const child = spawn(command, args, { stdio: 'inherit', shell: usesShell(command), ...options })
|
|
32
41
|
child.once('error', reject)
|
|
33
42
|
child.once('exit', (code, signal) => {
|
|
34
43
|
if (code === 0) resolve()
|
|
@@ -37,7 +46,7 @@ const run = (command, args, options = {}) => new Promise((resolve, reject) => {
|
|
|
37
46
|
})
|
|
38
47
|
|
|
39
48
|
const capture = (command, args, options = {}) => new Promise((resolve, reject) => {
|
|
40
|
-
const child = spawn(command, args, { shell:
|
|
49
|
+
const child = spawn(command, args, { shell: usesShell(command), ...options, stdio: ['ignore', 'pipe', 'pipe'] })
|
|
41
50
|
let stdout = ''
|
|
42
51
|
let stderr = ''
|
|
43
52
|
child.stdout.setEncoding('utf8')
|
|
@@ -56,7 +65,7 @@ const capture = (command, args, options = {}) => new Promise((resolve, reject) =
|
|
|
56
65
|
})
|
|
57
66
|
|
|
58
67
|
const captureOutcome = (command, args, options = {}) => new Promise((resolve, reject) => {
|
|
59
|
-
const child = spawn(command, args, { shell:
|
|
68
|
+
const child = spawn(command, args, { shell: usesShell(command), ...options, stdio: ['ignore', 'pipe', 'pipe'] })
|
|
60
69
|
let stdout = ''
|
|
61
70
|
let stderr = ''
|
|
62
71
|
child.stdout.setEncoding('utf8')
|
|
@@ -306,7 +315,23 @@ async function runOfficialCloneCliSmoke(referenceRoot, registry, upgradeFrom, ca
|
|
|
306
315
|
const dshHome = join(temporaryRoot, 'dsh-home')
|
|
307
316
|
let added = false
|
|
308
317
|
const environment = { ...process.env, DSH_HOME: dshHome }
|
|
309
|
-
|
|
318
|
+
let lastLifecycle = { command: '(none)', code: 0, stdout: '', stderr: '' }
|
|
319
|
+
const dsh = async (...args) => {
|
|
320
|
+
const outcome = await captureOutcome('pnpm', ['dsh', ...args], { cwd: worktree, env: environment })
|
|
321
|
+
lastLifecycle = {
|
|
322
|
+
command: `pnpm dsh ${args.join(' ')}`,
|
|
323
|
+
code: outcome.code ?? -1,
|
|
324
|
+
stdout: outcome.stdout,
|
|
325
|
+
stderr: outcome.stderr,
|
|
326
|
+
}
|
|
327
|
+
if (outcome.code !== 0) {
|
|
328
|
+
throw new Error([
|
|
329
|
+
`${lastLifecycle.command} exited ${String(lastLifecycle.code)}`,
|
|
330
|
+
outcome.stdout.trim(),
|
|
331
|
+
outcome.stderr.trim(),
|
|
332
|
+
].filter(Boolean).join('\n'))
|
|
333
|
+
}
|
|
334
|
+
}
|
|
310
335
|
const dumpConfig = () => capture('pnpm', ['dsh', '--profile', 'web', '--dump-config'], {
|
|
311
336
|
cwd: worktree,
|
|
312
337
|
env: environment,
|
|
@@ -316,7 +341,36 @@ async function runOfficialCloneCliSmoke(referenceRoot, registry, upgradeFrom, ca
|
|
|
316
341
|
const profilePackages = () => {
|
|
317
342
|
const profileRoot = join(dshHome, 'profiles/web')
|
|
318
343
|
const profileRequire = createRequire(join(profileRoot, 'package.json'))
|
|
319
|
-
|
|
344
|
+
let selectorPath
|
|
345
|
+
try {
|
|
346
|
+
selectorPath = profileRequire.resolve('dsh-context-compression-improved/package.json')
|
|
347
|
+
} catch (error) {
|
|
348
|
+
// A silent no-op lifecycle command is the difference between "the package
|
|
349
|
+
// manager refused" and "the CLI exited 0 without installing anything";
|
|
350
|
+
// carry that evidence instead of a bare MODULE_NOT_FOUND.
|
|
351
|
+
const declared = (() => {
|
|
352
|
+
try {
|
|
353
|
+
return JSON.stringify(JSON.parse(readFileSync(join(profileRoot, 'package.json'), 'utf8')).dependencies ?? {})
|
|
354
|
+
} catch {
|
|
355
|
+
return '(profile package.json unreadable)'
|
|
356
|
+
}
|
|
357
|
+
})()
|
|
358
|
+
const installed = (() => {
|
|
359
|
+
try {
|
|
360
|
+
return JSON.stringify(readdirSync(join(profileRoot, 'node_modules')).slice(0, 40))
|
|
361
|
+
} catch {
|
|
362
|
+
return '(profiles/web/node_modules unreadable)'
|
|
363
|
+
}
|
|
364
|
+
})()
|
|
365
|
+
throw new Error([
|
|
366
|
+
`official profile ${profileRoot} does not resolve dsh-context-compression-improved`,
|
|
367
|
+
`last lifecycle: ${lastLifecycle.command} (exit ${String(lastLifecycle.code)})`,
|
|
368
|
+
`lifecycle stdout: ${lastLifecycle.stdout.trim()}`,
|
|
369
|
+
`lifecycle stderr: ${lastLifecycle.stderr.trim()}`,
|
|
370
|
+
`profile dependencies: ${declared}`,
|
|
371
|
+
`profiles/web/node_modules: ${installed}`,
|
|
372
|
+
].join('\n'), { cause: error })
|
|
373
|
+
}
|
|
320
374
|
return {
|
|
321
375
|
profileRoot,
|
|
322
376
|
selectorPath,
|
|
@@ -623,8 +677,8 @@ async function runOfficialCloneCliSmoke(referenceRoot, registry, upgradeFrom, ca
|
|
|
623
677
|
// $DSH_HOME/profiles/node_modules — the shared-module fallback the
|
|
624
678
|
// plugin's harness peers resolve through in every later raw-node proof.
|
|
625
679
|
const addedDump = await dumpConfig()
|
|
626
|
-
assert(addedDump.includes('context-compression-
|
|
627
|
-
'official post-add dump lacks the
|
|
680
|
+
assert(addedDump.includes('context-compression-improved-bundle'),
|
|
681
|
+
'official post-add dump lacks the context-compression Bundle layer')
|
|
628
682
|
|
|
629
683
|
// Real profile start on the previous release, BEFORE seeding: the
|
|
630
684
|
// previous-release schema predates the autoCompact section, so this probe
|
|
@@ -646,11 +700,11 @@ async function runOfficialCloneCliSmoke(referenceRoot, registry, upgradeFrom, ca
|
|
|
646
700
|
const loadProof = await provePluginLoads()
|
|
647
701
|
const postUpBoot = await runProfileBootProbe('post-up', true)
|
|
648
702
|
const upDump = await dumpConfig()
|
|
649
|
-
for (const marker of ['context-compression-
|
|
703
|
+
for (const marker of ['context-compression-improved-bundle', 'presetOverlay: true']) {
|
|
650
704
|
assert(upDump.includes(marker), `official post-up dump lacks ${marker}`)
|
|
651
705
|
}
|
|
652
|
-
assert(upDump.match(/context-compression-
|
|
653
|
-
'official post-up dump contains more than one
|
|
706
|
+
assert(upDump.match(/context-compression-improved-bundle/gu)?.length === 1,
|
|
707
|
+
'official post-up dump contains more than one context-compression Bundle layer')
|
|
654
708
|
|
|
655
709
|
// pnpm's peers check is informational: plugin peers resolve through the
|
|
656
710
|
// healed profiles/node_modules fallback, which pnpm cannot see by design.
|
|
@@ -661,14 +715,14 @@ async function runOfficialCloneCliSmoke(referenceRoot, registry, upgradeFrom, ca
|
|
|
661
715
|
|
|
662
716
|
await dsh('plugin', '--profile', 'web', 'remove', 'dsh-context-compression-improved')
|
|
663
717
|
const removedDump = await dumpConfig()
|
|
664
|
-
assert(!removedDump.includes('context-compression-
|
|
665
|
-
'official CLI remove left the
|
|
718
|
+
assert(!removedDump.includes('context-compression-improved-bundle'),
|
|
719
|
+
'official CLI remove left the context-compression Bundle layer active')
|
|
666
720
|
|
|
667
721
|
await dsh('plugin', '--profile', 'web', 'add',
|
|
668
722
|
'dsh-context-compression-improved@latest', '--registry', registry)
|
|
669
723
|
const secondDump = await dumpConfig()
|
|
670
|
-
assert(secondDump.includes('context-compression-
|
|
671
|
-
'official CLI reinstall did not restore the
|
|
724
|
+
assert(secondDump.includes('context-compression-improved-bundle'),
|
|
725
|
+
'official CLI reinstall did not restore the context-compression Bundle layer')
|
|
672
726
|
|
|
673
727
|
return {
|
|
674
728
|
tag: before.tag,
|