fix(invariants): harden runtime contracts and gates
This commit is contained in:
@@ -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()
|
||||
|
||||
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 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()
|
||||
|
||||
Reference in New Issue
Block a user