fix(invariants): harden runtime contracts and gates
This commit is contained in:
@@ -12,7 +12,7 @@ The reference shape for the happy path is MiniCode's `LLMClient`: a stateful con
|
|||||||
|
|
||||||
### The principle
|
### The principle
|
||||||
|
|
||||||
**Model-visible ⟺ logged.** Anything that reaches a model request must be recorded in the session log. The checkable consequence: **every conversation request the loop sends is a pure function of the session log** — anyone holding the log reconstructs it byte-for-byte. Scope, stated precisely: the guarantee covers the loop-built `GenerateOptions`; provider wire bytes follow from it because both adapters' serialization is a pure per-message function at a pinned code version; direct one-shots (compaction's summarize call) log their envelope scalars (`compact/summary.{provider, model, maxTokens}`) and their input is deterministic code over the logged region — reconstructable from log + code, outside the invariant by the unfrozen-request marker.
|
**Model-visible ⟺ logged.** Anything that reaches a model request must be recorded in the session log. The checkable consequence: **every conversation request the loop sends is a pure function of the session log** — anyone holding the log reconstructs it byte-for-byte. Scope, stated precisely: the guarantee covers the loop-built `GenerateOptions`; provider wire bytes follow from it because both adapters' serialization is a pure per-message function at a pinned code version; direct one-shots (compaction's summarize call) log their envelope scalars (`compact/summary.{provider, model, maxTokens}`) and their input is deterministic code over the logged region — reconstructable from log + code, outside the invariant because only the loop marks request ownership.
|
||||||
|
|
||||||
Prefix-cache stability is corollary #1, not the headline: an append-only log projected by a per-node pure function yields requests that are append-extensions of their predecessors whenever the header is unchanged — stability is emergent, not managed. Byte-exact audit/replay is corollary #2; resume and fork with *attributable* drift is corollary #3.
|
Prefix-cache stability is corollary #1, not the headline: an append-only log projected by a per-node pure function yields requests that are append-extensions of their predecessors whenever the header is unchanged — stability is emergent, not managed. Byte-exact audit/replay is corollary #2; resume and fork with *attributable* drift is corollary #3.
|
||||||
|
|
||||||
@@ -26,7 +26,7 @@ Each step rebuilds prompt assembly. On the instance's first step, `agent/session
|
|||||||
|
|
||||||
**`step/start` is the reconstruction boundary.** A step derives messages from events before that sequence. Injection after the snapshot joins the next request, and reentrant appends are rejected during event publication. `agent/pre-step(agent, turn, step, signal)` remains the generic seam for content needed by the current request. Header reconstruction selects the step's `request/header`, or carries the prior snapshot when no new header is written.
|
**`step/start` is the reconstruction boundary.** A step derives messages from events before that sequence. Injection after the snapshot joins the next request, and reentrant appends are rejected during event publication. `agent/pre-step(agent, turn, step, signal)` remains the generic seam for content needed by the current request. Header reconstruction selects the step's `request/header`, or carries the prior snapshot when no new header is written.
|
||||||
|
|
||||||
**Enforcement.** The `dsh-agent-loop/invariant` companion registers with `ctx.invariants` and, when selected, independently rebuilds each loop request through a fresh `Session`, so the live cache cannot vouch for itself, then compares messages and folded header fields at `llm/stream`. Loop requests are identified by their frozen shape and session id; direct one-shots are excluded. Correctness depends on sequence-bounded reconstruction rather than listener order. A with-key e2e requires positive cache-read tokens after the first request; per-step usage is the production signal, and a header change or compaction appears as a cache-read drop on the next step.
|
**Enforcement.** The `dsh-agent-loop/invariant` companion registers with `ctx.invariants` and, when selected, independently rebuilds each loop request through a fresh `Session`, so the live cache cannot vouch for itself, then compares messages and folded header fields at `llm/stream`. The loop applies an internal non-enumerable identity before freezing each request; the independently built companion recognizes that identity, while direct one-shots remain excluded regardless of their frozen shape or session id. Correctness depends on sequence-bounded reconstruction rather than listener order. A with-key e2e requires positive cache-read tokens after the first request; per-step usage is the production signal, and a header change or compaction appears as a cache-read drop on the next step.
|
||||||
|
|
||||||
### The MiniCode shape: adopted, with the provenance arrow inverted
|
### The MiniCode shape: adopted, with the provenance arrow inverted
|
||||||
|
|
||||||
|
|||||||
@@ -2,5 +2,5 @@
|
|||||||
# side as of the last confirmed-consistent state. Both languages carry equal authority;
|
# side as of the last confirmed-consistent state. Both languages carry equal authority;
|
||||||
# after editing either side, bring the other along and re-record with:
|
# after editing either side, bring the other along and re-record with:
|
||||||
# pnpm run verify-translation-pairing --write
|
# pnpm run verify-translation-pairing --write
|
||||||
2026-07-19-package-invariant-runtime-contracts.md: 84c632e57b3360756660415d7bdd62397c52e4a3
|
2026-07-19-package-invariant-runtime-contracts.md: ab4301a80b01ac2b4b2870f02c232ab321a6fab1
|
||||||
2026-07-19-package-invariant-runtime-contracts.zh.md: 51734f6922f393ae37b0c3999723b7aade7651eb
|
2026-07-19-package-invariant-runtime-contracts.zh.md: 2e0816b2f381fd8fa51df51d8c482d6d4adf48b4
|
||||||
|
|||||||
@@ -34,9 +34,9 @@ The current 94-package workspace has 19 executable companions and 75 justified e
|
|||||||
| `dsh-session` | Strict sequence growth, turn/step enclosure, and same-step tool call/result pairing. |
|
| `dsh-session` | Strict sequence growth, turn/step enclosure, and same-step tool call/result pairing. |
|
||||||
| `dsh-agent` | Non-repeating agent status and terminal disposal transitions. |
|
| `dsh-agent` | Non-repeating agent status and terminal disposal transitions. |
|
||||||
| `dsh-scope` | Scoped-event carrier presence and routed-subject consistency. |
|
| `dsh-scope` | Scoped-event carrier presence and routed-subject consistency. |
|
||||||
| `dsh-agent-loop` | Frozen loop request reconstruction from the session event log. |
|
| `dsh-agent-loop` | Explicitly marked, frozen loop request reconstruction from the session event log. |
|
||||||
| `dsh-llm` | Stream block grammar, delta type/index matching, single usage, closed blocks, and terminal finish. |
|
| `dsh-llm` | Stream block grammar, delta type/index matching, single usage, closed blocks, and terminal finish. |
|
||||||
| `dsh-llm-retry` | Durable retry records identify the open turn's latest closed step, remain unique per step, increase monotonically, and stay within retry and timer bounds. |
|
| `dsh-llm-retry` | Durable retry records identify the open turn's latest closed step, remain unique per step, increase monotonically, and stay within retry and non-negative timer bounds. |
|
||||||
| `dsh-tools` | Monotonic pre/execute/post stages and immutable final execution/result snapshots. |
|
| `dsh-tools` | Monotonic pre/execute/post stages and immutable final execution/result snapshots. |
|
||||||
| `dsh-system-prompt` | Authoritative assembly section, tool, and variable data constraints. |
|
| `dsh-system-prompt` | Authoritative assembly section, tool, and variable data constraints. |
|
||||||
| `dsh-compact` | Compaction start/summary/end pairing, range endpoints, token counts, and successful-summary presence. |
|
| `dsh-compact` | Compaction start/summary/end pairing, range endpoints, token counts, and successful-summary presence. |
|
||||||
@@ -49,15 +49,15 @@ The current 94-package workspace has 19 executable companions and 75 justified e
|
|||||||
| `dsh-workflow` | Workflow and child-agent start/end events preserve run metadata, identity, outcome, count, and error relations. |
|
| `dsh-workflow` | Workflow and child-agent start/end events preserve run metadata, identity, outcome, count, and error relations. |
|
||||||
| `dsh-tasks` | Current and terminal task snapshots preserve id/kind, owner, status, and timestamp relationships. |
|
| `dsh-tasks` | Current and terminal task snapshots preserve id/kind, owner, status, and timestamp relationships. |
|
||||||
| `dsh-tool-todo` | Durable whole-list snapshots use unique trimmed items, closed statuses, and at most one active item. |
|
| `dsh-tool-todo` | Durable whole-list snapshots use unique trimmed items, closed statuses, and at most one active item. |
|
||||||
| `dsh-time-context` | Plugin-attributed clock readings agree with the session's open turn and next pre-step position, elapsed baseline, rendered time, and event time. |
|
| `dsh-time-context` | Plugin-attributed clock readings agree with the session's open turn, next pre-step position, and elapsed baseline; rendered time parses and does not postdate its event. |
|
||||||
|
|
||||||
Session-backed companions reconstruct their trace from existing durable events when they load. Other checks observe the authoritative live event boundary or mutable service result. Validation runs before publication where accepting an invalid event would otherwise commit bad state.
|
Session-backed companions reconstruct their trace from existing durable events when they load. Other checks observe the authoritative live event boundary or mutable service result. Validation runs before publication where accepting an invalid event would otherwise commit bad state.
|
||||||
|
|
||||||
### Repository gate and tests
|
### Repository gate and tests
|
||||||
|
|
||||||
`verify-package-invariants` discovers every workspace package and enforces companion source, exact-name registration, `./invariant` exports, publication files, dependencies, TypeScript references, and bundle entries. Its AST rule rejects generated markers and unexplained empty installers. A non-empty installer must accept and use the failure reporter, and registration must pass that checked local `install` function. The gate deliberately does not infer semantic quality from method names or helper calls.
|
`verify-package-invariants` discovers every workspace package and enforces companion source, exact-name registration, named-only Loader shape, `./invariant` exports, publication files, dependencies, TypeScript references, and bundle entries. Its AST rule rejects generated markers, default exports, and unexplained empty installers. A non-empty installer must accept and use the failure reporter, and registration must pass that checked local `install` function. The gate deliberately does not infer semantic quality from method names or helper calls.
|
||||||
|
|
||||||
Vitest mounts `InvariantService` with `{ enabled: true }` for every package test topology and loads the owning companion. The invariant subpath path mapping resolves source companions instead of stale built output. Focused suites cover every executable companion's valid and invalid observations, and the exhaustive topology loads all companions to prove registration and disposal wiring. Tests that synthesize event streams must produce a valid surrounding lifecycle unless the test is intentionally asserting a violation.
|
Vitest mounts `InvariantService` with `{ enabled: true }` for every package test topology and loads the owning companion. The invariant subpath path mapping resolves source companions instead of stale built output. Focused suites cover every executable companion's valid and invalid observations, and the exhaustive topology runs every source companion through the real Loader namespace normalization. An artifact gate imports every compiled `./invariant` self-reference under plain Node and repeats that Loader-shape check. Tests that synthesize event streams must produce a valid surrounding lifecycle unless the test is intentionally asserting a violation.
|
||||||
|
|
||||||
## Alternatives considered
|
## Alternatives considered
|
||||||
|
|
||||||
|
|||||||
@@ -34,9 +34,9 @@ Status: implemented
|
|||||||
| `dsh-session` | 序号严格递增、turn/step 包围关系,以及同一 step 内的工具调用/结果配对。 |
|
| `dsh-session` | 序号严格递增、turn/step 包围关系,以及同一 step 内的工具调用/结果配对。 |
|
||||||
| `dsh-agent` | agent 状态不得重复,并且不能离开终态 disposed。 |
|
| `dsh-agent` | agent 状态不得重复,并且不能离开终态 disposed。 |
|
||||||
| `dsh-scope` | scoped event 必须携带 carrier,且路由 subject 保持一致。 |
|
| `dsh-scope` | scoped event 必须携带 carrier,且路由 subject 保持一致。 |
|
||||||
| `dsh-agent-loop` | 从 session 事件日志重建冻结的 loop 请求。 |
|
| `dsh-agent-loop` | 从 session 事件日志重建带显式标记的冻结 loop 请求。 |
|
||||||
| `dsh-llm` | stream block 文法、delta 类型/索引匹配、单次 usage、block 闭合和终止 finish。 |
|
| `dsh-llm` | stream block 文法、delta 类型/索引匹配、单次 usage、block 闭合和终止 finish。 |
|
||||||
| `dsh-llm-retry` | 持久化重试记录指向当前打开 turn 中最近关闭的 step;每个 step 的记录保持唯一,重试次数单调递增,并且重试次数和定时器延迟均保持在边界内。 |
|
| `dsh-llm-retry` | 持久化重试记录指向当前打开 turn 中最近关闭的 step;每个 step 的记录保持唯一,重试次数单调递增,并且重试次数和非负的定时器延迟均保持在边界内。 |
|
||||||
| `dsh-tools` | pre/execute/post 阶段单调推进,以及最终 execution/result 快照不可变。 |
|
| `dsh-tools` | pre/execute/post 阶段单调推进,以及最终 execution/result 快照不可变。 |
|
||||||
| `dsh-system-prompt` | 权威 assembly 中 section、tool 和 variable 的数据约束。 |
|
| `dsh-system-prompt` | 权威 assembly 中 section、tool 和 variable 的数据约束。 |
|
||||||
| `dsh-compact` | compaction start/summary/end 配对、范围端点、token 数量和成功时必须存在 summary。 |
|
| `dsh-compact` | compaction start/summary/end 配对、范围端点、token 数量和成功时必须存在 summary。 |
|
||||||
@@ -49,15 +49,15 @@ Status: implemented
|
|||||||
| `dsh-workflow` | workflow 和 child-agent start/end 事件保持 run metadata、身份、outcome、数量和 error 关系。 |
|
| `dsh-workflow` | workflow 和 child-agent start/end 事件保持 run metadata、身份、outcome、数量和 error 关系。 |
|
||||||
| `dsh-tasks` | 当前与终态 task snapshot 保持 id/kind、owner、status 和 timestamp 关系。 |
|
| `dsh-tasks` | 当前与终态 task snapshot 保持 id/kind、owner、status 和 timestamp 关系。 |
|
||||||
| `dsh-tool-todo` | 持久化全量 snapshot 使用唯一且已 trim 的条目、封闭 status,并且最多有一个活动条目。 |
|
| `dsh-tool-todo` | 持久化全量 snapshot 使用唯一且已 trim 的条目、封闭 status,并且最多有一个活动条目。 |
|
||||||
| `dsh-time-context` | 标注插件来源的时钟 reading 必须匹配 session 当前打开的 turn 和下一个 step 开始前的位置,并在 elapsed baseline、渲染时间和事件时间之间保持一致。 |
|
| `dsh-time-context` | 标注插件来源的时钟 reading 必须匹配 session 当前打开的 turn、下一个 step 开始前的位置和 elapsed baseline;渲染时间必须可解析,且不得晚于对应事件。 |
|
||||||
|
|
||||||
基于 session 的 companion 在加载时从已有持久化事件重建 trace。其他检查观测权威 live event 边界或可变服务结果。如果接受无效事件会提交错误状态,验证就在发布前执行。
|
基于 session 的 companion 在加载时从已有持久化事件重建 trace。其他检查观测权威 live event 边界或可变服务结果。如果接受无效事件会提交错误状态,验证就在发布前执行。
|
||||||
|
|
||||||
### 仓库门禁与测试
|
### 仓库门禁与测试
|
||||||
|
|
||||||
`verify-package-invariants` 发现每个 workspace 包,并强制 companion 源文件、完整名称注册、`./invariant` export、发布文件、依赖、TypeScript reference 和 bundle entry 完整。其 AST 规则拒绝生成标记和没有解释的空安装器。非空安装器必须接收并使用失败报告器,注册时还必须传入该经检查的本地 `install` 函数。门禁不会通过方法名或 helper 调用推断语义质量。
|
`verify-package-invariants` 发现每个 workspace 包,并强制 companion 源文件、完整名称注册、仅含具名 export 的 Loader 形状、`./invariant` export、发布文件、依赖、TypeScript reference 和 bundle entry 完整。其 AST 规则拒绝生成标记、默认导出和没有解释的空安装器。非空安装器必须接收并使用失败报告器,注册时还必须传入该经检查的本地 `install` 函数。门禁不会通过方法名或 helper 调用推断语义质量。
|
||||||
|
|
||||||
Vitest 为每个包测试拓扑使用 `{ enabled: true }` 挂载 `InvariantService`,并加载所有者 companion。不变量 subpath 的 path mapping 会解析源 companion,而不是陈旧的构建输出。聚焦 suite 覆盖每个可执行 companion 的有效和无效观测;穷举拓扑加载全部 companion,以证明注册和释放 wiring。合成事件流的测试必须构造有效的外围生命周期,除非测试本身就是在断言违规。
|
Vitest 为每个包测试拓扑使用 `{ enabled: true }` 挂载 `InvariantService`,并加载所有者 companion。不变量 subpath 的 path mapping 会解析源 companion,而不是陈旧的构建输出。聚焦 suite 覆盖每个可执行 companion 的有效和无效观测;穷举拓扑通过真实 Loader 命名空间归一化运行每个源 companion。产物门禁在 plain Node 下导入每个已编译的 `./invariant` 自引用,并重复执行该 Loader 形状检查。合成事件流的测试必须构造有效的外围生命周期,除非测试本身就是在断言违规。
|
||||||
|
|
||||||
## 考虑过的替代方案
|
## 考虑过的替代方案
|
||||||
|
|
||||||
|
|||||||
@@ -41,6 +41,7 @@
|
|||||||
"verify-doc-refs": "tsx scripts/verify-doc-refs.ts",
|
"verify-doc-refs": "tsx scripts/verify-doc-refs.ts",
|
||||||
"verify-package-paths": "tsx scripts/verify-package-paths.ts",
|
"verify-package-paths": "tsx scripts/verify-package-paths.ts",
|
||||||
"verify-package-invariants": "tsx scripts/verify-package-invariants.ts",
|
"verify-package-invariants": "tsx scripts/verify-package-invariants.ts",
|
||||||
|
"verify-built-package-invariants": "node scripts/verify-built-package-invariants.mjs",
|
||||||
"verify-package-readme-model-experience": "tsx scripts/verify-package-readme-model-experience.ts",
|
"verify-package-readme-model-experience": "tsx scripts/verify-package-readme-model-experience.ts",
|
||||||
"verify-mermaid": "tsx scripts/verify-mermaid.ts",
|
"verify-mermaid": "tsx scripts/verify-mermaid.ts",
|
||||||
"verify-agent-note-classification": "tsx scripts/verify-agent-note-classification.ts",
|
"verify-agent-note-classification": "tsx scripts/verify-agent-note-classification.ts",
|
||||||
@@ -78,7 +79,7 @@
|
|||||||
"verify-module-graph": "tsx scripts/gen-module-graph.ts --check",
|
"verify-module-graph": "tsx scripts/gen-module-graph.ts --check",
|
||||||
"constraints": "tsx scripts/check-workspace-constraints.ts",
|
"constraints": "tsx scripts/check-workspace-constraints.ts",
|
||||||
"doc-sync": "pnpm run doc-typecheck && pnpm run verify-cordis-catalog && pnpm run verify-cordis-api && pnpm run verify-export-jsdoc && pnpm run verify-tool-catalog && pnpm run verify-config-catalog && pnpm run verify-persistence-catalog && pnpm run verify-doc-graphs && pnpm run verify-scoped-events && pnpm run verify-md-wrap && pnpm run verify-md-links && pnpm run verify-doc-refs && pnpm run verify-package-paths && pnpm run verify-package-readme-model-experience && pnpm run verify-mermaid && pnpm run verify-agent-note-classification && pnpm run verify-agent-note-format && pnpm run verify-type-equiv && pnpm run verify-translation-prompt && pnpm run verify-translation-pairing && pnpm run verify-doc-budgets && pnpm run verify-package-readme-limitations && pnpm run docs:check",
|
"doc-sync": "pnpm run doc-typecheck && pnpm run verify-cordis-catalog && pnpm run verify-cordis-api && pnpm run verify-export-jsdoc && pnpm run verify-tool-catalog && pnpm run verify-config-catalog && pnpm run verify-persistence-catalog && pnpm run verify-doc-graphs && pnpm run verify-scoped-events && pnpm run verify-md-wrap && pnpm run verify-md-links && pnpm run verify-doc-refs && pnpm run verify-package-paths && pnpm run verify-package-readme-model-experience && pnpm run verify-mermaid && pnpm run verify-agent-note-classification && pnpm run verify-agent-note-format && pnpm run verify-type-equiv && pnpm run verify-translation-prompt && pnpm run verify-translation-pairing && pnpm run verify-doc-budgets && pnpm run verify-package-readme-limitations && pnpm run docs:check",
|
||||||
"hygiene": "pnpm run knip && pnpm run publint && pnpm run constraints && pnpm run verify-package-invariants && pnpm run verify-cordis-config && pnpm run verify-node-next-types && pnpm run verify-runtime-closure",
|
"hygiene": "pnpm run knip && pnpm run publint && pnpm run constraints && pnpm run verify-package-invariants && pnpm run verify-built-package-invariants && pnpm run verify-cordis-config && pnpm run verify-node-next-types && pnpm run verify-runtime-closure",
|
||||||
"demo:headless": "node --expose-internals --import tsx packages/examples/cli-demo/src/bin.ts --config examples/headless-agent/cordis.yml",
|
"demo:headless": "node --expose-internals --import tsx packages/examples/cli-demo/src/bin.ts --config examples/headless-agent/cordis.yml",
|
||||||
"demo:tui": "node --expose-internals --import tsx packages/examples/tui-demo/src/bin.ts examples/tui-agent/cordis.yml",
|
"demo:tui": "node --expose-internals --import tsx packages/examples/tui-demo/src/bin.ts examples/tui-agent/cordis.yml",
|
||||||
"demo:code-mode": "node scripts/demo-code-mode.mjs",
|
"demo:code-mode": "node scripts/demo-code-mode.mjs",
|
||||||
|
|||||||
@@ -26,6 +26,8 @@ Step 1 measures from the latest preceding model-visible message, including the p
|
|||||||
|
|
||||||
A time reading records a request-preparation attempt, not a committed step or transmitted request. Because the listener runs first, its append may remain when a later pre-step listener cancels or fails the attempt; the log is append-only and the plugin performs no rollback.
|
A time reading records a request-preparation attempt, not a committed step or transmitted request. Because the listener runs first, its append may remain when a later pre-step listener cancels or fails the attempt; the log is append-only and the plugin performs no rollback.
|
||||||
|
|
||||||
|
The separately published `./invariant` companion checks each plugin-attributed reading against the open turn, next pre-step position, elapsed baseline, and durable event time. Its rendered timestamp must parse and cannot postdate the event; process suspension between sampling and append does not invalidate the reading.
|
||||||
|
|
||||||
The time reading stays in derived conversation history until a later compaction shadows it. Request headers contain no time-context state. Request reconstruction uses the complete durable surface prefix at each `step/start`, so transmitted requests need not map one-to-one to readings: a failed preparation can leave an extra reading, while interval suppression can let a request reuse existing history without adding one.
|
The time reading stays in derived conversation history until a later compaction shadows it. Request headers contain no time-context state. Request reconstruction uses the complete durable surface prefix at each `step/start`, so transmitted requests need not map one-to-one to readings: a failed preparation can leave an extra reading, while interval suppression can let a request reuse existing history without adding one.
|
||||||
|
|
||||||
## Model Experience
|
## Model Experience
|
||||||
|
|||||||
@@ -75,8 +75,8 @@ function validateReading(
|
|||||||
if (rendered === undefined) fail('time-context reading omitted its rendered timestamp')
|
if (rendered === undefined) fail('time-context reading omitted its rendered timestamp')
|
||||||
const renderedTime = Date.parse(rendered.replace(/\[[^\]]+\]$/, ''))
|
const renderedTime = Date.parse(rendered.replace(/\[[^\]]+\]$/, ''))
|
||||||
if (!Number.isFinite(renderedTime) || !Number.isSafeInteger(event.time)
|
if (!Number.isFinite(renderedTime) || !Number.isSafeInteger(event.time)
|
||||||
|| event.time < renderedTime || event.time - renderedTime >= 1_000) {
|
|| event.time < renderedTime) {
|
||||||
fail('time-context rendered timestamp must identify the durable event second')
|
fail('time-context rendered timestamp must parse and not postdate its durable event')
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -62,6 +62,13 @@ describe('time-context invariants', () => {
|
|||||||
expect(() => { ctx.emit('session/event', preparing(2, 3), event(text)) }).not.toThrow()
|
expect(() => { ctx.emit('session/event', preparing(2, 3), event(text)) }).not.toThrow()
|
||||||
})
|
})
|
||||||
|
|
||||||
|
it('accepts a reading durably appended after a long process pause', async () => {
|
||||||
|
const ctx = await setup()
|
||||||
|
expect(() => {
|
||||||
|
ctx.emit('session/event', preparing(1, 1), event(reading(), SECOND + 60_000))
|
||||||
|
}).not.toThrow()
|
||||||
|
})
|
||||||
|
|
||||||
it.each([
|
it.each([
|
||||||
[reading('1', '3', 'step context'), /expected turn 2\/step 3/],
|
[reading('1', '3', 'step context'), /expected turn 2\/step 3/],
|
||||||
[reading('2', '2', 'step context'), /expected turn 2\/step 3/],
|
[reading('2', '2', 'step context'), /expected turn 2\/step 3/],
|
||||||
@@ -96,10 +103,9 @@ describe('time-context invariants', () => {
|
|||||||
[reading('1', '999999999999999999999', 'step context'), SECOND, undefined, /positive safe integers/],
|
[reading('1', '999999999999999999999', 'step context'), SECOND, undefined, /positive safe integers/],
|
||||||
[reading('1', '1', 'step context'), SECOND, undefined, /wrong elapsed-time baseline/],
|
[reading('1', '1', 'step context'), SECOND, undefined, /wrong elapsed-time baseline/],
|
||||||
[reading('1', '2', 'model-visible message'), SECOND, undefined, /wrong elapsed-time baseline/],
|
[reading('1', '2', 'model-visible message'), SECOND, undefined, /wrong elapsed-time baseline/],
|
||||||
[reading('1', '1', 'model-visible message', '2026-99-99T00:00:00+00:00[UTC]'), SECOND, undefined, /durable event second/],
|
[reading('1', '1', 'model-visible message', '2026-99-99T00:00:00+00:00[UTC]'), SECOND, undefined, /must parse and not postdate/],
|
||||||
[reading(), Number.NaN, undefined, /durable event second/],
|
[reading(), Number.NaN, undefined, /must parse and not postdate/],
|
||||||
[reading(), SECOND - 1, undefined, /durable event second/],
|
[reading(), SECOND - 1, undefined, /must parse and not postdate/],
|
||||||
[reading(), SECOND + 1_000, undefined, /durable event second/],
|
|
||||||
['ignored', SECOND, [], /exactly one text block/],
|
['ignored', SECOND, [], /exactly one text block/],
|
||||||
['ignored', SECOND, [{ type: 'image', data: 'x', mimeType: 'image/png' }], /exactly one text block/],
|
['ignored', SECOND, [{ type: 'image', data: 'x', mimeType: 'image/png' }], /exactly one text block/],
|
||||||
['ignored', SECOND, [{ type: 'text', text: 'one' }, { type: 'text', text: 'two' }], /exactly one text block/],
|
['ignored', SECOND, [{ type: 'text', text: 'one' }, { type: 'text', text: 'two' }], /exactly one text block/],
|
||||||
|
|||||||
@@ -29,7 +29,7 @@ The config-driven `ctx.agentLoop.create()` path keeps its agent owned by the loo
|
|||||||
|
|
||||||
### Invariant companion
|
### Invariant companion
|
||||||
|
|
||||||
The optional `@deepseek-ai/dsh-agent-loop/invariant` companion registers request reconstruction with `ctx.invariants`. For each frozen loop-built request carrying a live session id, it independently rebuilds the message boundary and folded request header from the session log; direct one-shot calls remain outside this marker contract.
|
The optional `@deepseek-ai/dsh-agent-loop/invariant` companion registers request reconstruction with `ctx.invariants`. The loop marks each request with an internal non-enumerable identity before freezing it; the companion then requires a live session and independently rebuilds the message boundary and folded request header from the log. Direct one-shot calls remain outside this contract even when callers freeze them or attach a session id.
|
||||||
|
|
||||||
### Configuration (schemastery)
|
### Configuration (schemastery)
|
||||||
|
|
||||||
|
|||||||
@@ -7,6 +7,7 @@ import type { Context } from 'cordis'
|
|||||||
import type { GenerateOptions } from '@deepseek-ai/dsh-llm'
|
import type { GenerateOptions } from '@deepseek-ai/dsh-llm'
|
||||||
import type { InvariantFailure, InvariantInstaller } from '@deepseek-ai/dsh-invariants'
|
import type { InvariantFailure, InvariantInstaller } from '@deepseek-ai/dsh-invariants'
|
||||||
import { Session, SessionId, foldRequestHeader } from '@deepseek-ai/dsh-session'
|
import { Session, SessionId, foldRequestHeader } from '@deepseek-ai/dsh-session'
|
||||||
|
import { isLoopRequest } from './request-marker.ts'
|
||||||
|
|
||||||
const PACKAGE_NAME = '@deepseek-ai/dsh-agent-loop'
|
const PACKAGE_NAME = '@deepseek-ai/dsh-agent-loop'
|
||||||
|
|
||||||
@@ -20,9 +21,11 @@ const install: InvariantInstaller = Object.assign((ctx: Context, fail: Invariant
|
|||||||
// Prepend prevents a short-circuiting replay listener from silencing the
|
// Prepend prevents a short-circuiting replay listener from silencing the
|
||||||
// check; correctness itself comes from the sequence-bounded reconstruction.
|
// check; correctness itself comes from the sequence-bounded reconstruction.
|
||||||
ctx.on('llm/stream', (options: GenerateOptions, next) => {
|
ctx.on('llm/stream', (options: GenerateOptions, next) => {
|
||||||
if (options.sessionId === undefined || !Object.isFrozen(options)) return next()
|
if (!isLoopRequest(options)) return next()
|
||||||
|
if (!Object.isFrozen(options)) fail('a loop-built request must be frozen')
|
||||||
|
if (options.sessionId === undefined) fail('a loop-built request must carry a session id')
|
||||||
const session = ctx.sessions.get(options.sessionId)
|
const session = ctx.sessions.get(options.sessionId)
|
||||||
if (!session) return next()
|
if (!session) fail(`a loop-built request must carry a live session id, got "${String(options.sessionId)}"`)
|
||||||
if (!Object.isFrozen(options.messages)) {
|
if (!Object.isFrozen(options.messages)) {
|
||||||
fail('a loop-built request must carry a frozen messages array')
|
fail('a loop-built request must carry a frozen messages array')
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -15,6 +15,7 @@ import { canonicalHeader } from '@deepseek-ai/dsh-session'
|
|||||||
import type { Session, TurnEndReason, TurnTrigger } from '@deepseek-ai/dsh-session'
|
import type { Session, TurnEndReason, TurnTrigger } from '@deepseek-ai/dsh-session'
|
||||||
import { createTransmissionLog, recordRequestHeader } from './request-log.ts'
|
import { createTransmissionLog, recordRequestHeader } from './request-log.ts'
|
||||||
import type { TransmissionLog } from './request-log.ts'
|
import type { TransmissionLog } from './request-log.ts'
|
||||||
|
import { markLoopRequest } from './request-marker.ts'
|
||||||
import { renderPrompt } from '@deepseek-ai/dsh-system-prompt'
|
import { renderPrompt } from '@deepseek-ai/dsh-system-prompt'
|
||||||
import type { PromptAssembly } from '@deepseek-ai/dsh-system-prompt'
|
import type { PromptAssembly } from '@deepseek-ai/dsh-system-prompt'
|
||||||
import type {} from '@deepseek-ai/dsh-tools'
|
import type {} from '@deepseek-ai/dsh-tools'
|
||||||
@@ -619,7 +620,7 @@ async function runStep(
|
|||||||
recordRequestHeader(session, transmission, header)
|
recordRequestHeader(session, transmission, header)
|
||||||
|
|
||||||
// Freeze the logged header plus boundary snapshot; the prefix precedes derived history.
|
// Freeze the logged header plus boundary snapshot; the prefix precedes derived history.
|
||||||
const request: GenerateOptions = deepFreeze({
|
const request: GenerateOptions = deepFreeze(markLoopRequest({
|
||||||
provider: header.config.provider,
|
provider: header.config.provider,
|
||||||
model: header.config.model,
|
model: header.config.model,
|
||||||
messages: [...header.messagePrefix ?? [], ...boundaryMessages],
|
messages: [...header.messagePrefix ?? [], ...boundaryMessages],
|
||||||
@@ -630,7 +631,7 @@ async function runStep(
|
|||||||
...header.config.stop !== undefined ? { stop: header.config.stop } : {},
|
...header.config.stop !== undefined ? { stop: header.config.stop } : {},
|
||||||
sessionId: session.id,
|
sessionId: session.id,
|
||||||
signal,
|
signal,
|
||||||
})
|
}))
|
||||||
|
|
||||||
// --- Model call (streaming-first; raw chunks are the replay record) ---
|
// --- Model call (streaming-first; raw chunks are the replay record) ---
|
||||||
const assembler = new BlockAssembler()
|
const assembler = new BlockAssembler()
|
||||||
|
|||||||
22
packages/core/agent-loop/src/request-marker.ts
Normal file
22
packages/core/agent-loop/src/request-marker.ts
Normal file
@@ -0,0 +1,22 @@
|
|||||||
|
/** Internal identity shared by the independently bundled loop and invariant companion. */
|
||||||
|
|
||||||
|
const LOOP_REQUEST = Symbol.for('@deepseek-ai/dsh-agent-loop/request')
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Mark a request as owned by the agent loop before it is frozen.
|
||||||
|
* @param request - mutable request object being assembled by the loop.
|
||||||
|
* @returns the same request with a non-enumerable loop identity.
|
||||||
|
*/
|
||||||
|
export function markLoopRequest<T extends object>(request: T): T {
|
||||||
|
Object.defineProperty(request, LOOP_REQUEST, { value: true })
|
||||||
|
return request
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Test whether a request carries the agent loop's internal identity.
|
||||||
|
* @param request - request observed at the LLM stream boundary.
|
||||||
|
* @returns whether the loop marked this exact request object.
|
||||||
|
*/
|
||||||
|
export function isLoopRequest(request: object): boolean {
|
||||||
|
return Reflect.get(request, LOOP_REQUEST) === true
|
||||||
|
}
|
||||||
@@ -3,6 +3,7 @@ import { Context } from 'cordis'
|
|||||||
import SessionStore, { SessionId } from '@deepseek-ai/dsh-session'
|
import SessionStore, { SessionId } from '@deepseek-ai/dsh-session'
|
||||||
import InvariantService from '@deepseek-ai/dsh-invariants'
|
import InvariantService from '@deepseek-ai/dsh-invariants'
|
||||||
import * as AgentLoopInvariant from '@deepseek-ai/dsh-agent-loop/invariant'
|
import * as AgentLoopInvariant from '@deepseek-ai/dsh-agent-loop/invariant'
|
||||||
|
import { markLoopRequest } from '../src/request-marker.ts'
|
||||||
|
|
||||||
async function setup(): Promise<Context> {
|
async function setup(): Promise<Context> {
|
||||||
const ctx = new Context()
|
const ctx = new Context()
|
||||||
@@ -16,6 +17,10 @@ function dispatch(ctx: Context, options: unknown): void {
|
|||||||
void ctx.waterfall('llm/stream', options as never, () => (async function* () {})() as never)
|
void ctx.waterfall('llm/stream', options as never, () => (async function* () {})() as never)
|
||||||
}
|
}
|
||||||
|
|
||||||
|
function loopRequest<T extends object>(options: T): Readonly<T> {
|
||||||
|
return Object.freeze(markLoopRequest(options))
|
||||||
|
}
|
||||||
|
|
||||||
async function requestSetup() {
|
async function requestSetup() {
|
||||||
const ctx = await setup()
|
const ctx = await setup()
|
||||||
const session = ctx.sessions.create(SessionId('req-check'))
|
const session = ctx.sessions.create(SessionId('req-check'))
|
||||||
@@ -30,14 +35,14 @@ async function requestSetup() {
|
|||||||
describe('request-reconstruction invariant', () => {
|
describe('request-reconstruction invariant', () => {
|
||||||
it('accepts a frozen request equal to the boundary derivation and folded header', async () => {
|
it('accepts a frozen request equal to the boundary derivation and folded header', async () => {
|
||||||
const { ctx, session, boundary } = await requestSetup()
|
const { ctx, session, boundary } = await requestSetup()
|
||||||
const options = Object.freeze({ model: 'm', messages: Object.freeze(boundary), sessionId: session.id })
|
const options = loopRequest({ model: 'm', messages: Object.freeze(boundary), sessionId: session.id })
|
||||||
expect(() => { dispatch(ctx, options) }).not.toThrow()
|
expect(() => { dispatch(ctx, options) }).not.toThrow()
|
||||||
})
|
})
|
||||||
|
|
||||||
it('uses the step boundary rather than content appended afterward', async () => {
|
it('uses the step boundary rather than content appended afterward', async () => {
|
||||||
const { ctx, session, boundary } = await requestSetup()
|
const { ctx, session, boundary } = await requestSetup()
|
||||||
session.append('context/message', { content: [{ type: 'text', text: '[late]' }], source: { kind: 'plugin', plugin: 'x' } }, { surfaceOp: 'append' })
|
session.append('context/message', { content: [{ type: 'text', text: '[late]' }], source: { kind: 'plugin', plugin: 'x' } }, { surfaceOp: 'append' })
|
||||||
const options = Object.freeze({ model: 'm', messages: Object.freeze(boundary), sessionId: session.id })
|
const options = loopRequest({ model: 'm', messages: Object.freeze(boundary), sessionId: session.id })
|
||||||
expect(() => { dispatch(ctx, options) }).not.toThrow()
|
expect(() => { dispatch(ctx, options) }).not.toThrow()
|
||||||
})
|
})
|
||||||
|
|
||||||
@@ -45,20 +50,20 @@ describe('request-reconstruction invariant', () => {
|
|||||||
const { ctx, session, boundary } = await requestSetup()
|
const { ctx, session, boundary } = await requestSetup()
|
||||||
const prefix = { role: 'user' as const, content: [{ type: 'text' as const, text: '<system-reminder>catalog</system-reminder>' }] }
|
const prefix = { role: 'user' as const, content: [{ type: 'text' as const, text: '<system-reminder>catalog</system-reminder>' }] }
|
||||||
session.append('request/header', { header: { config: { provider: 'mock', model: 'm' }, messagePrefix: [prefix] }, reason: 'change' })
|
session.append('request/header', { header: { config: { provider: 'mock', model: 'm' }, messagePrefix: [prefix] }, reason: 'change' })
|
||||||
expect(() => { dispatch(ctx, Object.freeze({ model: 'm', messages: Object.freeze([prefix, ...boundary]), sessionId: session.id })) })
|
expect(() => { dispatch(ctx, loopRequest({ model: 'm', messages: Object.freeze([prefix, ...boundary]), sessionId: session.id })) })
|
||||||
.not.toThrow()
|
.not.toThrow()
|
||||||
expect(() => { dispatch(ctx, Object.freeze({ model: 'm', messages: Object.freeze([...boundary]), sessionId: session.id })) })
|
expect(() => { dispatch(ctx, loopRequest({ model: 'm', messages: Object.freeze([...boundary]), sessionId: session.id })) })
|
||||||
.toThrow(/diverges from the boundary derivation/)
|
.toThrow(/diverges from the boundary derivation/)
|
||||||
expect(() => { dispatch(ctx, Object.freeze({ model: 'm', messages: Object.freeze([...boundary, prefix]), sessionId: session.id })) })
|
expect(() => { dispatch(ctx, loopRequest({ model: 'm', messages: Object.freeze([...boundary, prefix]), sessionId: session.id })) })
|
||||||
.toThrow(/diverges from the boundary derivation/)
|
.toThrow(/diverges from the boundary derivation/)
|
||||||
})
|
})
|
||||||
|
|
||||||
it('rejects message and header divergence', async () => {
|
it('rejects message and header divergence', async () => {
|
||||||
const { ctx, session, boundary } = await requestSetup()
|
const { ctx, session, boundary } = await requestSetup()
|
||||||
const divergent = [...boundary, { role: 'user', content: [{ type: 'text', text: 'phantom' }] }]
|
const divergent = [...boundary, { role: 'user', content: [{ type: 'text', text: 'phantom' }] }]
|
||||||
expect(() => { dispatch(ctx, Object.freeze({ model: 'm', messages: Object.freeze(divergent), sessionId: session.id })) })
|
expect(() => { dispatch(ctx, loopRequest({ model: 'm', messages: Object.freeze(divergent), sessionId: session.id })) })
|
||||||
.toThrow(/diverges from the boundary derivation/)
|
.toThrow(/diverges from the boundary derivation/)
|
||||||
expect(() => { dispatch(ctx, Object.freeze({ model: 'other', messages: Object.freeze(boundary), sessionId: session.id })) })
|
expect(() => { dispatch(ctx, loopRequest({ model: 'other', messages: Object.freeze(boundary), sessionId: session.id })) })
|
||||||
.toThrow(/diverges from the folded request header/)
|
.toThrow(/diverges from the folded request header/)
|
||||||
})
|
})
|
||||||
|
|
||||||
@@ -66,7 +71,7 @@ describe('request-reconstruction invariant', () => {
|
|||||||
const ctx = await setup()
|
const ctx = await setup()
|
||||||
const session = ctx.sessions.create(SessionId('req-bare'))
|
const session = ctx.sessions.create(SessionId('req-bare'))
|
||||||
session.append('turn/start', { turn: 1, trigger: { kind: 'message', source: { kind: 'user' } } })
|
session.append('turn/start', { turn: 1, trigger: { kind: 'message', source: { kind: 'user' } } })
|
||||||
const bare = Object.freeze({ model: 'm', messages: Object.freeze([]), sessionId: session.id })
|
const bare = loopRequest({ model: 'm', messages: Object.freeze([]), sessionId: session.id })
|
||||||
expect(() => { dispatch(ctx, bare) }).toThrow(/no step\/start/)
|
expect(() => { dispatch(ctx, bare) }).toThrow(/no step\/start/)
|
||||||
session.append('step/start', { turn: 1, step: 1 })
|
session.append('step/start', { turn: 1, step: 1 })
|
||||||
expect(() => { dispatch(ctx, bare) }).toThrow(/no request\/header event/)
|
expect(() => { dispatch(ctx, bare) }).toThrow(/no request\/header event/)
|
||||||
@@ -74,12 +79,34 @@ describe('request-reconstruction invariant', () => {
|
|||||||
|
|
||||||
it('rejects an unfrozen messages array but skips requests outside the loop contract', async () => {
|
it('rejects an unfrozen messages array but skips requests outside the loop contract', async () => {
|
||||||
const { ctx, session, boundary } = await requestSetup()
|
const { ctx, session, boundary } = await requestSetup()
|
||||||
expect(() => { dispatch(ctx, Object.freeze({ model: 'm', messages: [...boundary], sessionId: session.id })) })
|
expect(() => { dispatch(ctx, loopRequest({ model: 'm', messages: [...boundary], sessionId: session.id })) })
|
||||||
.toThrow(/frozen messages array/)
|
.toThrow(/frozen messages array/)
|
||||||
expect(() => { dispatch(ctx, { model: 'summarizer', messages: [], sessionId: session.id }) }).not.toThrow()
|
expect(() => { dispatch(ctx, { model: 'summarizer', messages: [], sessionId: session.id }) }).not.toThrow()
|
||||||
expect(() => { dispatch(ctx, Object.freeze({ model: 'm', messages: Object.freeze([]) })) }).not.toThrow()
|
expect(() => { dispatch(ctx, Object.freeze({ model: 'm', messages: Object.freeze([]) })) }).not.toThrow()
|
||||||
expect(() => { dispatch(ctx, Object.freeze({ model: 'm', messages: Object.freeze([]), sessionId: SessionId('ghost') })) })
|
expect(() => { dispatch(ctx, Object.freeze({ model: 'm', messages: Object.freeze([]), sessionId: SessionId('ghost') })) })
|
||||||
.not.toThrow()
|
.not.toThrow()
|
||||||
|
|
||||||
|
const directSession = ctx.sessions.create(SessionId('direct-one-shot'))
|
||||||
|
expect(() => {
|
||||||
|
dispatch(ctx, Object.freeze({ model: 'one-shot', messages: Object.freeze([]), sessionId: directSession.id }))
|
||||||
|
}).not.toThrow()
|
||||||
|
})
|
||||||
|
|
||||||
|
it('rejects malformed requests carrying the loop marker', async () => {
|
||||||
|
const { ctx, session } = await requestSetup()
|
||||||
|
expect(() => {
|
||||||
|
dispatch(ctx, markLoopRequest({ model: 'm', messages: Object.freeze([]), sessionId: session.id }))
|
||||||
|
}).toThrow(/request must be frozen/)
|
||||||
|
expect(() => {
|
||||||
|
dispatch(ctx, loopRequest({ model: 'm', messages: Object.freeze([]) }))
|
||||||
|
}).toThrow(/carry a session id/)
|
||||||
|
expect(() => {
|
||||||
|
dispatch(ctx, loopRequest({
|
||||||
|
model: 'm',
|
||||||
|
messages: Object.freeze([]),
|
||||||
|
sessionId: SessionId('missing-loop-session'),
|
||||||
|
}))
|
||||||
|
}).toThrow(/live session id/)
|
||||||
})
|
})
|
||||||
|
|
||||||
it('prepends ahead of a short-circuiting stream listener', async () => {
|
it('prepends ahead of a short-circuiting stream listener', async () => {
|
||||||
@@ -93,7 +120,7 @@ describe('request-reconstruction invariant', () => {
|
|||||||
session.append('user/message', { content: [{ type: 'text', text: 'hi' }], source: { kind: 'user' } }, { surfaceOp: 'append' })
|
session.append('user/message', { content: [{ type: 'text', text: 'hi' }], source: { kind: 'user' } }, { surfaceOp: 'append' })
|
||||||
session.append('step/start', { turn: 1, step: 1 })
|
session.append('step/start', { turn: 1, step: 1 })
|
||||||
session.append('request/header', { header: { config: { provider: 'mock', model: 'm' } }, reason: 'initial' })
|
session.append('request/header', { header: { config: { provider: 'mock', model: 'm' } }, reason: 'initial' })
|
||||||
const divergent = Object.freeze({
|
const divergent = loopRequest({
|
||||||
model: 'm',
|
model: 'm',
|
||||||
messages: Object.freeze([{ role: 'user', content: [{ type: 'text', text: 'phantom' }] }]),
|
messages: Object.freeze([{ role: 'user', content: [{ type: 'text', text: 'phantom' }] }]),
|
||||||
sessionId: session.id,
|
sessionId: session.id,
|
||||||
|
|||||||
@@ -521,7 +521,7 @@ describe('dsh-agent-spine-demo bundle', () => {
|
|||||||
expect(typeof unwrapped.apply).toBe('function')
|
expect(typeof unwrapped.apply).toBe('function')
|
||||||
})
|
})
|
||||||
|
|
||||||
it('keeps every invariant companion loadable through the real Loader unwrap path', () => {
|
it('keeps each standard-spine invariant companion loadable through the real Loader unwrap path', () => {
|
||||||
const loader = Object.create(Loader.prototype) as Loader
|
const loader = Object.create(Loader.prototype) as Loader
|
||||||
for (const companion of [sessionInvariant, agentInvariant, scopeInvariant, agentLoopInvariant]) {
|
for (const companion of [sessionInvariant, agentInvariant, scopeInvariant, agentLoopInvariant]) {
|
||||||
expect('default' in companion).toBe(false)
|
expect('default' in companion).toBe(false)
|
||||||
|
|||||||
@@ -6,7 +6,7 @@ The default policy permits two retries for `RATE_LIMIT`, `SERVER`, `TIMEOUT`, an
|
|||||||
|
|
||||||
Before waiting, the plugin appends a non-surface `llm/retry` event with the failure and scheduled delay. Cancellation and plugin disposal abort the wait; disposal drains the plugin's active backoffs, and a callback captured before disposal fails closed if invoked afterward.
|
Before waiting, the plugin appends a non-surface `llm/retry` event with the failure and scheduled delay. Cancellation and plugin disposal abort the wait; disposal drains the plugin's active backoffs, and a callback captured before disposal fails closed if invoked afterward.
|
||||||
|
|
||||||
The separately published `./invariant` companion checks that every retry record names the current open turn and its latest closed step, has a unique step record and increasing retry number, and carries a positive bounded retry budget and timer delay.
|
The separately published `./invariant` companion checks that every retry record names the current open turn and its latest closed step, has a unique step record and increasing retry number, and carries a positive bounded retry budget and non-negative bounded timer delay. Full jitter may schedule zero milliseconds at its lower boundary.
|
||||||
|
|
||||||
```yaml
|
```yaml
|
||||||
- name: '@deepseek-ai/dsh-llm-retry'
|
- name: '@deepseek-ai/dsh-llm-retry'
|
||||||
|
|||||||
@@ -26,8 +26,8 @@ function validateRetry(
|
|||||||
if (!Number.isSafeInteger(maxRetries) || maxRetries < 1 || retry > maxRetries) {
|
if (!Number.isSafeInteger(maxRetries) || maxRetries < 1 || retry > maxRetries) {
|
||||||
fail(`llm/retry retry ${retry} must not exceed a positive safe maxRetries ${maxRetries}`)
|
fail(`llm/retry retry ${retry} must not exceed a positive safe maxRetries ${maxRetries}`)
|
||||||
}
|
}
|
||||||
if (!(delayMs > 0 && delayMs <= MAX_TIMER_DELAY_MS)) {
|
if (!(delayMs >= 0 && delayMs <= MAX_TIMER_DELAY_MS)) {
|
||||||
fail(`llm/retry delayMs must be within 1..${MAX_TIMER_DELAY_MS}`)
|
fail(`llm/retry delayMs must be within 0..${MAX_TIMER_DELAY_MS}`)
|
||||||
}
|
}
|
||||||
|
|
||||||
const currentTurnEvents: SessionEvent[] = []
|
const currentTurnEvents: SessionEvent[] = []
|
||||||
|
|||||||
@@ -36,6 +36,10 @@ describe('llm-retry invariants', () => {
|
|||||||
session.append('llm/retry', {
|
session.append('llm/retry', {
|
||||||
turn: 1, step: 2, retry: 2, maxRetries: 2, delayMs: 1_000, failure,
|
turn: 1, step: 2, retry: 2, maxRetries: 2, delayMs: 1_000, failure,
|
||||||
})
|
})
|
||||||
|
const zeroDelay = closeStep(ctx, 'retry-invariant-zero-delay')
|
||||||
|
zeroDelay.append('llm/retry', {
|
||||||
|
turn: 1, step: 1, retry: 1, maxRetries: 1, delayMs: 0, failure,
|
||||||
|
})
|
||||||
}).not.toThrow()
|
}).not.toThrow()
|
||||||
expect(() => { ctx.emit('tools/change') }).not.toThrow()
|
expect(() => { ctx.emit('tools/change') }).not.toThrow()
|
||||||
})
|
})
|
||||||
@@ -46,7 +50,7 @@ describe('llm-retry invariants', () => {
|
|||||||
[{ retry: 1, maxRetries: 0, delayMs: 1 }, /positive safe maxRetries/],
|
[{ retry: 1, maxRetries: 0, delayMs: 1 }, /positive safe maxRetries/],
|
||||||
[{ retry: 1, maxRetries: 1.5, delayMs: 1 }, /positive safe maxRetries/],
|
[{ retry: 1, maxRetries: 1.5, delayMs: 1 }, /positive safe maxRetries/],
|
||||||
[{ retry: 3, maxRetries: 2, delayMs: 1 }, /must not exceed/],
|
[{ retry: 3, maxRetries: 2, delayMs: 1 }, /must not exceed/],
|
||||||
[{ retry: 1, maxRetries: 2, delayMs: 0 }, /delayMs/],
|
[{ retry: 1, maxRetries: 2, delayMs: -1 }, /delayMs/],
|
||||||
[{ retry: 1, maxRetries: 2, delayMs: MAX_TIMER_DELAY_MS + 1 }, /delayMs/],
|
[{ retry: 1, maxRetries: 2, delayMs: MAX_TIMER_DELAY_MS + 1 }, /delayMs/],
|
||||||
])('rejects invalid retry bounds %#', async (data, message) => {
|
])('rejects invalid retry bounds %#', async (data, message) => {
|
||||||
const ctx = await setup()
|
const ctx = await setup()
|
||||||
|
|||||||
@@ -232,6 +232,29 @@ describe('bounded transient retry policy', () => {
|
|||||||
})
|
})
|
||||||
})
|
})
|
||||||
|
|
||||||
|
it('accepts the zero-delay lower jitter bound', async () => {
|
||||||
|
vi.useFakeTimers()
|
||||||
|
const adapter = new ScriptedAdapter([
|
||||||
|
new LlmError('busy', 'SERVER'),
|
||||||
|
textResponse('done'),
|
||||||
|
])
|
||||||
|
;({ ctx: context } = await harness(adapter, {
|
||||||
|
initialDelayMs: 1,
|
||||||
|
maxDelayMs: 1,
|
||||||
|
jitterRatio: 1,
|
||||||
|
}, undefined, { random: () => 0 }))
|
||||||
|
const agent = context.agentLoop.create(SessionId('retry-zero-delay'), { provider: 'mock', model: 'mock' })
|
||||||
|
const scheduled = waitForRetry(context, agent, 1)
|
||||||
|
|
||||||
|
agent.send([{ type: 'text', text: 'go' }])
|
||||||
|
expect((await scheduled).data.delayMs).toBe(0)
|
||||||
|
|
||||||
|
const idle = waitForIdle(context, agent)
|
||||||
|
await vi.runAllTimersAsync()
|
||||||
|
await idle
|
||||||
|
expect(adapter.requests).toHaveLength(2)
|
||||||
|
})
|
||||||
|
|
||||||
it('uses a bounded provider Retry-After verbatim and delegates an over-cap instruction', async () => {
|
it('uses a bounded provider Retry-After verbatim and delegates an over-cap instruction', async () => {
|
||||||
vi.useFakeTimers()
|
vi.useFakeTimers()
|
||||||
const accepted = new ScriptedAdapter([
|
const accepted = new ScriptedAdapter([
|
||||||
|
|||||||
@@ -40,7 +40,7 @@ The current executable companions protect these relationships:
|
|||||||
| `dsh-fs`, `dsh-subagent`, `dsh-workflow` | Filesystem event identity, provider/child pairing, and workflow/agent lifecycle identity. |
|
| `dsh-fs`, `dsh-subagent`, `dsh-workflow` | Filesystem event identity, provider/child pairing, and workflow/agent lifecycle identity. |
|
||||||
| `dsh-permission`, `dsh-user-approval` | Active-preset references and approval asked/decided audit pairing. |
|
| `dsh-permission`, `dsh-user-approval` | Active-preset references and approval asked/decided audit pairing. |
|
||||||
| `dsh-tasks`, `dsh-tool-todo` | Task snapshot lifecycle/ownership fields and durable whole-list todo structure. |
|
| `dsh-tasks`, `dsh-tool-todo` | Task snapshot lifecycle/ownership fields and durable whole-list todo structure. |
|
||||||
| `dsh-time-context` | Durable clock readings agree with the session's open turn and next pre-step position, elapsed baseline, and event timestamp. |
|
| `dsh-time-context` | Durable clock readings agree with the session's open turn and next pre-step position and elapsed baseline; rendered time parses and does not postdate its event. |
|
||||||
|
|
||||||
The root entrypoint of each owner remains independent of diagnostics. Loading the service alone installs no product checks, and loading a companion without the service waits on its declared `invariants` injection.
|
The root entrypoint of each owner remains independent of diagnostics. Loading the service alone installs no product checks, and loading a companion without the service waits on its declared `invariants` injection.
|
||||||
|
|
||||||
@@ -77,6 +77,6 @@ None; invariant checks do not assemble or send provider requests.
|
|||||||
|
|
||||||
## Known Limitations and Deferred Work
|
## Known Limitations and Deferred Work
|
||||||
|
|
||||||
- Request reconstruction covers frozen loop-built requests with a live session id; direct one-shot LLM calls remain outside that marker contract.
|
- Request reconstruction covers requests explicitly marked by the loop before freezing; direct one-shot LLM calls remain outside that marker contract even when callers freeze them or attach a session id.
|
||||||
- Live-only lifecycle companions cannot reconstruct operations that began before their own reload. Standard and test compositions mount them before the corresponding operations begin.
|
- Live-only lifecycle companions cannot reconstruct operations that began before their own reload. Standard and test compositions mount them before the corresponding operations begin.
|
||||||
- Regular-expression filters are fixed for the service lifetime; changing them requires ordinary Cordis plugin reload.
|
- Regular-expression filters are fixed for the service lifetime; changing them requires ordinary Cordis plugin reload.
|
||||||
|
|||||||
@@ -162,27 +162,28 @@ export class InvariantService extends Service {
|
|||||||
throw new InvariantError(packageName, message)
|
throw new InvariantError(packageName, message)
|
||||||
})
|
})
|
||||||
)
|
)
|
||||||
const child = ctx.plugin(installer.inject === undefined
|
|
||||||
? installInvariant
|
|
||||||
: Object.assign(installInvariant, { inject: installer.inject }))
|
|
||||||
|
|
||||||
try {
|
try {
|
||||||
await child
|
const child = ctx.plugin(installer.inject === undefined
|
||||||
} catch (error) {
|
? installInvariant
|
||||||
try {
|
: Object.assign(installInvariant, { inject: installer.inject }))
|
||||||
await child.dispose()
|
|
||||||
} finally {
|
|
||||||
registrations.delete(packageName)
|
|
||||||
}
|
|
||||||
throw error
|
|
||||||
}
|
|
||||||
|
|
||||||
return async () => {
|
|
||||||
try {
|
try {
|
||||||
|
await child
|
||||||
|
} catch (error) {
|
||||||
await child.dispose()
|
await child.dispose()
|
||||||
} finally {
|
throw error
|
||||||
registrations.delete(packageName)
|
|
||||||
}
|
}
|
||||||
|
|
||||||
|
return async () => {
|
||||||
|
try {
|
||||||
|
await child.dispose()
|
||||||
|
} finally {
|
||||||
|
registrations.delete(packageName)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
} catch (error) {
|
||||||
|
registrations.delete(packageName)
|
||||||
|
throw error
|
||||||
}
|
}
|
||||||
}, `invariants.register(${JSON.stringify(packageName)})`)
|
}, `invariants.register(${JSON.stringify(packageName)})`)
|
||||||
} catch (error) {
|
} catch (error) {
|
||||||
|
|||||||
@@ -260,6 +260,28 @@ describe('InvariantService lifecycle', () => {
|
|||||||
expect(retry).toHaveBeenCalledOnce()
|
expect(retry).toHaveBeenCalledOnce()
|
||||||
})
|
})
|
||||||
|
|
||||||
|
it('rolls back publication effects and ownership when child-fiber publication fails', async () => {
|
||||||
|
const { ctx } = await setup()
|
||||||
|
const leaked = vi.fn()
|
||||||
|
let rejectPublication = true
|
||||||
|
const stopRejecting = ctx.on('internal/plugin', (fiber) => {
|
||||||
|
if (!rejectPublication || fiber.uid === null) return
|
||||||
|
rejectPublication = false
|
||||||
|
fiber.ctx.on('invariants-test/ping', leaked, { global: true })
|
||||||
|
throw new Error('publication failed')
|
||||||
|
})
|
||||||
|
|
||||||
|
const failed = runtimeRegistration(ctx.invariants.register('@deepseek-ai/dsh-publication-probe', () => {}))
|
||||||
|
await expect(Promise.resolve(failed)).rejects.toThrow('publication failed')
|
||||||
|
ctx.emit('invariants-test/ping')
|
||||||
|
expect(leaked).not.toHaveBeenCalled()
|
||||||
|
stopRejecting()
|
||||||
|
|
||||||
|
const retry = runtimeRegistration(ctx.invariants.register('@deepseek-ai/dsh-publication-probe', () => {}))
|
||||||
|
await retry
|
||||||
|
await retry()
|
||||||
|
})
|
||||||
|
|
||||||
it('joins asynchronous checks and rolls back their effects on failure', async () => {
|
it('joins asynchronous checks and rolls back their effects on failure', async () => {
|
||||||
const { ctx } = await setup()
|
const { ctx } = await setup()
|
||||||
const leaked = vi.fn()
|
const leaked = vi.fn()
|
||||||
|
|||||||
@@ -152,6 +152,15 @@ export const apply = (ctx: { invariants: { register(name: string, install: () =>
|
|||||||
.toContain('line 6: ctx.invariants.register must use the checked local install function')
|
.toContain('line 6: ctx.invariants.register must use the checked local install function')
|
||||||
})
|
})
|
||||||
|
|
||||||
|
it.each([
|
||||||
|
'export default { name, inject, apply }',
|
||||||
|
"export * as default from './probe.ts'",
|
||||||
|
])('rejects a default export that would collapse the Loader namespace', (defaultExport) => {
|
||||||
|
const source = `${handwrittenInvariant('@deepseek-ai/dsh-probe')}\n${defaultExport}\n`
|
||||||
|
expect(collectPackageInvariantViolations(fixture({ source })).map(violation => violation.message))
|
||||||
|
.toContain('must not default-export; Loader must retain the companion namespace')
|
||||||
|
})
|
||||||
|
|
||||||
it('accepts explained empty installers and rejects unexplained ones', () => {
|
it('accepts explained empty installers and rejects unexplained ones', () => {
|
||||||
const explained = `
|
const explained = `
|
||||||
export const name = 'probe-invariant'
|
export const name = 'probe-invariant'
|
||||||
|
|||||||
@@ -210,6 +210,9 @@ function checkSource(
|
|||||||
addViolation(violations, owner.sourcePath, `must named-export ${exportedName}`)
|
addViolation(violations, owner.sourcePath, `must named-export ${exportedName}`)
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
if (hasDefaultExport(sourceFile)) {
|
||||||
|
addViolation(violations, owner.sourcePath, 'must not default-export; Loader must retain the companion namespace')
|
||||||
|
}
|
||||||
checkInstaller(owner, sourceFile, sourceText, violations)
|
checkInstaller(owner, sourceFile, sourceText, violations)
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -314,6 +317,19 @@ function hasNamedExport(sourceFile: ts.SourceFile, name: string): boolean {
|
|||||||
})
|
})
|
||||||
}
|
}
|
||||||
|
|
||||||
|
function hasDefaultExport(sourceFile: ts.SourceFile): boolean {
|
||||||
|
return sourceFile.statements.some((statement) => {
|
||||||
|
if (ts.isExportAssignment(statement)) return true
|
||||||
|
const modifiers = ts.canHaveModifiers(statement) ? ts.getModifiers(statement) : undefined
|
||||||
|
if (modifiers?.some(modifier => modifier.kind === ts.SyntaxKind.DefaultKeyword)) return true
|
||||||
|
if (!ts.isExportDeclaration(statement) || statement.exportClause === undefined) return false
|
||||||
|
if (ts.isNamespaceExport(statement.exportClause)) {
|
||||||
|
return statement.exportClause.name.text === 'default'
|
||||||
|
}
|
||||||
|
return statement.exportClause.elements.some(element => element.name.text === 'default')
|
||||||
|
})
|
||||||
|
}
|
||||||
|
|
||||||
/** Format violations for the command-line gate. */
|
/** Format violations for the command-line gate. */
|
||||||
export function formatPackageInvariantViolation(
|
export function formatPackageInvariantViolation(
|
||||||
root: string,
|
root: string,
|
||||||
|
|||||||
@@ -224,6 +224,7 @@ function ciPrimaryGates(): Gate[] {
|
|||||||
label: 'node-next types',
|
label: 'node-next types',
|
||||||
needs: ['build'],
|
needs: ['build'],
|
||||||
}),
|
}),
|
||||||
|
builtPackageInvariantsGate(['build']),
|
||||||
builtBinSmokeGate(),
|
builtBinSmokeGate(),
|
||||||
]
|
]
|
||||||
}
|
}
|
||||||
@@ -248,6 +249,7 @@ function ciArtifactGates(): Gate[] {
|
|||||||
label: 'node-next types',
|
label: 'node-next types',
|
||||||
needs: ['build'],
|
needs: ['build'],
|
||||||
}),
|
}),
|
||||||
|
builtPackageInvariantsGate(['build']),
|
||||||
builtBinSmokeGate(),
|
builtBinSmokeGate(),
|
||||||
]
|
]
|
||||||
}
|
}
|
||||||
@@ -295,6 +297,13 @@ function snapshotGate(): Gate {
|
|||||||
})
|
})
|
||||||
}
|
}
|
||||||
|
|
||||||
|
function builtPackageInvariantsGate(needs?: string[]): Gate {
|
||||||
|
return pnpmScript('built-package-invariants', 'verify-built-package-invariants', {
|
||||||
|
label: 'built package invariants',
|
||||||
|
...needs === undefined ? {} : { needs },
|
||||||
|
})
|
||||||
|
}
|
||||||
|
|
||||||
function positiveIntArg(envName: string, flag: string): string[] {
|
function positiveIntArg(envName: string, flag: string): string[] {
|
||||||
const raw = process.env[envName]
|
const raw = process.env[envName]
|
||||||
if (raw === undefined || raw === '') return []
|
if (raw === undefined || raw === '') return []
|
||||||
@@ -312,6 +321,7 @@ function hygieneLeafGates(options: { artifactNeeds?: string[] } = {}): Gate[] {
|
|||||||
pnpmScript('publint', 'publint', artifactOptions),
|
pnpmScript('publint', 'publint', artifactOptions),
|
||||||
pnpmScript('constraints', 'constraints'),
|
pnpmScript('constraints', 'constraints'),
|
||||||
pnpmScript('package-invariants', 'verify-package-invariants', { label: 'package invariants' }),
|
pnpmScript('package-invariants', 'verify-package-invariants', { label: 'package invariants' }),
|
||||||
|
builtPackageInvariantsGate(options.artifactNeeds),
|
||||||
pnpmScript('node-next-types', 'verify-node-next-types', {
|
pnpmScript('node-next-types', 'verify-node-next-types', {
|
||||||
label: 'node-next types',
|
label: 'node-next types',
|
||||||
...artifactOptions,
|
...artifactOptions,
|
||||||
|
|||||||
@@ -1,11 +1,12 @@
|
|||||||
import { describe, expect, it, vi } from 'vitest'
|
import { describe, expect, it, vi } from 'vitest'
|
||||||
import { Context, Service } from 'cordis'
|
import { Context, Service } from 'cordis'
|
||||||
|
import Loader from '@cordisjs/plugin-loader'
|
||||||
import type { InvariantInstaller } from '@deepseek-ai/dsh-invariants'
|
import type { InvariantInstaller } from '@deepseek-ai/dsh-invariants'
|
||||||
import { packageInvariantOwners } from './package-invariants.ts'
|
import { packageInvariantOwners } from './package-invariants.ts'
|
||||||
import {
|
import {
|
||||||
MANUAL_INVARIANT_TESTS,
|
|
||||||
testInvariantCompanionPaths,
|
testInvariantCompanionPaths,
|
||||||
testInvariantCompanions,
|
testInvariantCompanions,
|
||||||
|
usesManualInvariantTree,
|
||||||
} from './test-invariants.ts'
|
} from './test-invariants.ts'
|
||||||
|
|
||||||
declare module 'cordis' {
|
declare module 'cordis' {
|
||||||
@@ -51,9 +52,10 @@ describe('global test invariant host', () => {
|
|||||||
.toEqual(Object.keys(testInvariantCompanions).sort())
|
.toEqual(Object.keys(testInvariantCompanions).sort())
|
||||||
})
|
})
|
||||||
|
|
||||||
it('executes each companion registration with its owning package name', async () => {
|
it('loads and executes every source companion through the real Loader shape', async () => {
|
||||||
const owners = new Map(packageInvariantOwners(process.cwd()).map(owner => [owner.sourcePath, owner.packageName]))
|
const owners = new Map(packageInvariantOwners(process.cwd()).map(owner => [owner.sourcePath, owner.packageName]))
|
||||||
const registrations = new Map<string, string>()
|
const registrations = new Map<string, string>()
|
||||||
|
const loader = Object.create(Loader.prototype) as Loader
|
||||||
const register = vi.fn((_packageName: string, installer: InvariantInstaller) => {
|
const register = vi.fn((_packageName: string, installer: InvariantInstaller) => {
|
||||||
expect(typeof installer).toBe('function')
|
expect(typeof installer).toBe('function')
|
||||||
return () => {}
|
return () => {}
|
||||||
@@ -61,7 +63,13 @@ describe('global test invariant host', () => {
|
|||||||
const fakeContext = { invariants: { register } } as unknown as Context
|
const fakeContext = { invariants: { register } } as unknown as Context
|
||||||
for (const [rawPath, companion] of Object.entries(testInvariantCompanions)) {
|
for (const [rawPath, companion] of Object.entries(testInvariantCompanions)) {
|
||||||
const path = rawPath.replace(/^\.\.\//, '')
|
const path = rawPath.replace(/^\.\.\//, '')
|
||||||
await companion.apply(fakeContext)
|
expect(companion.default, path).toBeUndefined()
|
||||||
|
const unwrapped = loader.unwrapExports(companion) as typeof companion
|
||||||
|
expect(unwrapped, path).toBe(companion)
|
||||||
|
expect(typeof unwrapped.name, path).toBe('string')
|
||||||
|
expect(unwrapped.inject, path).toContain('invariants')
|
||||||
|
expect(typeof unwrapped.apply, path).toBe('function')
|
||||||
|
await unwrapped.apply(fakeContext)
|
||||||
const call = register.mock.calls.at(-1)
|
const call = register.mock.calls.at(-1)
|
||||||
if (call === undefined) throw new Error(`${path}: companion did not register`)
|
if (call === undefined) throw new Error(`${path}: companion did not register`)
|
||||||
registrations.set(path, call[0])
|
registrations.set(path, call[0])
|
||||||
@@ -69,29 +77,11 @@ describe('global test invariant host', () => {
|
|||||||
expect(registrations).toEqual(owners)
|
expect(registrations).toEqual(owners)
|
||||||
})
|
})
|
||||||
|
|
||||||
it('limits manual composition to focused invariant topology tests', () => {
|
it('recognizes focused invariant suites without a package inventory', () => {
|
||||||
expect(MANUAL_INVARIANT_TESTS).toEqual([
|
expect(usesManualInvariantTree('/repo/packages/core/session/tests/invariant.spec.ts')).toBe(true)
|
||||||
'/packages/support/invariants/tests/service.spec.ts',
|
expect(usesManualInvariantTree('/repo/packages/core/session/tests/request-invariant-hmr.spec.ts')).toBe(true)
|
||||||
'/packages/compact/compact/tests/invariant.spec.ts',
|
expect(usesManualInvariantTree('C:\\repo\\packages\\support\\invariants\\tests\\service.spec.ts')).toBe(true)
|
||||||
'/packages/context/time-context/tests/invariant.spec.ts',
|
expect(usesManualInvariantTree('/repo/packages/examples/agent-spine-demo/tests/agent-core.spec.ts')).toBe(true)
|
||||||
'/packages/core/session/tests/invariant.spec.ts',
|
expect(usesManualInvariantTree('/repo/packages/core/session/tests/session.spec.ts')).toBe(false)
|
||||||
'/packages/core/agent/tests/invariant.spec.ts',
|
|
||||||
'/packages/core/scope/tests/invariant.spec.ts',
|
|
||||||
'/packages/core/agent-loop/tests/invariant.spec.ts',
|
|
||||||
'/packages/core/system-prompt/tests/invariant.spec.ts',
|
|
||||||
'/packages/core/tools/tests/invariant.spec.ts',
|
|
||||||
'/packages/fs/fs/tests/invariant.spec.ts',
|
|
||||||
'/packages/hooks/hook-protocol/tests/invariant.spec.ts',
|
|
||||||
'/packages/llm/llm/tests/invariant.spec.ts',
|
|
||||||
'/packages/llm/llm-retry/tests/invariant.spec.ts',
|
|
||||||
'/packages/sandbox/sandbox-policy/tests/invariant.spec.ts',
|
|
||||||
'/packages/subagent/subagent/tests/invariant.spec.ts',
|
|
||||||
'/packages/tasks/tasks/tests/invariant.spec.ts',
|
|
||||||
'/packages/todo/tool-todo/tests/invariant.spec.ts',
|
|
||||||
'/packages/ui/permission/tests/invariant.spec.ts',
|
|
||||||
'/packages/ui/user-approval/tests/invariant.spec.ts',
|
|
||||||
'/packages/workflow/workflow/tests/invariant.spec.ts',
|
|
||||||
'/packages/examples/agent-spine-demo/tests/agent-core.spec.ts',
|
|
||||||
])
|
|
||||||
})
|
})
|
||||||
})
|
})
|
||||||
|
|||||||
@@ -21,6 +21,7 @@ declare global {
|
|||||||
export interface TestInvariantCompanion {
|
export interface TestInvariantCompanion {
|
||||||
readonly name: string
|
readonly name: string
|
||||||
readonly inject: readonly string[]
|
readonly inject: readonly string[]
|
||||||
|
readonly default?: unknown
|
||||||
apply(ctx: Context): Promise<() => void>
|
apply(ctx: Context): Promise<() => void>
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -28,28 +29,9 @@ export interface TestInvariantCompanion {
|
|||||||
export const testInvariantCompanions: Readonly<Record<string, TestInvariantCompanion>> =
|
export const testInvariantCompanions: Readonly<Record<string, TestInvariantCompanion>> =
|
||||||
import.meta.glob<TestInvariantCompanion>('../packages/*/*/src/invariant.ts', { eager: true })
|
import.meta.glob<TestInvariantCompanion>('../packages/*/*/src/invariant.ts', { eager: true })
|
||||||
|
|
||||||
/** Tests that exercise selection or companion lifecycle with a deliberately hand-built service tree. */
|
/** Manual-topology suites whose names cannot follow the focused invariant convention. */
|
||||||
export const MANUAL_INVARIANT_TESTS = [
|
const MANUAL_INVARIANT_TEST_EXCEPTIONS = [
|
||||||
'/packages/support/invariants/tests/service.spec.ts',
|
'/packages/support/invariants/tests/service.spec.ts',
|
||||||
'/packages/compact/compact/tests/invariant.spec.ts',
|
|
||||||
'/packages/context/time-context/tests/invariant.spec.ts',
|
|
||||||
'/packages/core/session/tests/invariant.spec.ts',
|
|
||||||
'/packages/core/agent/tests/invariant.spec.ts',
|
|
||||||
'/packages/core/scope/tests/invariant.spec.ts',
|
|
||||||
'/packages/core/agent-loop/tests/invariant.spec.ts',
|
|
||||||
'/packages/core/system-prompt/tests/invariant.spec.ts',
|
|
||||||
'/packages/core/tools/tests/invariant.spec.ts',
|
|
||||||
'/packages/fs/fs/tests/invariant.spec.ts',
|
|
||||||
'/packages/hooks/hook-protocol/tests/invariant.spec.ts',
|
|
||||||
'/packages/llm/llm/tests/invariant.spec.ts',
|
|
||||||
'/packages/llm/llm-retry/tests/invariant.spec.ts',
|
|
||||||
'/packages/sandbox/sandbox-policy/tests/invariant.spec.ts',
|
|
||||||
'/packages/subagent/subagent/tests/invariant.spec.ts',
|
|
||||||
'/packages/tasks/tasks/tests/invariant.spec.ts',
|
|
||||||
'/packages/todo/tool-todo/tests/invariant.spec.ts',
|
|
||||||
'/packages/ui/permission/tests/invariant.spec.ts',
|
|
||||||
'/packages/ui/user-approval/tests/invariant.spec.ts',
|
|
||||||
'/packages/workflow/workflow/tests/invariant.spec.ts',
|
|
||||||
'/packages/examples/agent-spine-demo/tests/agent-core.spec.ts',
|
'/packages/examples/agent-spine-demo/tests/agent-core.spec.ts',
|
||||||
] as const
|
] as const
|
||||||
|
|
||||||
@@ -66,7 +48,8 @@ const hosts = new WeakMap<Context, InvariantHost>()
|
|||||||
const originalPlugin = RegistryService.prototype.plugin
|
const originalPlugin = RegistryService.prototype.plugin
|
||||||
|
|
||||||
RegistryService.prototype.plugin = function(plugin: Plugin, config?: unknown, getOuterStack?: () => string[]) {
|
RegistryService.prototype.plugin = function(plugin: Plugin, config?: unknown, getOuterStack?: () => string[]) {
|
||||||
if (usesManualInvariantTree()) return originalPlugin.call(this, plugin, config, getOuterStack)
|
const testPath = expect.getState().testPath ?? ''
|
||||||
|
if (usesManualInvariantTree(testPath)) return originalPlugin.call(this, plugin, config, getOuterStack)
|
||||||
|
|
||||||
const root = this.ctx.root
|
const root = this.ctx.root
|
||||||
const host = hosts.get(root) ?? startInvariantHost(root)
|
const host = hosts.get(root) ?? startInvariantHost(root)
|
||||||
@@ -83,9 +66,15 @@ RegistryService.prototype.plugin = function(plugin: Plugin, config?: unknown, ge
|
|||||||
return joinInvariantStartup(fiber, host.ready)
|
return joinInvariantStartup(fiber, host.ready)
|
||||||
}
|
}
|
||||||
|
|
||||||
function usesManualInvariantTree(): boolean {
|
/**
|
||||||
const testPath = expect.getState().testPath?.replaceAll('\\', '/') ?? ''
|
* Detect focused suites that construct service selection or companion lifecycle explicitly.
|
||||||
return MANUAL_INVARIANT_TESTS.some(path => testPath.endsWith(path))
|
* @param testPath - absolute or repo-relative Vitest file path.
|
||||||
|
* @returns whether the global invariant host must leave the root untouched.
|
||||||
|
*/
|
||||||
|
export function usesManualInvariantTree(testPath: string): boolean {
|
||||||
|
const normalized = testPath.replaceAll('\\', '/')
|
||||||
|
if (/\/packages\/[^/]+\/[^/]+\/tests\/[^/]*invariant[^/]*\.spec\.ts$/.test(normalized)) return true
|
||||||
|
return MANUAL_INVARIANT_TEST_EXCEPTIONS.some(path => normalized.endsWith(path))
|
||||||
}
|
}
|
||||||
|
|
||||||
const ALL_COMPANION_TESTS = ['/scripts/test-invariants.spec.ts'] as const
|
const ALL_COMPANION_TESTS = ['/scripts/test-invariants.spec.ts'] as const
|
||||||
|
|||||||
51
scripts/verify-built-package-invariants.mjs
Normal file
51
scripts/verify-built-package-invariants.mjs
Normal file
@@ -0,0 +1,51 @@
|
|||||||
|
/** Verify every compiled companion through its package self-reference under plain Node. */
|
||||||
|
|
||||||
|
import { spawnSync } from 'node:child_process'
|
||||||
|
import { globSync, readFileSync } from 'node:fs'
|
||||||
|
import { dirname, resolve } from 'node:path'
|
||||||
|
import { pathToFileURL } from 'node:url'
|
||||||
|
|
||||||
|
const root = resolve(import.meta.dirname, '..')
|
||||||
|
const loaderUrl = pathToFileURL(resolve(root, 'vendor/loader/lib/index.js')).href
|
||||||
|
const failures = []
|
||||||
|
const manifests = globSync('packages/*/*/package.json', { cwd: root }).sort()
|
||||||
|
|
||||||
|
for (const manifestPath of manifests) {
|
||||||
|
const packageDir = dirname(resolve(root, manifestPath))
|
||||||
|
const manifest = JSON.parse(readFileSync(resolve(root, manifestPath), 'utf8'))
|
||||||
|
const packageName = manifest.name
|
||||||
|
if (typeof packageName !== 'string' || packageName.length === 0) {
|
||||||
|
failures.push(`${manifestPath}: missing package name`)
|
||||||
|
continue
|
||||||
|
}
|
||||||
|
|
||||||
|
const probe = `
|
||||||
|
const companion = await import(${JSON.stringify(`${packageName}/invariant`)});
|
||||||
|
const { default: Loader } = await import(${JSON.stringify(loaderUrl)});
|
||||||
|
if ('default' in companion) throw new Error('companion has a default export');
|
||||||
|
const loader = Object.create(Loader.prototype);
|
||||||
|
const unwrapped = loader.unwrapExports(companion);
|
||||||
|
if (unwrapped !== companion) throw new Error('Loader collapsed the companion namespace');
|
||||||
|
if (typeof unwrapped.name !== 'string') throw new Error('companion name is missing');
|
||||||
|
if (!Array.isArray(unwrapped.inject) || !unwrapped.inject.includes('invariants')) {
|
||||||
|
throw new Error('companion does not inject invariants');
|
||||||
|
}
|
||||||
|
if (typeof unwrapped.apply !== 'function') throw new Error('companion apply is missing');
|
||||||
|
`
|
||||||
|
const result = spawnSync(process.execPath, ['--input-type=module', '--eval', probe], {
|
||||||
|
cwd: packageDir,
|
||||||
|
encoding: 'utf8',
|
||||||
|
})
|
||||||
|
if (result.status === 0) continue
|
||||||
|
const detail = result.error?.message
|
||||||
|
?? (result.stderr.trim() || result.stdout.trim() || `node exited ${result.status}`)
|
||||||
|
failures.push(`${packageName}: ${detail}`)
|
||||||
|
}
|
||||||
|
|
||||||
|
if (failures.length > 0) {
|
||||||
|
console.error('verify-built-package-invariants: compiled companion failures:')
|
||||||
|
for (const failure of failures) console.error(` ${failure}`)
|
||||||
|
process.exit(1)
|
||||||
|
}
|
||||||
|
|
||||||
|
console.log(`verify-built-package-invariants: ${manifests.length} compiled companion(s) passed plain-Node Loader checks.`)
|
||||||
Reference in New Issue
Block a user