Просмотр исходного кода

Merge ACP diagnostic lifecycle fixes into DSH SDK layer

pku-xht 1 месяц назад
Родитель
Сommit
12c7e968a0

+ 2 - 2
.agents/notes/implemented/feature/2026-08-21-out-of-process-subagent-minimal-diagnostics.i18n.yaml

@@ -2,5 +2,5 @@
 # side as of the last confirmed-consistent state. Both languages carry equal authority;
 # side as of the last confirmed-consistent state. Both languages carry equal authority;
 # after editing either side, bring the other along and re-record with:
 # after editing either side, bring the other along and re-record with:
 #   pnpm run verify-translation-pairing --write .agents/notes/implemented/feature/2026-08-21-out-of-process-subagent-minimal-diagnostics.md
 #   pnpm run verify-translation-pairing --write .agents/notes/implemented/feature/2026-08-21-out-of-process-subagent-minimal-diagnostics.md
-2026-08-21-out-of-process-subagent-minimal-diagnostics.md: 81cd8485dc39d4a50dc8938d13c8a066013c9ed5
-2026-08-21-out-of-process-subagent-minimal-diagnostics.zh.md: cb3c9b1893defea37d5c948411579e511b02e574
+2026-08-21-out-of-process-subagent-minimal-diagnostics.md: 86a8ed5f5ce38af9792aa8961c939098b79647e3
+2026-08-21-out-of-process-subagent-minimal-diagnostics.zh.md: f8a885ec6b36e28474683857eefe6d399d047857

+ 2 - 2
.agents/notes/implemented/feature/2026-08-21-out-of-process-subagent-minimal-diagnostics.md

@@ -30,10 +30,10 @@ When an ACP permission request contributes to a non-completed result, a fixed li
 
 
 | Stage | Owned operation | Safe categories and facts |
 | Stage | Owned operation | Safe categories and facts |
 | --- | --- | --- |
 | --- | --- | --- |
-| `initialize` | Parent workspace resolution, spawn, and ACP initialize | `configuration`, `transport`, `process-start`, or `process-exit` |
+| `initialize` | Parent workspace resolution and ACP initialize | `configuration`, `transport`, or `process-exit` |
 | `new-session` | ACP `session/new` and returned session-id validation | `protocol`, `transport`, or `process-exit` |
 | `new-session` | ACP `session/new` and returned session-id validation | `protocol`, `transport`, or `process-exit` |
 | `prompt` | ACP prompt request, remote stop reason, and permission callback | `remote-limit`, `transport`, `unknown`, or a permission-only diagnostic |
 | `prompt` | ACP prompt request, remote stop reason, and permission callback | `remote-limit`, `transport`, `unknown`, or a permission-only diagnostic |
-| `process` | Managed child exits before a prompt terminal response | `process-exit` plus independently observed exit code and signal |
+| `process` | Child-process spawn failure, or a managed child exits before a prompt terminal response | `process-start`, or `process-exit` plus independently observed exit code and signal |
 | `teardown` | EOF quiescence and managed process-tree termination | Fixed teardown facts; the original cleanup failure remains internal |
 | `teardown` | EOF quiescence and managed process-tree termination | Fixed teardown facts; the original cleanup failure remains internal |
 
 
 `max_turn_requests` remains the shared `error` stop reason and adds `remote-limit`. An unknown stop reason remains `error` and becomes the fixed `unknown` category without copying the value. `max_tokens`, `refusal`, and `cancelled` keep their existing shared stop reasons; they add a diagnostic only when a permission decision must be explained.
 `max_turn_requests` remains the shared `error` stop reason and adds `remote-limit`. An unknown stop reason remains `error` and becomes the fixed `unknown` category without copying the value. `max_tokens`, `refusal`, and `cancelled` keep their existing shared stop reasons; they add a diagnostic only when a permission decision must be explained.

+ 2 - 2
.agents/notes/implemented/feature/2026-08-21-out-of-process-subagent-minimal-diagnostics.zh.md

@@ -30,10 +30,10 @@ Subagent failure (provider: <provider>; stage: <stage>; category: <category>; st
 
 
 | Stage | 归属操作 | 安全 category 与事实 |
 | Stage | 归属操作 | 安全 category 与事实 |
 | --- | --- | --- |
 | --- | --- | --- |
-| `initialize` | 父工作区解析、spawn 与 ACP initialize | `configuration`、`transport`、`process-start` 或 `process-exit` |
+| `initialize` | 父工作区解析与 ACP initialize | `configuration`、`transport` 或 `process-exit` |
 | `new-session` | ACP `session/new` 与返回 session id 校验 | `protocol`、`transport` 或 `process-exit` |
 | `new-session` | ACP `session/new` 与返回 session id 校验 | `protocol`、`transport` 或 `process-exit` |
 | `prompt` | ACP prompt 请求、远端结束原因与权限回调 | `remote-limit`、`transport`、`unknown` 或仅权限诊断 |
 | `prompt` | ACP prompt 请求、远端结束原因与权限回调 | `remote-limit`、`transport`、`unknown` 或仅权限诊断 |
-| `process` | 受管子进程先于 prompt 终态响应退出 | `process-exit`,以及分别观测到的退出码与信号 |
+| `process` | 子进程 spawn 失败,或受管子进程先于 prompt 终态响应退出 | `process-start`,或 `process-exit` 以及分别观测到的退出码与信号 |
 | `teardown` | EOF 停稳与受管进程树终止 | 固定 teardown 事实;原始清理失败仍留在内部 |
 | `teardown` | EOF 停稳与受管进程树终止 | 固定 teardown 事实;原始清理失败仍留在内部 |
 
 
 `max_turn_requests` 继续映射到共享 `error`,并附加 `remote-limit`。未知结束原因继续映射到 `error`,category 固定为 `unknown`,不会复制原值。`max_tokens`、`refusal` 与 `cancelled` 保持既有共享结束原因;只有需要解释权限决定时才会附加诊断。
 `max_turn_requests` 继续映射到共享 `error`,并附加 `remote-limit`。未知结束原因继续映射到 `error`,category 固定为 `unknown`,不会复制原值。`max_tokens`、`refusal` 与 `cancelled` 保持既有共享结束原因;只有需要解释权限决定时才会附加诊断。

+ 11 - 6
packages/subagent/subagent-acp/src/run.ts

@@ -137,7 +137,7 @@ function permissionDiagnostic(permission: AcpPermissionDecision): string {
   return `ACP unattended decision (policy: ${permission.policy}; request: ${permission.request}; decision: ${permission.decision})`
   return `ACP unattended decision (policy: ${permission.policy}; request: ${permission.request}; decision: ${permission.decision})`
 }
 }
 
 
