Skip to content
Draft
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
2 changes: 1 addition & 1 deletion .github/workflows/android-tests.yml
Original file line number Diff line number Diff line change
Expand Up @@ -159,7 +159,7 @@ jobs:
echo "sdk.dir=$ANDROID_HOME" > local.properties

- name: Run JVM unit tests
run: ./gradlew :app:testDebugUnitTest --stacktrace --no-daemon
run: ./gradlew :app:testDebugUnitTest :terminal:testDebugUnitTest --stacktrace --no-daemon

- name: Upload Android test reports
if: always()
Expand Down
2 changes: 1 addition & 1 deletion .github/workflows/pr-check.yml
Original file line number Diff line number Diff line change
Expand Up @@ -550,7 +550,7 @@ jobs:
run: |
set -euo pipefail
chmod +x ./gradlew
tasks=(":app:testDebugUnitTest")
tasks=(":app:testDebugUnitTest" ":terminal:testDebugUnitTest")
if [[ "$INSTRUMENTATION_REQUIRED" == "true" ]]; then
tasks+=(":app:compileDebugAndroidTestKotlin" ":app:compileDebugAndroidTestJavaWithJavac")
fi
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -95,7 +95,7 @@ object SystemToolPromptsInternal {
),
ToolPrompt(
name = "execute_hidden_terminal_command",
description = "Execute a command in a hidden non-PTY terminal executor. Commands using the same executor_key reuse the same hidden login context and are not shown in the visible terminal UI.",
description = "Execute a command in a hidden non-PTY terminal executor. Commands using the same executor_key reuse the same healthy hidden login context and are not shown in the visible terminal UI.",
parametersStructured =
listOf(
ToolParameterSchema(
Expand All @@ -114,7 +114,7 @@ object SystemToolPromptsInternal {
ToolParameterSchema(
name = "timeout_ms",
type = "integer",
description = "optional, command timeout in milliseconds",
description = "optional, whole hidden execution lifecycle timeout in milliseconds, including initialization and executor queueing",
required = false,
default = "120000"
)
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -366,13 +366,18 @@ class StandardTerminalCommandExecutor(private val context: Context) {
?.toLongOrNull()
?: 120000L

AppLogger.d(TAG, "Hidden exec requested: executorKey=$executorKey timeoutMs=$timeoutMs")
val terminal = Terminal.getInstance(context)
val hiddenResult =
terminal.executeHiddenCommand(
command = command,
executorKey = executorKey,
timeoutMs = timeoutMs
)
AppLogger.d(
TAG,
"Hidden exec completed: executorKey=$executorKey state=${hiddenResult.state} exitCode=${hiddenResult.exitCode}"
)
val output = extractHiddenExecOutput(hiddenResult)
val didTimeout = hiddenResult.state == HiddenExecResult.State.TIMEOUT
val errorMessage =
Expand Down
Original file line number Diff line number Diff line change
@@ -0,0 +1,53 @@
---
fork: https://github.com/yoruuuchan/Operit.git
issue: https://github.com/AAswordman/Operit/issues/1180
---

# 调用链与阻塞点 [DONE]

## 调用链

1. `Tools.System.terminal.hiddenExec` 把参数映射为 `execute_hidden_terminal_command`。
2. ToolPkg runtime 创建 Promise;只有 `JsNativeInterfaceDelegates.callToolAsync` 的 native 线程
调用 `sendToolResult` 后,Promise 才会 resolve 或 reject。
3. native 线程同步等待 `StandardTerminalCommandExecutor.executeHiddenCommand` 中的
`runBlocking`,再进入 `TerminalManager` 和当前 `TerminalProvider`。
4. 本地 provider 复用 `executorKey` 对应的后台 login shell,通过 marker 区分每条命令;
SSH provider 使用独立 exec channel。

因此 Promise 本身不是阻塞源。只要 provider 或 manager 不返回,native callback 就不会发生,
上层 Promise 会永久 pending。

## 真实阻塞点

最新 `dev` 的本地 hidden executor 有三类 timeout 覆盖缺口:

- `getOrCreateHiddenExecShell` 在 timeout 之外执行,包含全局创建 mutex、
`ProcessBuilder.start()` 和固定 30 秒 READY 等待。
- 命令写入虽然位于 `withTimeout` 中,但使用结构化 `withContext(Dispatchers.IO)`;
native pipe write/flush 不响应协程取消时,父协程必须继续等待这个 IO 子任务,timeout 无法返回。
- shell 已退出但后代仍持有 stdout 时,reader 可能永远等不到 EOF,结果 marker 也不会回来。

SSH 的 channel 建立也没有消耗同一 timeout 预算。上述任一路径阻塞,都会让 native callback
缺席,而不是在 QuickJS 前或 Promise 解析阶段卡住。

## 基线复现

在独立临时工作树中固定 `upstream/dev@5948dff9`,只加入进程注入测试缝,不加入修复。
以下三个 wall-clock 测试都在测试自身 deadline 到达后失败,证明旧实现没有完成调用:

- READY marker 永不出现;
- 另一个 key 等待全局 executor 创建 mutex;
- shell stdin write 永久阻塞。

测试直接约束 `LocalTerminalProvider` 通用层,没有改动 QQbot。

## 定位结论

已确认阻塞发生在 Kotlin/native provider 的 executor 准备、调度和 IO 生命周期。旧实现不是某条
异常分支漏调 `complete`,而是 timeout 作用域外或结构化取消等待中的 native IO 永远没有返回,
导致上层 callback 和 Promise 完成路径都无法到达。

## 修复位置

修复放在 `TerminalManager` 与 `TerminalProvider` 通用执行链,QQbot 仍使用原有 API 和参数。
Original file line number Diff line number Diff line change
@@ -0,0 +1,37 @@
---
fork: https://github.com/yoruuuchan/Operit.git
issue: https://github.com/AAswordman/Operit/issues/1180
---

# Executor 生命周期修复 [DONE]

## 全生命周期 deadline

`TerminalManager` 从环境初始化前开始计时,并把剩余预算传给 provider。manager 使用自身的
长生命周期 IO scope 承载可能不响应取消的 native 工作;调用方只 await 结果,到 deadline 后
立即取消该工作并返回明确 `TIMEOUT`,不会为了 join 阻塞 IO 而继续挂起。外部 cancellation
保留为 cancellation,并有 key、阶段和 timeout 日志。

## 本地 executor 状态机

- deadline 覆盖 provider 可用性、全局创建 mutex、进程启动、READY、同 key 排队、
stdin write/flush 和结果 marker 读取。
- 进程启动与 pipe write 在 provider scope 中执行;调用 deadline 不需要等待不可中断 IO。
启动完成时若调用已经取消,未发布的进程会立即销毁。
- 每个 shell 有原子 closed 状态、reader job、exit watcher 和命令 mutex。shell 退出时由 watcher
主动关闭结果 channel,即使后代仍持有 stdout,调用也会得到 `PROCESS_EXITED`。
- active 调用超时、取消、reader 异常、marker 异常或进程退出时,shell 会先从 key map 原子移除,
再终止命令进程组和 shell。销毁进程先于关闭 buffered writer,避免 close 再次阻塞 flush。
- 仅仅在 mutex 中排队的调用超时,不会关闭另一个 active owner。若 owner 关闭旧 shell,仍有预算的
排队调用会重新解析同一 key、创建健康 shell 并继续执行。
- 启动失败不会把半初始化 executor 发布到 map;相同或不同 key 的下一次调用均可重新创建。

## SSH 路径

SSH exec channel 的打开、connect、stdout/stderr drain 和完成检查共享同一剩余预算;失败、超时
与 cancellation 都在 `finally` 中关闭本次 channel。`executorKey`、结果字段和调用接口保持不变。

## 可诊断结果

成功、启动失败、执行错误、进程退出、timeout 与 cancellation 都有明确状态或异常;日志包含
`executorKey`、命令 token、当前阶段、timeout、最终 state 和 exit code。
Original file line number Diff line number Diff line change
@@ -0,0 +1,39 @@
---
fork: https://github.com/yoruuuchan/Operit.git
issue: https://github.com/AAswordman/Operit/issues/1180
---

# 测试与兼容性 [DONE]

## 覆盖范围

- 正常 hiddenExec 成功完成
- 进程启动失败返回明确错误
- READY 缺失、reader 异常、shell 退出等 executor 异常及时结束
- 创建 mutex、进程启动、同 key 排队、pipe write 或结果读取达到 timeout 后返回并清理
- queued timeout 不终止 active owner;owner timeout 后 queued 调用在新 shell 上继续
- cancellation 传播并清理 active shell
- END marker 分片时等待完整 exit-code 行
- disconnect 后拒绝新调用且不创建进程
- timeout 后相同与不同 executorKey 可再次执行
- QQbot、code_runner 与 linux_ssh 调用参数和结果字段保持兼容

## 自动化结果

- `:terminal:testDebugUnitTest`:Java 21,13 个测试全部通过。
- `:app:compileDebugKotlin`:Java 21,通过;验证 ToolPkg/Kotlin 入口与 terminal submodule 集成。
- `:terminal:assembleDebug`:作为全量 assemble 依赖执行并通过。
- `git diff --check`:父仓库与 terminal submodule 均通过。
- Android JVM test 工作流和 PR check 现同时执行 `:app:testDebugUnitTest` 与
`:terminal:testDebugUnitTest`,避免 submodule 测试只存在于磁盘而不进入 CI。

## 基线与环境边界

- `:app:testDebugUnitTest` 在当前分支编译既有测试源码时失败;独立干净工作树中的最新
`upstream/dev@5948dff9` 复现完全相同的 7 条错误,涉及
`DeepseekProviderMediaRoleTest` 和 `XaiProviderReasoningTest`,与本次 diff 无关。
- `assembleDebug` 已通过本次改动涉及的 Kotlin、terminal AAR、native/FFmpeg/STT 资产校验,
随后在 `:mnn:configureCMakeDebug[arm64-v8a]` 因本机没有 host `nmake`/C++ compiler 而停止。
- 当前没有连接的 adb 设备或可用 AVD,也没有 QQbot 凭证,未声称完成带凭证的 QQbot 设备重放。
QQbot、code_runner 和 linux_ssh 的调用点已逐一审计;它们继续使用相同参数和结果字段,
自动化测试直接覆盖 QQbot 所经过的同一通用 hidden executor。
46 changes: 46 additions & 0 deletions docs/TODO/issue-1180-hidden-exec-lifecycle/index.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,46 @@
---
fork: https://github.com/yoruuuchan/Operit.git
issue: https://github.com/AAswordman/Operit/issues/1180
base: https://github.com/AAswordman/Operit/tree/dev
---

# Issue 1180 hiddenExec 生命周期 [DONE]

## 现状

`Tools.System.terminal.hiddenExec` 从 ToolPkg JavaScript API 进入 Kotlin 工具执行器后,
最终交给 `terminal` 子模块中的隐藏 shell executor。Issue 1180 显示 QQbot 在启动后台
网关时可能永久等待;调用只有在上层生成被用户取消后才结束,传入的 timeout 没有约束完整
生命周期。

当前需要区分初始化、executor 创建与复用、同 key 排队、进程启动、输出解析和结果回传,
找到真实没有完成的等待路径,而不是在 QQbot 调用方添加专用绕行。

## 目标

- 所有 hiddenExec 路径最终明确成功、失败、取消或超时
- timeout 覆盖初始化、排队、进程启动和结果读取
- 失效或超时的 executor 不污染后续同 key 或不同 key 调用
- 保持 QQbot、code_runner 与 linux_ssh 的既有调用接口

## 作用域

- ToolPkg hiddenExec JavaScript bridge 与 Kotlin 工具入口的调用链审计
- `terminal` 子模块中的 hidden executor 生命周期修复与单元测试
- `examples/types/system.d.ts` 中与实际 timeout 和复用行为一致的 API 文档
- 本目录中的定位与验证记录

## PR

- 父仓库:https://github.com/AAswordman/Operit/pull/1201(Draft,目标 `AAswordman/Operit:dev`)
- terminal submodule:https://github.com/AAswordman/OperitTerminalCore/pull/6(目标 `master`,需先合并,父 PR 的 submodule 指针指向它)

## 完成状态

- 已同步最新 `upstream/dev`,确认 Issue 仍开放且无人分配或提交关联 PR
- 已在 Issue 下留言认领
- 已在最新 dev 基线上复现 executor 准备、创建 mutex 和阻塞 writer 三条永久等待路径
- 已完成通用生命周期修复、13 项回归测试、调用方兼容审计和 API 文档更新
- 已把 terminal 测试接入 Android JVM test 与 PR check 工作流
- 已完成相关测试、应用 Kotlin 集成编译和构建边界核验
- 已推送 fork 分支并创建父仓库 Draft PR 与 terminal submodule PR,已在 Issue 下同步链接
2 changes: 1 addition & 1 deletion examples/types/results.d.ts
Original file line number Diff line number Diff line change
Expand Up @@ -678,7 +678,7 @@ export interface HiddenTerminalCommandResultData {
/** Hidden executor key used for execution */
executorKey: string;

/** Whether this execution ended due to timeout. On timeout, the current command is cancelled and the terminal session is kept. */
/** Whether this execution ended due to timeout. An active timed-out hidden executor is retired so the next call with that key starts cleanly. */
timedOut?: boolean;

/** Returns a formatted string representation of the hidden terminal execution result */
Expand Down
6 changes: 3 additions & 3 deletions examples/types/system.d.ts
Original file line number Diff line number Diff line change
Expand Up @@ -299,10 +299,10 @@ export namespace System {

/**
* Execute a command in a hidden non-PTY executor.
* Commands using the same executorKey reuse the same hidden login context and are not shown in the visible terminal UI.
* Commands using the same executorKey reuse the same healthy hidden login context and are not shown in the visible terminal UI.
* @param command The command to execute.
* @param options Optional hidden executor options.
* @returns Promise resolving to the hidden command execution result. On timeout, the current command is cancelled, the hidden executor session is kept, and the returned result has `timedOut === true`.
* @param options Optional hidden executor options. timeoutMs covers initialization, executor queueing, and execution (default: 120000ms).
* @returns Promise resolving to the hidden command execution result, with `timedOut === true` on timeout. Failed or cancelled active executors are retired; the next call with that key creates a new login context. A queued call timing out does not cancel another active command.
*/
function hiddenExec(command: string, options?: {
executorKey?: string;
Expand Down