Skip to content
Open
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
79 changes: 79 additions & 0 deletions docs/_specs/nohup-exit-code/01_requirement.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,79 @@
# nohup 模式 exit_code 修复 — Requirement Spec

## Background

`Sandbox.arun()` 支持 `mode="nohup"` 参数,用于在沙箱内以后台方式运行长时命令。nohup 模式的执行流程如下:

1. 通过 `nohup {cmd} < /dev/null > {tmp_file} 2>&1 & echo PIDSTART${!}PIDEND;disown` 将命令提交到后台
2. 轮询 `kill -0 {pid}` 检测进程是否存活
3. 进程结束后,读取输出文件内容作为 `output`
4. 返回 `Observation(output=..., exit_code=...)`

### 当前问题

`exit_code` 的值不反映 cmd 的真实退出码:

- `success=True`(进程在 `wait_timeout` 内完成)→ 固定返回 `exit_code=0`
- `success=False`(超时)→ 固定返回 `exit_code=1`

这意味着即使 cmd 本身执行失败(如命令不存在、脚本报错),只要进程在超时前结束,`exit_code` 就是 `0`,调用方无法通过 `exit_code` 判断命令是否成功执行。

**示例**:

```python
result = await sandbox.arun(cmd="nonexistent_command_xyz", session="s", mode="nohup")
# 当前行为:result.exit_code == 0 ← 错误,命令不存在应返回 127
# 期望行为:result.exit_code == 127
```

---

## In / Out

### In(本次要做的)

1. **捕获 cmd 的真实退出码**
- 通过子 Shell 包裹,将 cmd 的退出码写入独立的 `.rc` 文件
- 进程结束后读取 `.rc` 文件,将其作为 `Observation.exit_code` 返回
- 适用于 `ignore_output=True` 和 `ignore_output=False` 两种路径

2. **保持超时语义不变**
- `success=False`(超时)时,`exit_code` 仍返回 `1`,`failure_reason` 包含超时信息

3. **向后兼容**
- `.rc` 文件读取失败时(如命令在 bash -c 之前就失败),`exit_code` 回退为原有逻辑(`0` 或 `1`)

### Out(本次不做的)

- 修改 `wait_for_process_completion` 的轮询策略或超时机制
- 修改 `ignore_output=True` 时的 detached 消息格式
- 对 `mode="normal"` 的任何修改(normal 模式天然返回真实 exit_code)
- SDK 客户端侧的行为变更文档更新

---

## Acceptance Criteria

- **AC1**:`arun(cmd="nonexistent_command", mode="nohup")` 返回 `exit_code=127`,`output` 包含 bash 的 "command not found" 信息
- **AC2**:`arun(cmd="exit 42", mode="nohup")` 返回 `exit_code=42`
- **AC3**:`arun(cmd="echo hello", mode="nohup")` 返回 `exit_code=0`,行为不变
- **AC4**:`arun(cmd="...", mode="nohup", ignore_output=True)` 同样返回真实 `exit_code`
- **AC5**:进程超时(`wait_timeout` 到期)时,`exit_code=1`,`failure_reason` 包含超时信息,行为不变
- **AC6**:`.rc` 文件不存在或内容非数字时,`exit_code` 回退为 `0`(success)或 `1`(timeout),不抛出异常

---

## Constraints

- 不引入新的外部依赖(不 `import shlex`)
- 不改变 nohup 命令中 cmd 的执行方式(不对 cmd 做额外 shell 转义)
- 不修改 `wait_for_process_completion` 的返回签名(保持 `tuple[bool, str]`)
- `.rc` 文件与 `.out` 文件使用相同的时间戳前缀,放在 `/tmp/` 下

---

## Risks

- **风险**:子 Shell `( ... )` 增加了一层进程,`${!}` 捕获的是子 Shell 的 PID 而非 nohup 进程的 PID,但监控语义不变(子 Shell 在 cmd 结束后才退出)
- **风险**:`.rc` 文件读取失败(磁盘满、权限问题等)时静默回退,不暴露给调用方
- **回滚**:仅修改 `rock/sdk/sandbox/client.py`,还原该文件即可
147 changes: 147 additions & 0 deletions docs/_specs/nohup-exit-code/02_interface.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,147 @@
# nohup 模式 exit_code 修复 — Interface Contract

本次修复不新增任何 API 端点,通过在 `Sandbox.arun()` 新增 `capture_exit_code` 开关参数,让调用方显式选择启用真实退出码捕获。

---

## 1. `Sandbox.arun()` — 签名变更

### 方法签名

