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
|
||||
|
||||
**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.
|
||||
|
||||
@@ -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.
|
||||
|
||||
**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
|
||||
|
||||
|
||||
+2
-2
@@ -2,5 +2,5 @@
|
||||
# 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:
|
||||
# pnpm run verify-translation-pairing --write
|
||||
2026-07-19-package-invariant-runtime-contracts.md: 84c632e57b3360756660415d7bdd62397c52e4a3
|
||||
2026-07-19-package-invariant-runtime-contracts.zh.md: 51734f6922f393ae37b0c3999723b7aade7651eb
|
||||
2026-07-19-package-invariant-runtime-contracts.md: ab4301a80b01ac2b4b2870f02c232ab321a6fab1
|
||||
2026-07-19-package-invariant-runtime-contracts.zh.md: 2e0816b2f381fd8fa51df51d8c482d6d4adf48b4
|
||||
+5
-5
@@ -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-agent` | Non-repeating agent status and terminal disposal transitions. |
|
||||
| `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-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-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. |
|
||||
@@ -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-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-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.
|
||||
|
||||
### 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
|
||||
|
||||
|
||||
+5
-5
@@ -34,9 +34,9 @@ Status: implemented
|
||||
| `dsh-session` | 序号严格递增、turn/step 包围关系,以及同一 step 内的工具调用/结果配对。 |
|
||||
| `dsh-agent` | agent 状态不得重复,并且不能离开终态 disposed。 |
|
||||
| `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-retry` | 持久化重试记录指向当前打开 turn 中最近关闭的 step;每个 step 的记录保持唯一,重试次数单调递增,并且重试次数和定时器延迟均保持在边界内。 |
|
||||
| `dsh-llm-retry` | 持久化重试记录指向当前打开 turn 中最近关闭的 step;每个 step 的记录保持唯一,重试次数单调递增,并且重试次数和非负的定时器延迟均保持在边界内。 |
|
||||
| `dsh-tools` | pre/execute/post 阶段单调推进,以及最终 execution/result 快照不可变。 |
|
||||
| `dsh-system-prompt` | 权威 assembly 中 section、tool 和 variable 的数据约束。 |
|
||||
| `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-tasks` | 当前与终态 task snapshot 保持 id/kind、owner、status 和 timestamp 关系。 |
|
||||
| `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 边界或可变服务结果。如果接受无效事件会提交错误状态,验证就在发布前执行。
|
||||
|
||||
### 仓库门禁与测试
|
||||
|
||||
`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 形状检查。合成事件流的测试必须构造有效的外围生命周期,除非测试本身就是在断言违规。
|
||||
|
||||
## 考虑过的替代方案
|
||||
|
||||
|
||||
+2
-1
@@ -41,6 +41,7 @@
|
||||
"verify-doc-refs": "tsx scripts/verify-doc-refs.ts",
|
||||
"verify-package-paths": "tsx scripts/verify-package-paths.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-mermaid": "tsx scripts/verify-mermaid.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",
|
||||
"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",
|
||||
"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: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",
|
||||
|
||||
@@ -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.
|
||||
|
||||
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.
|
||||
|
||||
## Model Experience
|
||||
|
||||
@@ -75,8 +75,8 @@ function validateReading(
|
||||
if (rendered === undefined) fail('time-context reading omitted its rendered timestamp')
|
||||
const renderedTime = Date.parse(rendered.replace(/\[[^\]]+\]$/, ''))
|
||||
if (!Number.isFinite(renderedTime) || !Number.isSafeInteger(event.time)
|
||||
|| event.time < renderedTime || event.time - renderedTime >= 1_000) {
|
||||
fail('time-context rendered timestamp must identify the durable event second')
|
||||
|| event.time < renderedTime) {
|
||||
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()
|
||||
})
|
||||
|
||||
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([
|
||||
[reading('1', '3', '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', '1', 'step context'), 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(), Number.NaN, undefined, /durable event second/],
|
||||
[reading(), SECOND - 1, undefined, /durable event second/],
|
||||
[reading(), SECOND + 1_000, 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, /must parse and not postdate/],
|
||||
[reading(), SECOND - 1, undefined, /must parse and not postdate/],
|
||||
['ignored', SECOND, [], /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/],
|
||||
|
||||
@@ -29,7 +29,7 @@ The config-driven `ctx.agentLoop.create()` path keeps its agent owned by the loo
|
||||
|
||||
### 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)
|
||||
|
||||
|
||||
@@ -7,6 +7,7 @@ import type { Context } from 'cordis'
|
||||
import type { GenerateOptions } from '@deepseek-ai/dsh-llm'
|
||||
import type { InvariantFailure, InvariantInstaller } from '@deepseek-ai/dsh-invariants'
|
||||
import { Session, SessionId, foldRequestHeader } from '@deepseek-ai/dsh-session'
|
||||
import { isLoopRequest } from './request-marker.ts'
|
||||
|
||||
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
|
||||
// check; correctness itself comes from the sequence-bounded reconstruction.
|
||||
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)
|
||||
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)) {
|
||||
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 { createTransmissionLog, recordRequestHeader } 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 type { PromptAssembly } from '@deepseek-ai/dsh-system-prompt'
|
||||
import type {} from '@deepseek-ai/dsh-tools'
|
||||
@@ -619,7 +620,7 @@ async function runStep(
|
||||
recordRequestHeader(session, transmission, header)
|
||||
|
||||
// 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,
|
||||
model: header.config.model,
|
||||
messages: [...header.messagePrefix ?? [], ...boundaryMessages],
|
||||
@@ -630,7 +631,7 @@ async function runStep(
|
||||
...header.config.stop !== undefined ? { stop: header.config.stop } : {},
|
||||
sessionId: session.id,
|
||||
signal,
|
||||
})
|
||||
}))
|
||||
|
||||
// --- Model call (streaming-first; raw chunks are the replay record) ---
|
||||
const assembler = new BlockAssembler()
|
||||
|
||||
@@ -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 InvariantService from '@deepseek-ai/dsh-invariants'
|
||||
import * as AgentLoopInvariant from '@deepseek-ai/dsh-agent-loop/invariant'
|
||||
import { markLoopRequest } from '../src/request-marker.ts'
|
||||
|
||||
async function setup(): Promise<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)
|
||||
}
|
||||
|
||||
function loopRequest<T extends object>(options: T): Readonly<T> {
|
||||
return Object.freeze(markLoopRequest(options))
|
||||
}
|
||||
|
||||
async function requestSetup() {
|
||||
const ctx = await setup()
|
||||
const session = ctx.sessions.create(SessionId('req-check'))
|
||||
@@ -30,14 +35,14 @@ async function requestSetup() {
|
||||
describe('request-reconstruction invariant', () => {
|
||||
it('accepts a frozen request equal to the boundary derivation and folded header', async () => {
|
||||
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()
|
||||
})
|
||||
|
||||
it('uses the step boundary rather than content appended afterward', async () => {
|
||||
const { ctx, session, boundary } = await requestSetup()
|
||||
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()
|
||||
})
|
||||
|
||||
@@ -45,20 +50,20 @@ describe('request-reconstruction invariant', () => {
|
||||
const { ctx, session, boundary } = await requestSetup()
|
||||
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' })
|
||||
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()
|
||||
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/)
|
||||
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/)
|
||||
})
|
||||
|
||||
it('rejects message and header divergence', async () => {
|
||||
const { ctx, session, boundary } = await requestSetup()
|
||||
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/)
|
||||
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/)
|
||||
})
|
||||
|
||||
@@ -66,7 +71,7 @@ describe('request-reconstruction invariant', () => {
|
||||
const ctx = await setup()
|
||||
const session = ctx.sessions.create(SessionId('req-bare'))
|
||||
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/)
|
||||
session.append('step/start', { turn: 1, step: 1 })
|
||||
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 () => {
|
||||
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/)
|
||||
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([]), sessionId: SessionId('ghost') })) })
|
||||
.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 () => {
|
||||
@@ -93,7 +120,7 @@ describe('request-reconstruction invariant', () => {
|
||||
session.append('user/message', { content: [{ type: 'text', text: 'hi' }], source: { kind: 'user' } }, { surfaceOp: 'append' })
|
||||
session.append('step/start', { turn: 1, step: 1 })
|
||||
session.append('request/header', { header: { config: { provider: 'mock', model: 'm' } }, reason: 'initial' })
|
||||
const divergent = Object.freeze({
|
||||
const divergent = loopRequest({
|
||||
model: 'm',
|
||||
messages: Object.freeze([{ role: 'user', content: [{ type: 'text', text: 'phantom' }] }]),
|
||||
sessionId: session.id,
|
||||
|
||||
@@ -521,7 +521,7 @@ describe('dsh-agent-spine-demo bundle', () => {
|
||||
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
|
||||
for (const companion of [sessionInvariant, agentInvariant, scopeInvariant, agentLoopInvariant]) {
|
||||
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.
|
||||
|
||||
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
|
||||
- name: '@deepseek-ai/dsh-llm-retry'
|
||||
|
||||
@@ -26,8 +26,8 @@ function validateRetry(
|
||||
if (!Number.isSafeInteger(maxRetries) || maxRetries < 1 || retry > maxRetries) {
|
||||
fail(`llm/retry retry ${retry} must not exceed a positive safe maxRetries ${maxRetries}`)
|
||||
}
|
||||
if (!(delayMs > 0 && delayMs <= MAX_TIMER_DELAY_MS)) {
|
||||
fail(`llm/retry delayMs must be within 1..${MAX_TIMER_DELAY_MS}`)
|
||||
if (!(delayMs >= 0 && delayMs <= MAX_TIMER_DELAY_MS)) {
|
||||
fail(`llm/retry delayMs must be within 0..${MAX_TIMER_DELAY_MS}`)
|
||||
}
|
||||
|
||||
const currentTurnEvents: SessionEvent[] = []
|
||||
|
||||
@@ -36,6 +36,10 @@ describe('llm-retry invariants', () => {
|
||||
session.append('llm/retry', {
|
||||
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()
|
||||
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: 1.5, delayMs: 1 }, /positive safe maxRetries/],
|
||||
[{ 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/],
|
||||
])('rejects invalid retry bounds %#', async (data, message) => {
|
||||
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 () => {
|
||||
vi.useFakeTimers()
|
||||
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-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-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.
|
||||
|
||||
@@ -77,6 +77,6 @@ None; invariant checks do not assemble or send provider requests.
|
||||
|
||||
## 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.
|
||||
- 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)
|
||||
})
|
||||
)
|
||||
const child = ctx.plugin(installer.inject === undefined
|
||||
? installInvariant
|
||||
: Object.assign(installInvariant, { inject: installer.inject }))
|
||||
|
||||
try {
|
||||
await child
|
||||
} catch (error) {
|
||||
try {
|
||||
await child.dispose()
|
||||
} finally {
|
||||
registrations.delete(packageName)
|
||||
}
|
||||
throw error
|
||||
}
|
||||
const child = ctx.plugin(installer.inject === undefined
|
||||
? installInvariant
|
||||
: Object.assign(installInvariant, { inject: installer.inject }))
|
||||
|
||||
return async () => {
|
||||
try {
|
||||
await child
|
||||
} catch (error) {
|
||||
await child.dispose()
|
||||
} finally {
|
||||
registrations.delete(packageName)
|
||||
throw error
|
||||
}
|
||||
|
||||
return async () => {
|
||||
try {
|
||||
await child.dispose()
|
||||
} finally {
|
||||
registrations.delete(packageName)
|
||||
}
|
||||
}
|
||||
} catch (error) {
|
||||
registrations.delete(packageName)
|
||||
throw error
|
||||
}
|
||||
}, `invariants.register(${JSON.stringify(packageName)})`)
|
||||
} catch (error) {
|
||||
|
||||
@@ -260,6 +260,28 @@ describe('InvariantService lifecycle', () => {
|
||||
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 () => {
|
||||
const { ctx } = await setup()
|
||||
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')
|
||||
})
|
||||
|
||||
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', () => {
|
||||
const explained = `
|
||||
export const name = 'probe-invariant'
|
||||
|
||||
@@ -210,6 +210,9 @@ function checkSource(
|
||||
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)
|
||||
}
|
||||
|
||||
@@ -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. */
|
||||
export function formatPackageInvariantViolation(
|
||||
root: string,
|
||||
|
||||
@@ -224,6 +224,7 @@ function ciPrimaryGates(): Gate[] {
|
||||
label: 'node-next types',
|
||||
needs: ['build'],
|
||||
}),
|
||||
builtPackageInvariantsGate(['build']),
|
||||
builtBinSmokeGate(),
|
||||
]
|
||||
}
|
||||
@@ -248,6 +249,7 @@ function ciArtifactGates(): Gate[] {
|
||||
label: 'node-next types',
|
||||
needs: ['build'],
|
||||
}),
|
||||
builtPackageInvariantsGate(['build']),
|
||||
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[] {
|
||||
const raw = process.env[envName]
|
||||
if (raw === undefined || raw === '') return []
|
||||
@@ -312,6 +321,7 @@ function hygieneLeafGates(options: { artifactNeeds?: string[] } = {}): Gate[] {
|
||||
pnpmScript('publint', 'publint', artifactOptions),
|
||||
pnpmScript('constraints', 'constraints'),
|
||||
pnpmScript('package-invariants', 'verify-package-invariants', { label: 'package invariants' }),
|
||||
builtPackageInvariantsGate(options.artifactNeeds),
|
||||
pnpmScript('node-next-types', 'verify-node-next-types', {
|
||||
label: 'node-next types',
|
||||
...artifactOptions,
|
||||
|
||||
@@ -1,11 +1,12 @@
|
||||
import { describe, expect, it, vi } from 'vitest'
|
||||
import { Context, Service } from 'cordis'
|
||||
import Loader from '@cordisjs/plugin-loader'
|
||||
import type { InvariantInstaller } from '@deepseek-ai/dsh-invariants'
|
||||
import { packageInvariantOwners } from './package-invariants.ts'
|
||||
import {
|
||||
MANUAL_INVARIANT_TESTS,
|
||||
testInvariantCompanionPaths,
|
||||
testInvariantCompanions,
|
||||
usesManualInvariantTree,
|
||||
} from './test-invariants.ts'
|
||||
|
||||
declare module 'cordis' {
|
||||
@@ -51,9 +52,10 @@ describe('global test invariant host', () => {
|
||||
.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 registrations = new Map<string, string>()
|
||||
const loader = Object.create(Loader.prototype) as Loader
|
||||
const register = vi.fn((_packageName: string, installer: InvariantInstaller) => {
|
||||
expect(typeof installer).toBe('function')
|
||||
return () => {}
|
||||
@@ -61,7 +63,13 @@ describe('global test invariant host', () => {
|
||||
const fakeContext = { invariants: { register } } as unknown as Context
|
||||
for (const [rawPath, companion] of Object.entries(testInvariantCompanions)) {
|
||||
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)
|
||||
if (call === undefined) throw new Error(`${path}: companion did not register`)
|
||||
registrations.set(path, call[0])
|
||||
@@ -69,29 +77,11 @@ describe('global test invariant host', () => {
|
||||
expect(registrations).toEqual(owners)
|
||||
})
|
||||
|
||||
it('limits manual composition to focused invariant topology tests', () => {
|
||||
expect(MANUAL_INVARIANT_TESTS).toEqual([
|
||||
'/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',
|
||||
])
|
||||
it('recognizes focused invariant suites without a package inventory', () => {
|
||||
expect(usesManualInvariantTree('/repo/packages/core/session/tests/invariant.spec.ts')).toBe(true)
|
||||
expect(usesManualInvariantTree('/repo/packages/core/session/tests/request-invariant-hmr.spec.ts')).toBe(true)
|
||||
expect(usesManualInvariantTree('C:\\repo\\packages\\support\\invariants\\tests\\service.spec.ts')).toBe(true)
|
||||
expect(usesManualInvariantTree('/repo/packages/examples/agent-spine-demo/tests/agent-core.spec.ts')).toBe(true)
|
||||
expect(usesManualInvariantTree('/repo/packages/core/session/tests/session.spec.ts')).toBe(false)
|
||||
})
|
||||
})
|
||||
+14
-25
@@ -21,6 +21,7 @@ declare global {
|
||||
export interface TestInvariantCompanion {
|
||||
readonly name: string
|
||||
readonly inject: readonly string[]
|
||||
readonly default?: unknown
|
||||
apply(ctx: Context): Promise<() => void>
|
||||
}
|
||||
|
||||
@@ -28,28 +29,9 @@ export interface TestInvariantCompanion {
|
||||
export const testInvariantCompanions: Readonly<Record<string, TestInvariantCompanion>> =
|
||||
import.meta.glob<TestInvariantCompanion>('../packages/*/*/src/invariant.ts', { eager: true })
|
||||
|
||||
/** Tests that exercise selection or companion lifecycle with a deliberately hand-built service tree. */
|
||||
export const MANUAL_INVARIANT_TESTS = [
|
||||
/** Manual-topology suites whose names cannot follow the focused invariant convention. */
|
||||
const MANUAL_INVARIANT_TEST_EXCEPTIONS = [
|
||||
'/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',
|
||||
] as const
|
||||
|
||||
@@ -66,7 +48,8 @@ const hosts = new WeakMap<Context, InvariantHost>()
|
||||
const originalPlugin = RegistryService.prototype.plugin
|
||||
|
||||
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 host = hosts.get(root) ?? startInvariantHost(root)
|
||||
@@ -83,9 +66,15 @@ RegistryService.prototype.plugin = function(plugin: Plugin, config?: unknown, ge
|
||||
return joinInvariantStartup(fiber, host.ready)
|
||||
}
|
||||
|
||||
function usesManualInvariantTree(): boolean {
|
||||
const testPath = expect.getState().testPath?.replaceAll('\\', '/') ?? ''
|
||||
return MANUAL_INVARIANT_TESTS.some(path => testPath.endsWith(path))
|
||||
/**
|
||||
* Detect focused suites that construct service selection or companion lifecycle explicitly.
|
||||
* @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
|
||||
|
||||
@@ -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