Skip to content
Open
Show file tree
Hide file tree
Changes from 1 commit
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
108 changes: 108 additions & 0 deletions docs/workspace-recycle.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,108 @@
# 工作区会话回收

`botmux workspace-recycle` 在外部工作区回收成功后,关闭事先确认属于该工作区的 Botmux 会话。发现依据为会话自身的 `workingDir`;群绑定、路径不存在、Bot 名称和标题都不单独授权关闭。跨 Bot 的写入由各自在线 daemon 执行,CLI 没有离线改库或全局删除兜底。

## 操作入口

先安装包含本功能的 Botmux 到工作区之外,并让涉及的 daemon 使用同版本。不要从即将被删除的 checkout 运行收尾命令。数据目录也必须在目标工作区之外。

```sh
# 只读。也可对已经不存在的目录生成存量候选清单。
botmux workspace-recycle discover --workspace /work/tasks/example

# 回收前:目录必须仍然存在。operationId 由外部通用 Hook 持久化并复用。
botmux workspace-recycle prepare \
--workspace /work/tasks/example --operation reclaim-example-001

# 外部系统执行它自己获准的工作区回收,保存真实结果。
# 只有回收成功且旧目录确实消失后,才通知成功。
botmux workspace-recycle finish \
--operation reclaim-example-001 --event end-result-example-001 --outcome succeeded

# 回收失败:不关闭任何会话,终止该次操作。
botmux workspace-recycle finish \
--operation reclaim-example-001 --event end-result-example-001 --outcome failed

# 纯回读,包含各会话的结果、资源快照和失败原因。
botmux workspace-recycle status --operation reclaim-example-001
```

同一次操作的终态事件不能从失败改成成功,也不能换 `eventId`。失败回收后的新一轮使用新的 `operationId`。准备失败时,外部 Hook 应停止工作区删除;修复原因后重新准备,身份或目标集合变更时创建新的操作。相同成功事件可以重放,已验证关闭的会话只回读。

主机 CLI 会从受管进程上下文识别当前会话;脱离会话的 Hook 可在 `prepare` 显式传 `--initiator <exact-sessionId>`。它必须属于发现的目标集合。准备时允许该发起会话仍在运行,其他忙碌会话会阻止准备成功。成功回收后:

1. 先关闭并验证其他目标;任何失败、残留或覆盖缺口都会保留发起会话。
2. 将发起会话的交接写入工作区之外的持久日志,先返回 `pending` 回执。
3. daemon 在后续检查点等待该会话空闲、无未结束 turn/队列、无新输入,并再次检查其他目标的关闭状态及目标集合。
4. 条件满足才最后关闭发起会话。等待超过 15 分钟、路径/输入/worker 代次变化等情况返回具体 blocker。不会以忙碌强杀解决这些 blocker。

daemon 重启会恢复已持久化的延迟交接;如果重启改变了 worker 或输入证据,会安全停止,不能把重新启动的执行当成原空闲会话。普通部分失败不自动重试,操作员可检查原因后重放同一成功事件。`pending` 不是资源释放成功,调用方须用 `status` 获取最终回读。

## 通用生命周期 Hook 适配

Botmux 不调用工作区删除程序,也不往 Agent Task 核心植入 Botmux 逻辑。外部生命周期系统显式配置两次命令调用,将它自己的事件映射成下面的中立 JSON。当前接口可独立验证;接入某个通用 Hook 实现之后,还需要验证真实事件时序与失败传播。

```json
{
"protocol": "botmux.workspace-recycle.v1",
"phase": "before-reclaim",
"operationId": "reclaim-example-001",
"workspacePath": "/work/tasks/example",
"initiatorSessionId": "exact-botmux-session-id"
}
```

```json
{
"protocol": "botmux.workspace-recycle.v1",
"phase": "after-reclaim",
"operationId": "reclaim-example-001",
"eventId": "durable-end-result-example-001",
"outcome": "succeeded"
}
```

两个事件均交给同一个显式入口:

```sh
/opt/botmux/bin/botmux workspace-recycle hook --event-file - < /durable/events/event.json
```

配置方应保证:

- before 事件在删除前执行,准备失败会阻止删除;after 事件读取实际回收结果。
- operationId、原事件和命令回执保存在工作区之外;同一次失败的网络回执先回读再重放。
- 两个阶段使用相同主机与 `SESSION_DATA_DIR`,覆盖所有相关 Bot。daemon 不在线、版本缺少此接口、鉴权失败或任意 store 无法读取均作为失败处理。
- 若另有群工作目录改绑 Hook,应将群绑定指向实际归档结果;绑定变化不替代运行中会话的关闭。新出现且不在本次计划中的会话只报告,不能偷偷扩充关闭集合。
- 所有 Hook 默认是显式配置的命令调用;安装本功能不会扫描并关闭历史工作区。历史候选只能只读展示,实际清理需要另外确认精确目标和适用流程。