-/** Put the operation failure first, followed by the latest contributing permission fact. */
+/** Put the operation failure first, followed by the latest permission decision. */
 function diagnosticText(facts: AcpFailureFacts, permission?: AcpPermissionDecision): string {
 function diagnosticText(facts: AcpFailureFacts, permission?: AcpPermissionDecision): string {
   const failure = failureDiagnostic(facts)
   const failure = failureDiagnostic(facts)
   return permission === undefined ? failure : `${failure}\n${permissionDiagnostic(permission)}`
   return permission === undefined ? failure : `${failure}\n${permissionDiagnostic(permission)}`
@@ -378,7 +378,7 @@ export async function startAcpRun(request: SubagentStartRequest, spec: AcpRunSpe
     if (processOutcome !== undefined || child.pid <= 0) return processOutcome
     if (processOutcome !== undefined || child.pid <= 0) return processOutcome
     try {
     try {
       const exited = await child.waitForExit(
       const exited = await child.waitForExit(
-        AbortSignal.timeout(Math.min(spec.disposeGraceMs, 100)),
+        AbortSignal.timeout(Math.ceil(spec.disposeGraceMs)),
       )
       )
       if (exited) return await processDone
       if (exited) return await processDone
     } catch {
     } catch {
@@ -489,11 +489,16 @@ export async function startAcpRun(request: SubagentStartRequest, spec: AcpRunSpe
     request.signal.removeEventListener('abort', onAbort)
     request.signal.removeEventListener('abort', onAbort)
     const cancelledBeforeCleanup = flags.cancelled
     const cancelledBeforeCleanup = flags.cancelled
     // A child closing its protocol stream can precede whole-tree exit
     // A child closing its protocol stream can precede whole-tree exit
-    // observation. Wait briefly for an already-ending process, but do not let a
-    // still-live transport failure delay rollback by a full teardown grace.
-    const startupOutcome = await observeProcessOutcome()
+    // observation. Local cancellation does not need the discarded startup
+    // classification; other failures use the configured process grace.
+    const startupOutcome = cancelledBeforeCleanup
+      ? processOutcome
+      : await observeProcessOutcome()
     const failure = startupFailure(error, startupStage, child, startupOutcome)
     const failure = startupFailure(error, startupStage, child, startupOutcome)
-    if (!cancelledBeforeCleanup) {
+    if (cancelledBeforeCleanup) {
+      // Local cancellation owns the startup outcome; only cleanup failure is
+      // reported below when teardown itself rejects.
+    } else {
       reportFailure(spec, error instanceof AcpRunFailure
       reportFailure(spec, error instanceof AcpRunFailure
         ? error.cause
         ? error.cause
         : error)
         : error)

+ 3 - 0
packages/subagent/subagent-acp/tests/subagent-acp.spec.ts

@@ -635,6 +635,7 @@ describe('dsh-subagent-acp', () => {
     const ready = join(tmp, 'ready')
     const ready = join(tmp, 'ready')
     const go = join(tmp, 'go')
     const go = join(tmp, 'go')
     const rawCleanup = 'cancel rollback leaked SECRET_TOKEN'
     const rawCleanup = 'cancel rollback leaked SECRET_TOKEN'
+    const errors: string[] = []
     let realChild: SubprocessHandle | undefined
     let realChild: SubprocessHandle | undefined
     try {
     try {
       const controller = new AbortController()
       const controller = new AbortController()
@@ -650,6 +651,7 @@ describe('dsh-subagent-acp', () => {
           realChild = spawnSubprocess(spec)
           realChild = spawnSubprocess(spec)
           return rejectFinalExitWait(realChild, rawCleanup)
           return rejectFinalExitWait(realChild, rawCleanup)
         },
         },
+        onError: (error) => { errors.push(error.message) },
       })
       })
       await waitForFile(ready)
       await waitForFile(ready)
       controller.abort()
       controller.abort()
@@ -661,6 +663,7 @@ describe('dsh-subagent-acp', () => {
         `subagent-acp: ${expectedFailure('stage: teardown; category: unknown')}`,
         `subagent-acp: ${expectedFailure('stage: teardown; category: unknown')}`,
       )
       )
       expect((error as Error).message).not.toContain(rawCleanup)
       expect((error as Error).message).not.toContain(rawCleanup)
+      expect(errors).toEqual([rawCleanup])
       await realChild?.done
       await realChild?.done
     } finally {
     } finally {
       rmSync(tmp, { recursive: true, force: true })
       rmSync(tmp, { recursive: true, force: true })