Fix Codex wire frames and bound grace timers
This commit is contained in:
@@ -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 packages/subagent/subagent-codex/README.md
|
||||
README.md: 1dde57e10e27786ae06d395c7088976bf6f60ece
|
||||
README.zh.md: cb56281d9018e7a400ceb770e31da8a60d10d54f
|
||||
README.md: c25ee90edf8972da66448fe84cb659b0aec79e6f
|
||||
README.zh.md: 10c8fcc47a9ab04bca983857bd44ede265c23435
|
||||
|
||||
@@ -23,7 +23,7 @@ The provider advertises no optional start-time capabilities and reports `inherit
|
||||
| Key | Default | Meaning |
|
||||
|---|---|---|
|
||||
| `env` | `{}` | Explicit child environment layered over the subprocess seam's credential-scrubbed parent environment. |
|
||||
| `disposeGraceMs` | `3000` | Positive finite grace in milliseconds between the shared process-tree owner's termination tiers; disposal then waits for whole-tree exit. |
|
||||
| `disposeGraceMs` | `3000` | Positive finite grace in milliseconds, no greater than [`MAX_TIMER_DELAY_MS`](../../util/timeout/README.md), between the shared process-tree owner's termination tiers; disposal then waits for whole-tree exit. |
|
||||
|
||||
Production resolves `codex` from `PATH` and uses the host's native Codex configuration and authentication. The plugin does not install Codex, select a model, create `CODEX_HOME`, log in, or probe a version. Credential-shaped ambient variables are removed by the subprocess seam, so an API key intended for the child must be supplied explicitly in `env`; ordinary ambient values such as `PATH` and `HOME` remain available unless overridden.
|
||||
|
||||
|
||||
@@ -23,7 +23,7 @@
|
||||
| 配置键 | 默认值 | 含义 |
|
||||
|---|---|---|
|
||||
| `env` | `{}` | 显式指定的子进程环境,叠加在由子进程 seam 清除凭证后的父环境之上。 |
|
||||
| `disposeGraceMs` | `3000` | 共享进程树责任方各终止层级之间的宽限期,单位为毫秒且须为正有限值;随后资源释放会等待整棵进程树退出。 |
|
||||
| `disposeGraceMs` | `3000` | 共享进程树责任方各终止层级之间的宽限期,单位为毫秒且须为正有限值,并不得大于仓库共享的 [`MAX_TIMER_DELAY_MS`](../../util/timeout/README.md);随后资源释放会等待整棵进程树退出。 |
|
||||
|
||||
生产环境会从 `PATH` 中解析 `codex`,并使用宿主机原生的 Codex 配置与身份验证。本插件不安装 Codex、不选择模型、不创建 `CODEX_HOME`、不执行登录,也不探测版本。子进程 seam 会移除具有凭证特征的环境变量,因此供子进程使用的 API 密钥必须在 `env` 中显式提供;除非被覆盖,`PATH` 和 `HOME` 等普通环境变量值仍然可用。
|
||||
|
||||
|
||||
@@ -33,6 +33,7 @@
|
||||
"@deepseek-ai/dsh-session": "^0.0.1",
|
||||
"@deepseek-ai/dsh-subagent": "^0.0.1",
|
||||
"@deepseek-ai/dsh-subprocess": "^0.0.1",
|
||||
"@deepseek-ai/dsh-timeout": "^0.0.1",
|
||||
"cordis": "^4.0.0-rc.7"
|
||||
},
|
||||
"dependencies": {
|
||||
@@ -48,6 +49,7 @@
|
||||
"@deepseek-ai/dsh-subagent": "workspace:^",
|
||||
"@deepseek-ai/dsh-subprocess": "workspace:^",
|
||||
"@deepseek-ai/dsh-subprocess-local": "workspace:^",
|
||||
"@deepseek-ai/dsh-timeout": "workspace:^",
|
||||
"@openai/codex": "0.146.0",
|
||||
"cordis": "^4.0.0-rc.7"
|
||||
}
|
||||
|
||||
@@ -8,6 +8,7 @@
|
||||
|
||||
import type { Context } from 'cordis'
|
||||
import z from 'schemastery'
|
||||
import { MAX_TIMER_DELAY_MS } from '@deepseek-ai/dsh-timeout'
|
||||
import {
|
||||
assertPositiveFinite,
|
||||
NO_START_CAPABILITIES,
|
||||
@@ -85,5 +86,10 @@ export function apply(ctx: Context, config: Config): void {
|
||||
'disposeGraceMs',
|
||||
resolved.disposeGraceMs,
|
||||
)
|
||||
if (resolved.disposeGraceMs > MAX_TIMER_DELAY_MS) {
|
||||
throw new Error(
|
||||
`subagent-codex: disposeGraceMs must be no greater than ${MAX_TIMER_DELAY_MS}`,
|
||||
)
|
||||
}
|
||||
ctx.subagents.registerProvider(new CodexProvider(ctx, resolved))
|
||||
}
|
||||
|
||||
@@ -14,22 +14,6 @@ import { JsonRpcLineTransport } from '@deepseek-ai/dsh-sdk-protocol'
|
||||
|
||||
type JsonObject = Record<string, unknown>
|
||||
|
||||
interface Deferred<T> {
|
||||
readonly promise: Promise<T>
|
||||
readonly resolve: (value: T) => void
|
||||
readonly reject: (reason?: unknown) => void
|
||||
}
|
||||
|
||||
function deferred<T>(): Deferred<T> {
|
||||
let resolve!: (value: T) => void
|
||||
let reject!: (reason?: unknown) => void
|
||||
const promise = new Promise<T>((settle, fail) => {
|
||||
resolve = settle
|
||||
reject = fail
|
||||
})
|
||||
return { promise, resolve, reject }
|
||||
}
|
||||
|
||||
function object(value: unknown, label: string): JsonObject {
|
||||
if (value === null || typeof value !== 'object' || Array.isArray(value)) {
|
||||
throw new Error(`subagent-codex: app-server returned invalid ${label}`)
|
||||
@@ -98,11 +82,11 @@ async function raceAbort<T>(pending: Promise<T>, signal: AbortSignal): Promise<T
|
||||
*/
|
||||
export class CodexAppServerWire {
|
||||
private readonly transport: JsonRpcLineTransport
|
||||
private readonly fatal = deferred<never>()
|
||||
private readonly fatal = Promise.withResolvers<never>()
|
||||
private threadId: string | undefined
|
||||
private turnId: string | undefined
|
||||
private pendingTurnId: string | undefined
|
||||
private turnCompleted: Deferred<JsonObject> | undefined
|
||||
private turnCompleted: PromiseWithResolvers<JsonObject> | undefined
|
||||
private readonly earlyTurnNotifications: Array<{
|
||||
readonly method: string
|
||||
readonly params: JsonObject
|
||||
@@ -193,7 +177,7 @@ export class CodexAppServerWire {
|
||||
signal: AbortSignal,
|
||||
cancelled: () => boolean,
|
||||
): Promise<SubagentResult> {
|
||||
const completion = deferred<JsonObject>()
|
||||
const completion = Promise.withResolvers<JsonObject>()
|
||||
this.turnCompleted = completion
|
||||
const threadId = this.threadId as string
|
||||
const response = object(await this.guarded(this.transport.request('turn/start', {
|
||||
@@ -340,7 +324,8 @@ export class CodexAppServerWire {
|
||||
|
||||
private handleNotification(method: string, params: JsonObject): void {
|
||||
if (method === 'turn/started') {
|
||||
if (params.threadId !== this.threadId) return
|
||||
const threadId = string(params.threadId, 'turn/started thread id')
|
||||
if (threadId !== this.threadId) return
|
||||
const turn = object(params.turn, 'turn/started turn')
|
||||
if (this.turnCompleted !== undefined && this.turnId === undefined) {
|
||||
this.observePendingTurnId(string(turn.id, 'turn/started turn id'))
|
||||
@@ -348,7 +333,8 @@ export class CodexAppServerWire {
|
||||
return
|
||||
}
|
||||
if (method === 'item/completed') {
|
||||
if (params.threadId !== this.threadId) return
|
||||
const threadId = string(params.threadId, 'item/completed thread id')
|
||||
if (threadId !== this.threadId) return
|
||||
const id = string(params.turnId, 'item/completed turn id')
|
||||
if (this.turnId === undefined) {
|
||||
if (this.turnCompleted !== undefined) {
|
||||
@@ -373,7 +359,8 @@ export class CodexAppServerWire {
|
||||
return
|
||||
}
|
||||
if (method !== 'turn/completed') return
|
||||
if (params.threadId !== this.threadId) return
|
||||
const threadId = string(params.threadId, 'turn/completed thread id')
|
||||
if (threadId !== this.threadId) return
|
||||
const turn = object(params.turn, 'turn/completed turn')
|
||||
const id = string(turn.id, 'turn/completed turn id')
|
||||
const turnCompleted = this.turnCompleted
|
||||
|
||||
@@ -6,6 +6,7 @@ import type { Agent } from '@deepseek-ai/dsh-agent'
|
||||
import type { InvariantInstaller } from '@deepseek-ai/dsh-invariants'
|
||||
import type { ContentBlock } from '@deepseek-ai/dsh-llm'
|
||||
import SubagentService from '@deepseek-ai/dsh-subagent'
|
||||
import { MAX_TIMER_DELAY_MS } from '@deepseek-ai/dsh-timeout'
|
||||
import type {
|
||||
SubprocessHandle,
|
||||
SubprocessOutcome,
|
||||
@@ -294,6 +295,8 @@ describe('task admission and package contracts', () => {
|
||||
await expect(ctx.plugin(codex, { disposeGraceMs }))
|
||||
.rejects.toThrow('disposeGraceMs must be a positive finite number')
|
||||
}
|
||||
await expect(ctx.plugin(codex, { disposeGraceMs: MAX_TIMER_DELAY_MS + 1 }))
|
||||
.rejects.toThrow(`disposeGraceMs must be no greater than ${MAX_TIMER_DELAY_MS}`)
|
||||
await ctx.fiber.dispose()
|
||||
})
|
||||
|
||||
@@ -513,6 +516,16 @@ describe('CodexAppServerWire', () => {
|
||||
}
|
||||
})
|
||||
|
||||
it('fails closed when terminal notification params are not an object', async () => {
|
||||
const { child, wire } = await initializeWire()
|
||||
const result = wire.runTurn(['task'], new AbortController().signal, () => false)
|
||||
const turnStart = await child.peer.nextMethod('turn/start')
|
||||
child.peer.respond(turnStart, { turn: { id: 'turn-1' } })
|
||||
child.peer.send({ method: 'turn/completed', params: null })
|
||||
await expect(result).rejects.toThrow('invalid turn/completed thread id')
|
||||
wire.close()
|
||||
})
|
||||
|
||||
it('keeps an unsupported request authoritative over an early terminal in the same chunk', async () => {
|
||||
const { child, wire } = await initializeWire()
|
||||
const result = wire.runTurn(['task'], new AbortController().signal, () => false)
|
||||
|
||||
@@ -35,6 +35,9 @@
|
||||
{
|
||||
"path": "../../subprocess/subprocess"
|
||||
},
|
||||
{
|
||||
"path": "../../util/timeout"
|
||||
},
|
||||
{
|
||||
"path": "../../support/invariants"
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user