## 结果与资源证据

返回 JSON 中保留 exact `sessionId`、`larkAppId`、`chatId`、`rootMessageId`、scope、工作目录原值与规范化路径、身份指纹,以及 `session.workingDir` 关联依据。目录包含关系使用路径段边界,`/work/a-other` 不属于 `/work/a`。存活祖先目录的符号链接会被规范化;悬空或被改变的别名不能被猜测为原工作区。

证据位于 `<dataDir>/workspace-recycle/<operationId>/`:`operation.json` 为协调记录,按 Bot/session 键散列命名的 JSON 为各 daemon 的持久关闭记录。写入使用锁、临时文件、原子 rename 与 fsync;日志不包含 prompt、原始 transcript、附件或凭据。标准关闭继续保留历史会话记录,并沿用原有临时资源清理行为。

每个目标分别记录 `before` / `after`:活跃注册、worker 端口、具有出生身份的 worker/CLI/可发现子进程、RSS、FD、Linux inotify 实例与 watch 数,以及持久后端探测结果。PID 消失和 PID 被复用都会与原进程仍存活区分;不会对复用 PID 或共享进程补发终止信号。

| 状态 | 含义 |
| --- | --- |
| `prepared` | 精确目标已持久化,尚未关闭 |
| `deferred` / 聚合 `pending` | 当前会话已持久交接,尚未验证关闭 |
| `closed` | durable closed、退出活跃注册,所观测的所属资源释放验证通过 |
| `closed_with_residual` | 本地会话关闭,但资源仍存在、远端残留或无法完成资源证明 |
| `blocked` / 聚合 `partial` | 有明确失败/覆盖缺口;成功的兄弟目标不会被回滚或重复关闭 |
| `aborted` | 工作区回收失败,未触发关闭 |

退出码:`0` 已准备/已验证/已终止;`1` 部分失败或 blocker;`2` 参数/事件/读取错误;`3` 延迟交接待回读。调用者必须同时检查 `status`,不能把 `0` 一概解释为资源释放。

Linux 上的 inotify 配额是每用户的资源限制,不能将实例数与 Botmux 会话数等同。单次资源前后对比不能证明历史故障根因。当前资源观测覆盖采样时可归属的进程和已冻结的持久后端;不宣称枚举任意已脱离进程树的外部进程。其他平台无法验证子进程集合时会保留残留说明,远端 Mojo/Riff 的拒绝与隔离残留直接沿用标准关闭结果。

## 验证

```sh
bun run build
bun run test -- test/workspace-recycle.test.ts \
test/workspace-recycle.integration.test.ts test/workspace-recycle-ipc.test.ts
```

测试使用临时目录和自建子进程。覆盖多 Bot/多群、目录边界与别名、外部会话排除、忙碌输入与排空锁、失败回收、部分失败恢复、关闭回执丢失、残留、当前会话最后退出及重复创建/回收。真实进程夹具创建自己的 HTTP 监听和 watcher,走标准关闭后验证 PID/注册/历史;不会连接真实飞书会话或关闭开发机现有 worker。
7 changes: 7 additions & 0 deletions src/cli.ts
Original file line number Diff line number Diff line change
Expand Up @@ -6331,6 +6331,8 @@ botmux v${getVersion()} — IM ↔ AI 编程 CLI 桥接
在宿主终端注册、查看或清除 desktop device 凭证(AI CLI 会话内拒绝)
actor current --json
返回当前 BotMux turn 的已验证企业用户名,不暴露 open_id/邮箱;脱离当前进程树时拒绝
workspace-recycle discover|prepare|finish|status|hook
按精确工作区发现、回收会话并读取资源验证结果
mojo-containment list|revoke
查看 / 显式撤销无法自证静止的 mojo containment handle(设备隔离
blocker 的可审计操作员出口;revoke 需 --yes,存活证据需 --force)
Expand Down Expand Up @@ -14893,6 +14895,11 @@ switch (command) {
process.exitCode = await runMojoContainmentCommand(process.argv.slice(3));
break;
}
case 'workspace-recycle': {
const { runWorkspaceRecycleCommand } = await import('./cli/workspace-recycle.js');
process.exitCode = await runWorkspaceRecycleCommand(process.argv.slice(3));
break;
}
case 'list':
case 'ls': await cmdList(); break;
case '__zmx-attach-managed': cmdManagedZmxAttach(process.argv.slice(3)); break;
Expand Down
79 changes: 79 additions & 0 deletions src/cli/workspace-recycle.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,79 @@
import { readFileSync } from 'node:fs';
import { WorkspaceRecycler } from '../services/workspace-recycle.js';
import { resolveBotmuxDataDir } from '../core/data-dir.js';
import { resolveSessionContext } from '../core/session-marker.js';
import { WORKSPACE_RECYCLE_PROTOCOL } from '../core/workspace-recycle-model.js';

