fix(ptc): cancel pending runs when Session policy changes
Register calls before awaiting prior process cleanup. Carry policy cancellation into both Node execution modes and retain each admission until its call settles. Track persistent calls during provider disposal as well. Cover unchanged-mode temporary escalation and policy changes that return to the original mode while cleanup is pending. Document the admission guarantee. Validation: 308 focused tests passed, one skipped; both changed source files have 100% coverage. Two built-runtime smokes, two selected Session snapshots, all 43 doc-sync gates, and typed lint passed.
This commit is contained in:
1 parent
70d443903c
commit
b9863a9e28
6 files changed
+75
-9
No files matched your search
@@ -27,8 +27,8 @@
|
||||
en: df9944346e78d7de
|
||||
zh: e328efc31cbfb0bb
|
||||
/deepseek-ai-dsh-ptc-runtime-node/use-this-package/deadlines-and-cancellation:
|
||||
en: 6014e01f41d1ec22
|
||||
zh: 33ddebf3cdb1f914
|
||||
en: ad72b8bdba6a3966
|
||||
zh: 28a20fb5fb7c622d
|
||||
/deepseek-ai-dsh-ptc-runtime-node/use-this-package/failures:
|
||||
en: 7682e50f9dcfa25c
|
||||
zh: ddcb3502e12be06f
|
||||
|
||||
@@ -85,6 +85,8 @@ Service consumers pass an object as `stateOwner` to reuse its environment and aw
|
||||
|
||||
### Deadlines and cancellation
|
||||
|
||||
For calls carrying a Session policy, a mode change cancels admission even while waiting for cleanup or process startup. Restoring the previous mode does not revive a canceled call.
|
||||
|
||||
The PTC consumer exposes per-call timeout and approved sandbox choices as described in [dsh-tools](../../core/tools/README.md#ptc-mode). The runtime's readonly `timeout` descriptor reports its effective default and maximum to that consumer. The provider owns execution syntax, output syntax and state-retention guidance in `executionInstructions`. The tool consumer appends those mode-specific rules to its model-visible schema without inferring persistence from `programFormat`.
|
||||
|
||||
Omitting `timeoutMs` uses the configured elapsed default; numeric requests are validated and capped. Service callers can explicitly pass `timeoutMs: null` to omit the elapsed timer, as the workflow adapter does; `run_code` continues to accept only positive numeric overrides. An enabled deadline covers runtime setup and execution, including time awaiting nested tools or approval. It is not a CPU meter. Timeout or cancellation stops a synchronous loop through the host's managed process owner; successful per-call completion also cleans that managed range. The timer stops when an outcome is selected, before cleanup, so the returned call can take longer than its execution deadline while cleanup settles.
|
||||
|
||||
@@ -85,6 +85,8 @@ kind: "package-reference"
|
||||
|
||||
### 截止时间与取消
|
||||
|
||||
对于携带 Session 策略的调用,模式变化会取消其准入,即使调用仍在等待清理或进程启动。恢复先前的模式不会重新启用已取消的调用。
|
||||
|
||||
PTC 消费方按 [dsh-tools](../../core/tools/README.zh.md#ptc-mode) 的说明公开逐次超时与经审批的沙箱选择。运行时只读 `timeout` 描述符向该消费方报告有效默认值与上限。提供方通过 `executionInstructions` 负责求值语法、输出方式和状态保留指引。工具消费者将对应模式的规则附加到面向模型的 schema,不根据 `programFormat` 推断是否保留状态。
|
||||
|
||||
省略 `timeoutMs` 使用配置的经过时间默认值;数值请求经过验证并封顶。服务调用方可以显式传入 `timeoutMs: null` 来省略经过时间定时器,工作流适配器即如此;`run_code` 仍只接受正数覆盖值。启用的截止覆盖运行时准备和执行,包括等待嵌套工具或审批的时间。它不是 CPU 计量器。超时或取消通过 Host 的受管进程所有者停止同步循环;逐次执行成功完成也会清理该受管范围。选择结果后、清理前停止计时器,因此调用可能要在执行截止之后等待清理结算才返回。
|
||||
|
||||
@@ -58,7 +58,6 @@ export class NodePtcRuntime extends PtcRuntime {
|
||||
this.processes = new PolicyProcesses(ctx)
|
||||
if (this.config.executionMode === 'persistent-in-memory') {
|
||||
this.inMemory = new InMemoryExecution(ctx, this.config, this.processes)
|
||||
return
|
||||
}
|
||||
ctx.effect(() => async () => {
|
||||
this.disposed = true
|
||||
@@ -99,23 +98,32 @@ export class NodePtcRuntime extends PtcRuntime {
|
||||
|
||||
/**
|
||||
* Run a resolved program under the configured execution mode.
|
||||
* Session mode changes cancel admitted calls before process launch as well as active programs.
|
||||
* @param spec - Inputs returned by resolve for the configured mode.
|
||||
* @returns Logs, JSON completion or failure; retained processes remain until release or invalidation.
|
||||
*/
|
||||
async run(spec: PtcRunSpec): Promise<PtcRunResult> {
|
||||
await this.processes.wait(spec.sandboxPolicy?.sessionId)
|
||||
if (this.inMemory) return this.inMemory.run(spec)
|
||||
if (this.disposed) throw new Error('ptc-runtime-node: run after disposal')
|
||||
if (spec.sandboxPolicy === undefined) throw new Error('ptc-runtime-node: run requires a resolved sandbox policy')
|
||||
if (!isAbsolute(spec.cwd) || (spec.timeoutMs !== null && (!Number.isFinite(spec.timeoutMs) || spec.timeoutMs <= 0 || spec.timeoutMs > this.config.maxTimeoutMs))) throw new Error('ptc-runtime-node: run requires resolved cwd and timeout')
|
||||
const bindings = validateBindings(spec)
|
||||
const controller = new AbortController()
|
||||
const completion = Promise.withResolvers<void>()
|
||||
const unregister = spec.sandboxPolicy === undefined ? undefined : this.processes.register(spec.sandboxPolicy, async () => {
|
||||
controller.abort(new Error('sandbox mode changed'))
|
||||
await completion.promise
|
||||
})
|
||||
const live = { controller, finished: completion.promise }
|
||||
this.live.add(live)
|
||||
try {
|
||||
await this.processes.wait(spec.sandboxPolicy?.sessionId)
|
||||
if (this.inMemory) {
|
||||
const signal = spec.signal === undefined ? controller.signal : AbortSignal.any([spec.signal, controller.signal])
|
||||
return await this.inMemory.run({ ...spec, signal })
|
||||
}
|
||||
if (spec.sandboxPolicy === undefined) throw new Error('ptc-runtime-node: run requires a resolved sandbox policy')
|
||||
if (!isAbsolute(spec.cwd) || (spec.timeoutMs !== null && (!Number.isFinite(spec.timeoutMs) || spec.timeoutMs <= 0 || spec.timeoutMs > this.config.maxTimeoutMs))) throw new Error('ptc-runtime-node: run requires resolved cwd and timeout')
|
||||
const bindings = validateBindings(spec)
|
||||
return await this.execute(spec, spec.sandboxPolicy, bindings, controller)
|
||||
} finally {
|
||||
unregister?.()
|
||||
this.live.delete(live)
|
||||
completion.resolve()
|
||||
}
|
||||
|
||||
@@ -35,7 +35,7 @@ export class PolicyProcesses {
|
||||
}
|
||||
|
||||
/**
|
||||
* Track one process until its owner confirms cleanup.
|
||||
* Track an admitted call or retained process until its owner confirms cleanup.
|
||||
* @param policy - Resolved process policy, including an optional Session identity.
|
||||
* @param release - Idempotent cancellation and awaited cleanup.
|
||||
* @returns Removal after successful process cleanup.
|
||||
|
||||
@@ -120,6 +120,60 @@ it.each(['per-call', 'persistent-in-memory'] as const)('cancels a %s call when i
|
||||
expect(h.spawn).not.toHaveBeenCalled()
|
||||
})
|
||||
|
||||
it.each(['per-call', 'persistent-in-memory'] as const)('keeps an approved wider %s call when the standing mode is unchanged', async (executionMode) => {
|
||||
const h = await setup({ executionMode })
|
||||
const session = h.ctx.sessions.create(SessionId('temporary-admission'))
|
||||
setSandboxMode(session, 'read-only')
|
||||
const standing = h.ctx.sandboxPolicy.resolve({ session })
|
||||
h.receive((message) => {
|
||||
if (typeof message !== 'object' || message === null || !('type' in message)) return
|
||||
const done = { type: 'done', value: encodePtcJsonWire(1) }
|
||||
if (message.type === 'run') h.emit(frame(done))
|
||||
else if (message.type === 'boot') h.emit(done)
|
||||
})
|
||||
const pending = h.start({
|
||||
program: executionMode === 'per-call' ? 'return 1' : '1',
|
||||
bindings: [],
|
||||
sandboxPolicy: { ...standing, mode: 'danger-full-access' },
|
||||
})
|
||||
setSandboxMode(session, 'read-only')
|
||||
expect(await pending).toMatchObject({ value: 1, sandbox: { mode: 'danger-full-access' } })
|
||||
expect(h.spawn).toHaveBeenCalledOnce()
|
||||
expect(h.ctx.sandboxPolicy.resolve({ session }).mode).toBe('read-only')
|
||||
})
|
||||
|
||||
it.each(['per-call', 'persistent-in-memory'] as const)('keeps a %s call canceled when policy changes back during earlier cleanup', async (executionMode) => {
|
||||
const h = await setup({ executionMode })
|
||||
const session = h.ctx.sessions.create(SessionId('waiting-admission'))
|
||||
const booted = Promise.withResolvers<undefined>()
|
||||
const cleaning = Promise.withResolvers<undefined>()
|
||||
const cleanup = Promise.withResolvers<boolean>()
|
||||
h.receive((message) => {
|
||||
if (typeof message === 'object' && message !== null && 'type' in message
|
||||
&& (message.type === 'run' || message.type === 'boot')) booted.resolve(undefined)
|
||||
})
|
||||
h.waitForExit.mockImplementation(() => { cleaning.resolve(undefined); return cleanup.promise })
|
||||
const first = h.start({ ...request, sandboxPolicy: h.ctx.sandboxPolicy.resolve({ session }) })
|
||||
let waiting: ReturnType<typeof h.start> | undefined
|
||||
try {
|
||||
await booted.promise
|
||||
setSandboxMode(session, 'read-only')
|
||||
await cleaning.promise
|
||||
waiting = h.start({ ...request, stateOwner: {}, sandboxPolicy: h.ctx.sandboxPolicy.resolve({ session }) }, NO_INITIAL_FRAME)
|
||||
setSandboxMode(session, 'danger-full-access')
|
||||
setSandboxMode(session, 'read-only')
|
||||
expect(h.spawn).toHaveBeenCalledOnce()
|
||||
cleanup.resolve(true)
|
||||
expect((await first).error?.kind).toBe('abort')
|
||||
expect((await waiting).error?.kind).toBe('abort')
|
||||
expect(h.spawn).toHaveBeenCalledOnce()
|
||||
} finally {
|
||||
cleanup.resolve(true)
|
||||
await first
|
||||
await waiting
|
||||
}
|
||||
})
|
||||
|
||||
describe('persistent PTC host protocol', () => {
|
||||
it.each([
|
||||
null,
|
||||
|
||||
Reference in new issue
Block a user