```python
# 改前
async def arun(
self,
cmd: str,
session: str = None,
wait_timeout: int = 300,
wait_interval: int = 10,
mode: RunModeType = RunMode.NORMAL,
response_limited_bytes_in_nohup: int | None = None,
ignore_output: bool = False,
output_file: str | None = None,
) -> Observation:

# 改后
async def arun(
self,
cmd: str,
session: str = None,
wait_timeout: int = 300,
wait_interval: int = 10,
mode: RunModeType = RunMode.NORMAL,
response_limited_bytes_in_nohup: int | None = None,
ignore_output: bool = False,
output_file: str | None = None,
capture_exit_code: bool = False, # ← 新增
) -> Observation:
```

### `capture_exit_code` 参数说明

| 值 | 行为 |
|----|------|
| `False`(默认) | 原有逻辑不变:`success=True` → `exit_code=0`,`success=False` → `exit_code=1` |
| `True` | 启用子 Shell 包裹,捕获 cmd 真实退出码写入 `.rc` 文件,进程结束后读取 |

- 仅在 `mode="nohup"` 时生效;`mode="normal"` 下忽略该参数(normal 模式天然返回真实 exit_code)
- 默认 `False` 保证向后兼容,不改变任何现有调用的行为

### 返回值 `Observation.exit_code` 语义

| 场景 | `capture_exit_code=False`(原有) | `capture_exit_code=True`(新) |
|------|-----------------------------------|-------------------------------|
| cmd 成功执行(exit 0) | `0` | `0` |
| cmd 失败(exit N,N≠0) | `0` | `N` ← **修复** |
| cmd 不存在(command not found) | `0` | `127` ← **修复** |
| 进程完成,`.rc` 读取失败 | `0` | `0`(静默回退) |
| 进程超时(wait_timeout 到期) | `1` | `1`(不变,超时不读 `.rc`) |
| nohup 提交失败 | `1` | `1`(不变) |

### `failure_reason` 语义(不变)

`failure_reason` 只在以下情况非空:
- 进程超时:包含超时消息
- nohup 提交失败:包含错误信息

cmd 本身失败(exit_code ≠ 0)时,`failure_reason` 保持为空字符串,错误信息通过 `output` 字段(即 nohup 输出文件内容)传递。

---

## 2. 临时文件约定

`capture_exit_code=True` 时,nohup 模式每次调用会在沙箱内 `/tmp/` 目录额外生成 `.rc` 文件:

| 文件 | 路径模式 | 用途 | 条件 |
|------|----------|------|------|
| 输出文件 | `/tmp/tmp_{timestamp_ns}.out` | cmd 的 stdout + stderr | 始终生成 |
| 退出码文件 | `/tmp/tmp_{timestamp_ns}.rc` | cmd 的退出码(纯数字,一行) | 仅 `capture_exit_code=True` |

> 临时文件不会被自动清理,与现有 `.out` 文件行为一致。

---

## 3. `_arun_with_nohup()` — 签名变更

内部方法,新增 `capture_exit_code` 透传参数:

```python
# 改前
async def _arun_with_nohup(
self, cmd, session, wait_timeout, wait_interval,
response_limited_bytes_in_nohup, ignore_output, output_file,
) -> Observation:

# 改后
async def _arun_with_nohup(
self, cmd, session, wait_timeout, wait_interval,
response_limited_bytes_in_nohup, ignore_output, output_file,
capture_exit_code: bool = False, # ← 新增
) -> Observation:
```

`capture_exit_code=True` 时:生成 `exit_code_file` 路径并传给 `start_nohup_process` 和 `handle_nohup_output`。
`capture_exit_code=False` 时:`exit_code_file` 不生成,两个方法收到 `exit_code_file=None`,走原有逻辑。

---

## 4. `start_nohup_process()` — 签名变更

此方法为内部方法,新增可选参数 `exit_code_file`:

```python
# 改前
async def start_nohup_process(
self, cmd: str, tmp_file: str, session: str
) -> tuple[int | None, Observation | None]:

# 改后
async def start_nohup_process(
self, cmd: str, tmp_file: str, session: str,
exit_code_file: str | None = None, # ← 新增
) -> tuple[int | None, Observation | None]:
```

- `exit_code_file=None`:生成原始 nohup 命令(行为不变)
- `exit_code_file` 有值:生成子 Shell 包裹命令,将退出码写入该文件

---

## 5. `handle_nohup_output()` — 签名变更

新增可选参数 `exit_code_file`:

```python
# 改前
async def handle_nohup_output(
self, tmp_file, session, success, message, ignore_output, response_limited_bytes_in_nohup
) -> Observation:

# 改后
async def handle_nohup_output(
self, tmp_file, session, success, message, ignore_output, response_limited_bytes_in_nohup,
exit_code_file: str | None = None, # ← 新增
) -> Observation:
```

`exit_code_file=None` 时行为退化为改前逻辑,保证向后兼容(如有外部调用)。
Loading
Loading