const USAGE = `botmux workspace-recycle discover --workspace <absolute-path>
botmux workspace-recycle prepare --workspace <absolute-path> --operation <id> [--initiator <sessionId>]
botmux workspace-recycle finish --operation <id> --event <id> --outcome succeeded|failed
botmux workspace-recycle status --operation <id>
botmux workspace-recycle hook --event-file <json-file|->

discover is read-only (including missing legacy workspaces). prepare captures
exact targets while the directory exists. finish requires a successful generic
reclamation event AND a missing old directory. A failed event closes nothing.
The optional hook consumes botmux.workspace-recycle.v1 JSON from a generic
lifecycle adapter; Botmux must be installed outside the reclaimed directory.
JSON output; exit 0 = verified/prepared, 1 = partial/blocked, 2 = invalid input,
3 = durable initiator handoff pending (poll status). No force/global-delete mode.`;

export async function runWorkspaceRecycleCommand(args: string[]): Promise<number> {
if (args.length === 0 || args.includes('--help')) { console.log(USAGE); return 0; }
try {
const [action, ...rest] = args;
const options = new Map<string, string>();
for (let i = 0; i < rest.length; i += 2) {
if (!rest[i].startsWith('--') || !rest[i + 1] || rest[i + 1].startsWith('--') || options.has(rest[i])) throw new Error('invalid_or_duplicate_option');
options.set(rest[i], rest[i + 1]);
}
const allowed: Record<string, string[]> = {
discover: ['--workspace'], prepare: ['--workspace', '--operation', '--initiator'],
finish: ['--operation', '--event', '--outcome'], status: ['--operation'], hook: ['--event-file'],
};
if (!allowed[action] || [...options.keys()].some(key => !allowed[action].includes(key))) throw new Error('unknown_recycle_action_or_option');
const required = (key: string): string => {
const value = options.get(key);
if (!value) throw new Error(`required:${key}`);
return value;
};
const dataDir = resolveBotmuxDataDir();
const recycler = new WorkspaceRecycler({ dataDir });
const initiator = (workspace: string): string | undefined => {
const explicit = options.get('--initiator');
if (explicit) return explicit;
const current = resolveSessionContext(dataDir, process.env.BOTMUX_SESSION_ID)?.sessionId;
return current && recycler.discover(workspace).targets.some(target => target.sessionId === current) ? current : undefined;
};
let result: unknown;
if (action === 'discover') {
const discovery = recycler.discover(required('--workspace'));
result = { ok: discovery.errors.length === 0, dryRun: true, ...discovery };
} else if (action === 'prepare') {
result = await recycler.prepare(required('--operation'), required('--workspace'), initiator(required('--workspace')));
} else if (action === 'status') {
result = recycler.status(required('--operation'));
} else if (action === 'finish') {
const outcome = required('--outcome');
if (outcome !== 'succeeded' && outcome !== 'failed') throw new Error('invalid_recycle_outcome');
result = await recycler.finish(required('--operation'), { eventId: required('--event'), outcome });
} else {
const eventFile = required('--event-file');
const event = JSON.parse(readFileSync(eventFile === '-' ? 0 : eventFile, 'utf8'));
if (event.protocol !== WORKSPACE_RECYCLE_PROTOCOL || typeof event.operationId !== 'string') throw new Error('invalid_hook_event');
if (event.phase === 'before-reclaim') {
if (typeof event.workspacePath !== 'string' || (event.initiatorSessionId !== undefined && typeof event.initiatorSessionId !== 'string')) throw new Error('invalid_hook_workspace');
result = await recycler.prepare(event.operationId, event.workspacePath, event.initiatorSessionId ?? initiator(event.workspacePath));
} else if (event.phase === 'after-reclaim') {
if (typeof event.eventId !== 'string' || !['succeeded', 'failed'].includes(event.outcome)) throw new Error('invalid_hook_outcome');
result = await recycler.finish(event.operationId, { eventId: event.eventId, outcome: event.outcome });
} else throw new Error('invalid_hook_phase');
}
console.log(JSON.stringify(result, null, 2));
const outcome = result as { ok: boolean; status?: string };
return outcome.status === 'pending' ? 3 : outcome.ok ? 0 : 1;
} catch (error) {
console.log(JSON.stringify({ ok: false, error: String(error) }));
return 2;
}
}
40 changes: 40 additions & 0 deletions src/core/dashboard-ipc-server.ts
Original file line number Diff line number Diff line change
Expand Up @@ -162,6 +162,9 @@ import { DEFAULT_SESSION_OWNER_REMINDER } from './session-owner-reminder.js';
import { updateSessionOwnerReminderConfig } from '../services/session-owner-reminder-config-store.js';
import { sendSessionOwnerThreadNotification } from '../services/session-owner-notification.js';
import { matchesExpectedSessionLocateScope, type SessionLocateExpectedScope } from './session-locate-guard.js';
import { WorkspaceRecycleRuntime, type RecycleAction } from './workspace-recycle-runtime.js';
import { validateRecycleRequest } from './workspace-recycle-journal.js';
import { isSessionLifecycleInFlight, hasPendingOrdinaryImDelivery } from './worker-pool.js';
import { buildTerminalUrl } from './terminal-url.js';
import { dashboardEventBus } from './dashboard-events.js';
import { validateWorkingDir } from './working-dir.js';
Expand Down Expand Up @@ -1212,6 +1215,37 @@ ipcRoute('GET', '/api/sessions', (_req, res) => {
jsonRes(res, 200, { sessions: composeDashboardSessionRows({ includeTokenUsage: false }) });
});

// This route deliberately has NO session-capability/public allowlist entry.
// Recycling multiple sessions requires the existing trusted-host HMAC.
const workspaceRecycleRuntime = new WorkspaceRecycleRuntime({
appId: () => cachedLarkAppId,
dataDir: () => config.session.dataDir,
getSession: id => {
sessionStore.listSessionsStrict();
return sessionStore.getOwnedSession(id);
},
getRuntime: findActiveBySessionId,
allSessions: () => sessionStore.loadAllSessionsStrict(config.session.dataDir),
close: closeSession,
lifecycleBusy: ds => isSessionTransferring(ds) || isSessionLifecycleInFlight(ds) || hasPendingOrdinaryImDelivery(ds),
closeResidual: session => mojoCloseResidualForRow(session)?.reason,
onError: error => logger.warn(`[workspace-recycle] deferred recovery failed: ${String(error)}`),
});

ipcRoute('POST', '/api/workspace-recycle/:action', async (req, res, params) => {
if (!ipcHmacAuthorized(req)) return jsonRes(res, 401, { ok: false, error: 'unauthorized' });
if (!['prepare', 'close', 'defer', 'abort'].includes(params.action)) return jsonRes(res, 400, { ok: false, error: 'invalid_recycle_action' });
const body = await readJsonBody<unknown>(req);
try { validateRecycleRequest(body); }
catch (error) { return jsonRes(res, 400, { ok: false, error: String(error) }); }
try {
const result = await workspaceRecycleRuntime.perform(params.action as RecycleAction, body);
return jsonRes(res, result.status === 'deferred' ? 202 : result.ok ? 200 : 409, result);
} catch (error) {
return jsonRes(res, 409, { ok: false, error: String(error) });
}
});

ipcRoute('GET', '/api/sessions/:sessionId', (_req, res, params) => {
const ds = findActiveBySessionId(params.sessionId);
if (ds) return jsonRes(res, 200, { session: composeRowFromActive(ds) });
Expand Down Expand Up @@ -7590,6 +7624,12 @@ export function startIpcServer(opts: {
log: (m) => logger.warn(`[dashboard-ipc] ${m}`),
}).then((port) => {
boundPort = port;
// Restored current-session handoffs wait for the normal session restore
// barrier. The controller never depends on the removed workspace/process.
workspaceRecycleRuntime.start();
void (opts.ready ?? Promise.resolve()).then(() => workspaceRecycleRuntime.recoverDeferred())
.catch(error => logger.warn(`[workspace-recycle] deferred recovery failed: ${String(error)}`));
server.once('close', () => workspaceRecycleRuntime.stop());
return {
port,
close: () => new Promise<void>(r => server.close(() => r())),
Expand Down
9 changes: 9 additions & 0 deletions src/core/worker-pool.ts
Original file line number Diff line number Diff line change
Expand Up @@ -7549,6 +7549,15 @@ type OrdinaryImDelivery = {
* this in-memory delivery without retry would permanently drop the message. */
const pendingOrdinaryImDeliveries = new Map<string, OrdinaryImDelivery>();

/** A cached idle screen does not prove that a just-admitted message has been
* committed by the worker. Automatic workspace recycling must preserve it. */
export function hasPendingOrdinaryImDelivery(ds: DaemonSession): boolean {
for (const delivery of pendingOrdinaryImDeliveries.values()) {
if (delivery.ds === ds) return true;
}
return false;
}

function ordinaryImDeliveryKey(ds: DaemonSession, turnId: string, workerGeneration: number): string {
return `${ds.session.sessionId}:${workerGeneration}:${turnId}`;
}
Expand Down
Loading