@kangyishuai/dsh-session-rewind 0.1.0 → 0.2.0
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/README.md +15 -13
- package/README.zh-CN.md +15 -13
- package/lib/agent-settle.js +108 -0
- package/lib/client.js +110 -10
- package/lib/operations.js +19 -7
- package/lib/orphans.js +141 -0
- package/lib/service.js +90 -8
- package/locale/en.json +8 -2
- package/locale/zh.json +8 -2
- package/package.json +1 -1
package/README.md
CHANGED
|
@@ -8,17 +8,18 @@
|
|
|
8
8
|
|
|
9
9
|
Unofficial plugin for [DeepSeek Harness (DSH)](https://github.com/deepseek-ai/deepseek-harness). Not affiliated with DeepSeek.
|
|
10
10
|
|
|
11
|
-
> **Status: early.** Verified end to end on DSH 0.2.2-rc.1: 31 GUI assertions in a throwaway DSH home,
|
|
11
|
+
> **Status: early.** Verified end to end on DSH 0.2.2-rc.1: 31 GUI assertions in a throwaway DSH home, 158 unit tests, and a CDP-driven desktop pass (conflict refusal, conversation + file rewind, created-file trash, modified-file restore, rescue restore, panel/audit/diagnostics). Young software — keep backups of anything you cannot lose.
|
|
12
12
|
|
|
13
13
|
## What it does
|
|
14
14
|
|
|
15
15
|
Long conversations drift. A few turns later you wish you had answered a prompt differently, but starting a new session loses everything before it, and continuing keeps the unwanted turns in the context window forever. This plugin rewinds **in place**:
|
|
16
16
|
|
|
17
17
|
- **Two entries.** Press `Esc` `Esc` to open the rewind dialog, or click the ↶ button in any user message's action row (next to Copy).
|
|
18
|
+
- **Running turns are not a dead end.** Confirming a rewind while a turn is running cancels that turn (keeping the queue), waits for it to go idle, then re-plans against the settled history.
|
|
18
19
|
- **Two scopes, chosen per rewind.** The confirmation dialog offers **Rewind conversation only** (default) and **Rewind conversation and files**. The dialog first shows a dry-run preview: how many messages and roughly how many tokens will be withdrawn, which files will be restored or moved to the trash, conflicts, and skipped files.
|
|
19
20
|
- **Conversation.** The target message and everything after it are withdrawn from the model-visible context and from the transcript. The session id, title and earlier history are untouched. Nothing is deleted from the log — one marker event is appended.
|
|
20
21
|
- **Files (optional).** Files changed by `write` / `edit` / `apply_patch` in the withdrawn turns are restored to their pre-change content. Files created after the target are moved to the plugin's trash instead of deleted. A rescue point of the current files is created before anything is written; failures roll back from it.
|
|
21
|
-
- **Composer.** The target message's text (and images) is put back into the composer, ready to edit and send again. An existing draft is never overwritten.
|
|
22
|
+
- **Composer.** The target message's text (and images) is put back into the composer, ready to edit and send again. An existing draft is never overwritten. Tick **Send this message again right after rewinding** in the confirmation dialog to retry the turn in one click (conversation-only rewinds).
|
|
22
23
|
- **Rewind panel.** A new header action opens a panel with four tabs: **Withdrawn** (every message each rewind removed, searchable, copy or put back in the composer), **History** (audit of each rewind: scope, message count, tokens, file results), **Rescue points** (restore files from an earlier rescue), and **Settings** (default scope, page size, confirm-on-list, diagnostics).
|
|
23
24
|
|
|
24
25
|
## Install
|
|
@@ -49,14 +50,14 @@ Re-run `pnpm pack` + `add` after changing the code. The plugin has a host half,
|
|
|
49
50
|
|
|
50
51
|
**What is refused**
|
|
51
52
|
|
|
52
|
-
| Situation | What happens
|
|
53
|
-
| ------------------------------------------------------------------------ |
|
|
54
|
-
| The session has a running turn |
|
|
55
|
-
| The session is a subagent child | No ↶ button and no `Esc` `Esc` dialog: the Harness refuses generic session RPCs there.
|
|
56
|
-
| The target message was shadowed by compaction | Refused; the rewind boundary must still be in the model context.
|
|
57
|
-
| A file was changed outside the agent since the last recorded state | File mode is refused with a conflict list; no file is touched.
|
|
58
|
-
| A file is larger than 8 MiB, a symlink, or under `.git` / `node_modules` | That file is skipped and reported; other files still proceed.
|
|
59
|
-
| A rewind is already running on this session | Refused; one at a time.
|
|
53
|
+
| Situation | What happens |
|
|
54
|
+
| ------------------------------------------------------------------------ | --------------------------------------------------------------------------------------------------------------------------------------------------------- |
|
|
55
|
+
| The session has a running turn | It is cancelled first (queued steering messages are dropped), then the rewind runs. Set `cancelRunning: false` to refuse instead (`session-rewind/busy`). |
|
|
56
|
+
| The session is a subagent child | No ↶ button and no `Esc` `Esc` dialog: the Harness refuses generic session RPCs there. |
|
|
57
|
+
| The target message was shadowed by compaction | Refused; the rewind boundary must still be in the model context. |
|
|
58
|
+
| A file was changed outside the agent since the last recorded state | File mode is refused with a conflict list; no file is touched. |
|
|
59
|
+
| A file is larger than 8 MiB, a symlink, or under `.git` / `node_modules` | That file is skipped and reported; other files still proceed. |
|
|
60
|
+
| A rewind is already running on this session | Refused; one at a time. |
|
|
60
61
|
|
|
61
62
|
## File restore scope & safety
|
|
62
63
|
|
|
@@ -79,7 +80,7 @@ All plugin data lives under `<DSH_HOME>/session-rewind/` (`DSH_HOME` defaults to
|
|
|
79
80
|
| `rescues/<session>/<rescueId>/` | rescue points (current files before a restore) |
|
|
80
81
|
| `audit.jsonl` | one line per rewind (append-only) |
|
|
81
82
|
|
|
82
|
-
Retention: the 20 latest message anchors per session, the 5 latest rescue points, the 20 latest trash operations (30 days), and a 1 GiB soft cap across blobs/trash/rescues. When the cap is exceeded, old trash → old rescues → unreferenced blobs → oldest anchors are cleaned in that order; if still over the cap, new captures stop and diagnostics report it (no referenced data is deleted). Uninstalling keeps the data. The data root can be overridden with the plugin's `dataRoot` config.
|
|
83
|
+
On startup the plugin also removes data for sessions that no longer exist (they were deleted); the audit log is kept. Retention: the 20 latest message anchors per session, the 5 latest rescue points, the 20 latest trash operations (30 days), and a 1 GiB soft cap across blobs/trash/rescues. When the cap is exceeded, old trash → old rescues → unreferenced blobs → oldest anchors are cleaned in that order; if still over the cap, new captures stop and diagnostics report it (no referenced data is deleted). Uninstalling keeps the data. The data root can be overridden with the plugin's `dataRoot` config.
|
|
83
84
|
|
|
84
85
|
## Side effects & risks
|
|
85
86
|
|
|
@@ -94,7 +95,7 @@ Retention: the 20 latest message anchors per session, the 5 latest rescue points
|
|
|
94
95
|
|
|
95
96
|
| DSH version | Status |
|
|
96
97
|
| ----------- | --------------------------------------------------------------------------------------------------------------------------- |
|
|
97
|
-
| 0.2.2-rc.1 | Verified: `pnpm smoke:session-rewind` (31/31) +
|
|
98
|
+
| 0.2.2-rc.1 | Verified: `pnpm smoke:session-rewind` (31/31) + 158 unit tests + CDP-driven desktop pass (conversation-only and file modes) |
|
|
98
99
|
|
|
99
100
|
The plugin declares no `engines.dsh` and no `@deepseek-ai/*` peer range: DSH is pre-release and semver ranges over prereleases are unreliable (see [ADR 0005](../../docs/adr/0005-dsh-version-compatibility.md)). Treat the table as the compatibility statement. After a DSH upgrade run `pnpm probe:runtime`, `pnpm smoke:session-rewind`, and check the plugin's diagnostics tab; if a private seam changed, the plugin degrades instead of breaking the session.
|
|
100
101
|
|
|
@@ -103,7 +104,8 @@ The plugin declares no `engines.dsh` and no `@deepseek-ai/*` peer range: DSH is
|
|
|
103
104
|
```bash
|
|
104
105
|
pnpm test # unit tests (host engine, host RPC, client helpers)
|
|
105
106
|
pnpm verify # repo-wide gates
|
|
106
|
-
pnpm smoke:session-rewind
|
|
107
|
+
pnpm smoke:session-rewind # temp DSH home + real GUI end-to-end (needs dsh & ego-browser)
|
|
108
|
+
pnpm smoke:session-rewind-desktop # read-only desktop check (needs --remote-debugging-port=9222)
|
|
107
109
|
```
|
|
108
110
|
|
|
109
111
|
Design notes: [ADR 0006](../../docs/adr/0006-session-rewind-architecture.md) and [ADR 0007](../../docs/adr/0007-session-rewind-file-restore.md) (Chinese; the repository docs are written in Chinese).
|
package/README.zh-CN.md
CHANGED
|
@@ -8,7 +8,7 @@
|
|
|
8
8
|
|
|
9
9
|
非官方插件,与 DeepSeek 无关。面向 [DeepSeek Harness (DSH)](https://github.com/deepseek-ai/deepseek-harness)。
|
|
10
10
|
|
|
11
|
-
> **状态:早期。** 已在 DSH 0.2.2-rc.1 上端到端验证:一次性 DSH 目录里 31 项 GUI 断言 +
|
|
11
|
+
> **状态:早期。** 已在 DSH 0.2.2-rc.1 上端到端验证:一次性 DSH 目录里 31 项 GUI 断言 + 158 个单测 + CDP 驱动的桌面版实测(冲突拒绝、对话+文件回退、新建文件进回收站、修改文件还原、救援点恢复、面板/审计/诊断)。插件还年轻,重要数据请自行备份。
|
|
12
12
|
|
|
13
13
|
## 它做什么
|
|
14
14
|
|
|
@@ -18,7 +18,8 @@
|
|
|
18
18
|
- **每次回溯二选一**:确认框里提供「**仅回退对话**」(默认)与「**回退对话和文件**」,并先展示 dry-run 预览:将撤回多少条消息、约多少 token、哪些文件会还原/移入回收站、冲突与被跳过的文件。
|
|
19
19
|
- **对话**:目标消息及其后的内容从模型上下文和渲染对话中撤回;会话 id、标题和目标之前的历史不变;日志只追加一条 marker,不删历史。
|
|
20
20
|
- **文件(可选)**:被撤回轮次里由 `write` / `edit` / `apply_patch` 改过的文件还原到改动前内容;目标之后新建的文件移入插件回收站而不是删除。写入前先建救援点,失败从救援点回滚。
|
|
21
|
-
-
|
|
21
|
+
- **运行中也能回退**:确认回退时如果这一轮正在跑,会先取消它(保留队列)、等它空闲,再对新历史重新求值。
|
|
22
|
+
- **输入框**:目标消息文本(以及图片)放回输入框,改完可直接重发;已有草稿绝不覆盖。勾选「回退后立即重发」可一键重试这一轮(仅对话模式)。
|
|
22
23
|
- **回溯面板**:会话头部新增一个入口,四个页签:**已撤回**(每次回溯撤回的全部消息,可搜索、复制、放回输入框)、**回溯记录**(审计:范围、条数、token、文件结果)、**救援点**(手动恢复文件)、**设置**(默认范围、每页数量、列表确认、诊断)。
|
|
23
24
|
|
|
24
25
|
## 安装
|
|
@@ -49,14 +50,14 @@ dsh plugin --profile desktop add /tmp/kangyishuai-dsh-session-rewind-*.tgz
|
|
|
49
50
|
|
|
50
51
|
**会被拒绝的情况**
|
|
51
52
|
|
|
52
|
-
| 情况 | 表现
|
|
53
|
-
| ----------------------------------------------------------- |
|
|
54
|
-
| 会话有正在运行的回合 |
|
|
55
|
-
| 子代理会话 | 没有 ↶ 按钮,`Esc` `Esc` 也不弹框:宿主拒绝对这类会话的通用 RPC。
|
|
56
|
-
| 目标消息已被 compaction 遮蔽 | 拒绝;回溯边界必须仍在模型上下文里。
|
|
57
|
-
| 文件在上次记录后被外部修改 | 文件模式整体拒绝并列出冲突文件;一个文件都不动。
|
|
58
|
-
| 文件超过 8 MiB、是符号链接、或在 `.git` / `node_modules` 下 | 该文件跳过并报告,其余文件继续。
|
|
59
|
-
| 同一会话已有回溯在执行 | 拒绝;一次只允许一个。
|
|
53
|
+
| 情况 | 表现 |
|
|
54
|
+
| ----------------------------------------------------------- | ------------------------------------------------------------------------------------------------------------------------- |
|
|
55
|
+
| 会话有正在运行的回合 | 先取消该回合并等到 idle(队列里未投递的插话会被丢弃),再回溯;设 `cancelRunning: false` 则仍返回 `session-rewind/busy`。 |
|
|
56
|
+
| 子代理会话 | 没有 ↶ 按钮,`Esc` `Esc` 也不弹框:宿主拒绝对这类会话的通用 RPC。 |
|
|
57
|
+
| 目标消息已被 compaction 遮蔽 | 拒绝;回溯边界必须仍在模型上下文里。 |
|
|
58
|
+
| 文件在上次记录后被外部修改 | 文件模式整体拒绝并列出冲突文件;一个文件都不动。 |
|
|
59
|
+
| 文件超过 8 MiB、是符号链接、或在 `.git` / `node_modules` 下 | 该文件跳过并报告,其余文件继续。 |
|
|
60
|
+
| 同一会话已有回溯在执行 | 拒绝;一次只允许一个。 |
|
|
60
61
|
|
|
61
62
|
## 文件还原的范围与安全
|
|
62
63
|
|
|
@@ -79,7 +80,7 @@ dsh plugin --profile desktop add /tmp/kangyishuai-dsh-session-rewind-*.tgz
|
|
|
79
80
|
| `rescues/<session>/<rescueId>/` | 还原前的救援点 |
|
|
80
81
|
| `audit.jsonl` | 每次回溯一行(只追加) |
|
|
81
82
|
|
|
82
|
-
|
|
83
|
+
启动时会自动清理已删除会话的残留数据(entries/trash/救援点);审计日志保留。保留策略:每会话最近 20 个消息锚点、最近 5 份救援点、最近 20 次回收站操作(30 天)、blobs/trash/rescues 合计 1 GiB 软上限。超限时按「旧回收站 → 旧救援点 → 无引用 blob → 最旧锚点」清理;仍超限则停止新捕获并在诊断里告警(不删有引用的数据)。卸载保留数据。数据根可用插件配置 `dataRoot` 覆盖。
|
|
83
84
|
|
|
84
85
|
## 副作用与风险
|
|
85
86
|
|
|
@@ -94,7 +95,7 @@ dsh plugin --profile desktop add /tmp/kangyishuai-dsh-session-rewind-*.tgz
|
|
|
94
95
|
|
|
95
96
|
| DSH 版本 | 状态 |
|
|
96
97
|
| ---------- | ------------------------------------------------------------------------------------------------- |
|
|
97
|
-
| 0.2.2-rc.1 | 已验证:`pnpm smoke:session-rewind` 31/31 +
|
|
98
|
+
| 0.2.2-rc.1 | 已验证:`pnpm smoke:session-rewind` 31/31 + 158 个单测 + CDP 驱动的桌面版实测(仅对话与文件模式) |
|
|
98
99
|
|
|
99
100
|
插件不声明 `engines.dsh`,也不声明 `@deepseek-ai/*` 的 peer 范围:DSH 还在预发布阶段,预发布版本的 semver 范围不可靠(见 [ADR 0005](../../docs/adr/0005-dsh-version-compatibility.md))。以这张表为准。DSH 升级后跑 `pnpm probe:runtime`、`pnpm smoke:session-rewind`,并看插件诊断页签;私有 seam 变了会降级而不是让会话出错。
|
|
100
101
|
|
|
@@ -103,7 +104,8 @@ dsh plugin --profile desktop add /tmp/kangyishuai-dsh-session-rewind-*.tgz
|
|
|
103
104
|
```bash
|
|
104
105
|
pnpm test # 单测(宿主文件引擎、宿主 RPC、客户端 helper)
|
|
105
106
|
pnpm verify # 仓库统一门禁
|
|
106
|
-
pnpm smoke:session-rewind
|
|
107
|
+
pnpm smoke:session-rewind # 临时 DSH 目录 + 真实 GUI 端到端(需要 dsh 与 ego-browser)
|
|
108
|
+
pnpm smoke:session-rewind-desktop # 只读桌面回归(需要以 --remote-debugging-port=9222 重启)
|
|
107
109
|
```
|
|
108
110
|
|
|
109
111
|
设计记录:[ADR 0006](../../docs/adr/0006-session-rewind-architecture.md)、[ADR 0007](../../docs/adr/0007-session-rewind-file-restore.md)。
|
|
@@ -0,0 +1,108 @@
|
|
|
1
|
+
// @ts-check
|
|
2
|
+
// 回溯前把运行中的回合停下来:取消(保留 inbox)→ 等 idle → 丢弃尚未投递的 steering。
|
|
3
|
+
// 纯逻辑 + 注入 wait,便于用假 agent 单测;不 import 任何宿主包。
|
|
4
|
+
//
|
|
5
|
+
// DSH 0.2.2-rc.1 的 Agent 形状:
|
|
6
|
+
// - agent.status: "idle" | "running"
|
|
7
|
+
// - agent.cancel(cause, { keepInbox }):keepInbox 时不清空队列
|
|
8
|
+
// - agent.whenIdle(): Promise<void>(回合结束后 resolve)
|
|
9
|
+
// - agent.inbox.nextStep: 待投递的插话消息;agent.inbox.remove(id)
|
|
10
|
+
|
|
11
|
+
/** 默认等待 idle 的超时(毫秒)。 */
|
|
12
|
+
export const DEFAULT_CANCEL_TIMEOUT_MS = 15_000;
|
|
13
|
+
|
|
14
|
+
/**
|
|
15
|
+
* @typedef {object} SettleResult
|
|
16
|
+
* @property {boolean} cancelled 是否发起过取消
|
|
17
|
+
* @property {boolean} idle 是否最终空闲
|
|
18
|
+
* @property {string[]} droppedSteering 丢弃的插话消息 id
|
|
19
|
+
*/
|
|
20
|
+
|
|
21
|
+
/**
|
|
22
|
+
* 等待 agent 空闲;优先用宿主提供的 whenIdle,否则轮询 status。
|
|
23
|
+
* @param {any} agent
|
|
24
|
+
* @param {number} timeoutMs
|
|
25
|
+
* @param {(ms: number) => Promise<void>} sleep
|
|
26
|
+
* @returns {Promise<boolean>}
|
|
27
|
+
*/
|
|
28
|
+
async function waitForIdle(agent, timeoutMs, sleep) {
|
|
29
|
+
const timeout = new Promise((resolve) => {
|
|
30
|
+
void sleep(timeoutMs).then(() => resolve(false));
|
|
31
|
+
});
|
|
32
|
+
if (typeof agent?.whenIdle === "function") {
|
|
33
|
+
try {
|
|
34
|
+
const idle = Promise.resolve(agent.whenIdle()).then(
|
|
35
|
+
() => true,
|
|
36
|
+
() => false,
|
|
37
|
+
);
|
|
38
|
+
return await Promise.race([idle, timeout]);
|
|
39
|
+
} catch (_error) {
|
|
40
|
+
return false;
|
|
41
|
+
}
|
|
42
|
+
}
|
|
43
|
+
const deadline = Date.now() + timeoutMs;
|
|
44
|
+
while (Date.now() < deadline) {
|
|
45
|
+
if (agent?.status === "idle") return true;
|
|
46
|
+
await sleep(100);
|
|
47
|
+
}
|
|
48
|
+
return agent?.status === "idle";
|
|
49
|
+
}
|
|
50
|
+
|
|
51
|
+
/**
|
|
52
|
+
* 丢弃 next-step(尚未投递的插话)消息。
|
|
53
|
+
* @param {any} agent
|
|
54
|
+
* @returns {string[]}
|
|
55
|
+
*/
|
|
56
|
+
function dropPendingSteering(agent) {
|
|
57
|
+
/** @type {string[]} */
|
|
58
|
+
const dropped = [];
|
|
59
|
+
try {
|
|
60
|
+
const pending = agent?.inbox?.nextStep;
|
|
61
|
+
if (!pending || typeof agent?.inbox?.remove !== "function") return dropped;
|
|
62
|
+
for (const message of [...pending]) {
|
|
63
|
+
if (message && typeof message.id === "string" && message.id !== "") {
|
|
64
|
+
try {
|
|
65
|
+
agent.inbox.remove(message.id);
|
|
66
|
+
dropped.push(message.id);
|
|
67
|
+
} catch (_error) {
|
|
68
|
+
/* 单条失败不影响其余 */
|
|
69
|
+
}
|
|
70
|
+
}
|
|
71
|
+
}
|
|
72
|
+
} catch (_error) {
|
|
73
|
+
/* inbox 形状不符时放弃丢弃 */
|
|
74
|
+
}
|
|
75
|
+
return dropped;
|
|
76
|
+
}
|
|
77
|
+
|
|
78
|
+
/**
|
|
79
|
+
* 停下运行中的回合并等待空闲。已经空闲时是 no-op。
|
|
80
|
+
* @param {any} agent
|
|
81
|
+
* @param {{timeoutMs?: number, sleep?: (ms: number) => Promise<void>}} [options]
|
|
82
|
+
* @returns {Promise<SettleResult>}
|
|
83
|
+
*/
|
|
84
|
+
export async function settleAgent(agent, options = {}) {
|
|
85
|
+
if (!agent || agent.status === "idle")
|
|
86
|
+
return { cancelled: false, idle: true, droppedSteering: [] };
|
|
87
|
+
const timeoutMs =
|
|
88
|
+
typeof options.timeoutMs === "number" &&
|
|
89
|
+
Number.isFinite(options.timeoutMs) &&
|
|
90
|
+
options.timeoutMs > 0
|
|
91
|
+
? options.timeoutMs
|
|
92
|
+
: DEFAULT_CANCEL_TIMEOUT_MS;
|
|
93
|
+
const sleep =
|
|
94
|
+
typeof options.sleep === "function"
|
|
95
|
+
? options.sleep
|
|
96
|
+
: (/** @type {number} */ ms) => new Promise((resolve) => setTimeout(resolve, ms));
|
|
97
|
+
try {
|
|
98
|
+
if (typeof agent.cancel === "function") agent.cancel({ kind: "user" }, { keepInbox: true });
|
|
99
|
+
} catch (_error) {
|
|
100
|
+
/* 取消失败时仍尝试等待 idle;超时会交给调用方报错 */
|
|
101
|
+
}
|
|
102
|
+
const idle = await waitForIdle(agent, timeoutMs, sleep);
|
|
103
|
+
return {
|
|
104
|
+
cancelled: true,
|
|
105
|
+
idle,
|
|
106
|
+
droppedSteering: idle ? dropPendingSteering(agent) : [],
|
|
107
|
+
};
|
|
108
|
+
}
|
package/lib/client.js
CHANGED
|
@@ -45,6 +45,7 @@ window.__ModuleLoader__.load({
|
|
|
45
45
|
"dialog.cancel": "取消",
|
|
46
46
|
"dialog.confirm": "开始回退",
|
|
47
47
|
"dialog.executing": "正在回退…",
|
|
48
|
+
"dialog.settling": "正在停止当前回合并回退…",
|
|
48
49
|
"dialog.loading": "正在读取…",
|
|
49
50
|
"dialog.empty": "没有可回溯的消息。",
|
|
50
51
|
"dialog.previewLoading": "正在计算影响面…",
|
|
@@ -65,7 +66,11 @@ window.__ModuleLoader__.load({
|
|
|
65
66
|
"confirm.pathsLess": "收起路径",
|
|
66
67
|
"confirm.skipped": "{n} 个文件被跳过",
|
|
67
68
|
"confirm.conflictsTitle": "冲突文件(前 5 个)",
|
|
68
|
-
"confirm.busy": "
|
|
69
|
+
"confirm.busy": "当前回合仍在运行:回退时会先停止它,队列中未投递的插话会被丢弃",
|
|
70
|
+
"confirm.settling": "正在停止当前回合…",
|
|
71
|
+
"confirm.resend": "回退后立即重发这条消息",
|
|
72
|
+
"confirm.resendHint":
|
|
73
|
+
"适合「重试这一轮」:回退后自动把原文发出去,想改内容就在输入框里编辑后再发。",
|
|
69
74
|
"confirm.openToolCall": "目标之后有未完成的工具调用,回溯会截断它",
|
|
70
75
|
"toast.done": "已回退到此处,原消息已放回输入框",
|
|
71
76
|
"toast.draft": "已回退;输入框已有内容,未覆盖",
|
|
@@ -73,6 +78,8 @@ window.__ModuleLoader__.load({
|
|
|
73
78
|
"toast.rewound": "已回退 {n} 条消息(约 {tokens} token)",
|
|
74
79
|
"toast.rewoundFiles": ",还原 {restored} 个文件、移入回收站 {trashed} 个",
|
|
75
80
|
"toast.rewoundFailed": ",{n} 个文件处理失败",
|
|
81
|
+
"toast.resent": ",已重发这条消息",
|
|
82
|
+
"toast.stoppedTurn": ",已停止运行中的回合",
|
|
76
83
|
"toast.draftKept": ";输入框已有内容,未覆盖",
|
|
77
84
|
"toast.noComposerSuffix": ";未找到输入框",
|
|
78
85
|
"toast.view": "查看",
|
|
@@ -166,6 +173,7 @@ window.__ModuleLoader__.load({
|
|
|
166
173
|
"dialog.cancel": "Cancel",
|
|
167
174
|
"dialog.confirm": "Start rewind",
|
|
168
175
|
"dialog.executing": "Rewinding…",
|
|
176
|
+
"dialog.settling": "Stopping the running turn, then rewinding…",
|
|
169
177
|
"dialog.loading": "Loading…",
|
|
170
178
|
"dialog.empty": "No messages to rewind to.",
|
|
171
179
|
"dialog.previewLoading": "Computing the impact…",
|
|
@@ -187,8 +195,15 @@ window.__ModuleLoader__.load({
|
|
|
187
195
|
"confirm.pathsLess": "Hide paths",
|
|
188
196
|
"confirm.skipped": "{n} file(s) skipped",
|
|
189
197
|
"confirm.conflictsTitle": "Conflicting files (first 5)",
|
|
190
|
-
"confirm.busy":
|
|
198
|
+
"confirm.busy":
|
|
199
|
+
"The turn is still running: rewinding stops it first and drops any queued steering messages.",
|
|
200
|
+
"confirm.settling": "Stopping the running turn…",
|
|
201
|
+
"confirm.resend": "Send this message again right after rewinding",
|
|
202
|
+
"confirm.resendHint":
|
|
203
|
+
"Use this to retry a turn; to change the text, edit it in the composer instead.",
|
|
191
204
|
"confirm.openToolCall": "An unfinished tool call follows the target; rewinding truncates it.",
|
|
205
|
+
"toast.stoppedTurn": ", and the running turn was stopped",
|
|
206
|
+
"toast.resent": ", and the message was sent again",
|
|
192
207
|
"toast.done": "Rewound. The original message is back in the composer.",
|
|
193
208
|
"toast.draft": "Rewound. The composer already had content, so it was kept.",
|
|
194
209
|
"toast.noComposer": "Rewound, but no composer was found.",
|
|
@@ -1139,6 +1154,11 @@ window.__ModuleLoader__.load({
|
|
|
1139
1154
|
text: text,
|
|
1140
1155
|
images: imageRefsOf(source),
|
|
1141
1156
|
files: filesReceiptOf(source.files),
|
|
1157
|
+
cancelledTurn: source.cancelledTurn === true,
|
|
1158
|
+
droppedSteering:
|
|
1159
|
+
typeof source.droppedSteering === "number" && source.droppedSteering > 0
|
|
1160
|
+
? source.droppedSteering
|
|
1161
|
+
: 0,
|
|
1142
1162
|
};
|
|
1143
1163
|
}
|
|
1144
1164
|
|
|
@@ -1960,8 +1980,37 @@ window.__ModuleLoader__.load({
|
|
|
1960
1980
|
}
|
|
1961
1981
|
}
|
|
1962
1982
|
|
|
1983
|
+
// 「回退后立即重发」:仅对话模式提供(文件模式先还原文件,语义不同)。
|
|
1984
|
+
if (state.rewindMode !== "both" && state.previewLoading !== true) {
|
|
1985
|
+
blocks.push(
|
|
1986
|
+
h(
|
|
1987
|
+
"div",
|
|
1988
|
+
{ key: "resend", className: "dsh-srw-setting" },
|
|
1989
|
+
h(
|
|
1990
|
+
"label",
|
|
1991
|
+
{ className: "dsh-srw-check" },
|
|
1992
|
+
h("input", {
|
|
1993
|
+
type: "checkbox",
|
|
1994
|
+
"data-rewind-resend": "true",
|
|
1995
|
+
checked: state.resend === true,
|
|
1996
|
+
onChange: (/** @type {any} */ event) => {
|
|
1997
|
+
if (event?.target) api.setResend(event.target.checked === true);
|
|
1998
|
+
},
|
|
1999
|
+
}),
|
|
2000
|
+
h("span", null, t("confirm.resend")),
|
|
2001
|
+
),
|
|
2002
|
+
h("div", { className: "dsh-srw-option-hint" }, t("confirm.resendHint")),
|
|
2003
|
+
),
|
|
2004
|
+
);
|
|
2005
|
+
}
|
|
1963
2006
|
if (state.busy) {
|
|
1964
|
-
blocks.push(
|
|
2007
|
+
blocks.push(
|
|
2008
|
+
h(
|
|
2009
|
+
"div",
|
|
2010
|
+
{ key: "busy", className: "dsh-srw-warn" },
|
|
2011
|
+
state.executing === true ? t("confirm.settling") : t("confirm.busy"),
|
|
2012
|
+
),
|
|
2013
|
+
);
|
|
1965
2014
|
}
|
|
1966
2015
|
if (state.errorKey) {
|
|
1967
2016
|
blocks.push(
|
|
@@ -2077,8 +2126,8 @@ window.__ModuleLoader__.load({
|
|
|
2077
2126
|
|
|
2078
2127
|
let footer;
|
|
2079
2128
|
if (confirmMode) {
|
|
2080
|
-
|
|
2081
|
-
|
|
2129
|
+
// v2.1:运行中的回合不再阻止执行——宿主会先取消并等到 idle 再回溯。
|
|
2130
|
+
let disabled = state.executing === true || state.previewLoading === true;
|
|
2082
2131
|
let executing = state.executing === true || state.loading === true;
|
|
2083
2132
|
footer = h(
|
|
2084
2133
|
Fragment,
|
|
@@ -2091,7 +2140,11 @@ window.__ModuleLoader__.load({
|
|
|
2091
2140
|
disabled: disabled,
|
|
2092
2141
|
onClick: () => api.doRewind(),
|
|
2093
2142
|
},
|
|
2094
|
-
executing
|
|
2143
|
+
executing
|
|
2144
|
+
? state.busy === true
|
|
2145
|
+
? t("dialog.settling")
|
|
2146
|
+
: t("dialog.executing")
|
|
2147
|
+
: t("dialog.confirm"),
|
|
2095
2148
|
),
|
|
2096
2149
|
);
|
|
2097
2150
|
} else {
|
|
@@ -2720,7 +2773,7 @@ window.__ModuleLoader__.load({
|
|
|
2720
2773
|
} else if (state.mode === "confirm" && event.key === "Enter") {
|
|
2721
2774
|
event.preventDefault();
|
|
2722
2775
|
event.stopPropagation();
|
|
2723
|
-
if (!state.
|
|
2776
|
+
if (!state.executing && !state.previewLoading) api.doRewind();
|
|
2724
2777
|
}
|
|
2725
2778
|
return;
|
|
2726
2779
|
}
|
|
@@ -3059,6 +3112,7 @@ window.__ModuleLoader__.load({
|
|
|
3059
3112
|
previewErrorParams: null,
|
|
3060
3113
|
rewindMode: "chat",
|
|
3061
3114
|
showAllPaths: false,
|
|
3115
|
+
resend: false,
|
|
3062
3116
|
busy: false,
|
|
3063
3117
|
executing: false,
|
|
3064
3118
|
loading: false,
|
|
@@ -3182,6 +3236,32 @@ window.__ModuleLoader__.load({
|
|
|
3182
3236
|
}
|
|
3183
3237
|
|
|
3184
3238
|
/** 回填输入框;返回 'ok' | 'draft' | 'none'。 */
|
|
3239
|
+
/** 复用刚回填的 draft 直接提交(「回退后立即重发」)。 @param {any} sessionId */
|
|
3240
|
+
function submitComposer(/** @type {any} */ sessionId) {
|
|
3241
|
+
try {
|
|
3242
|
+
let conversation = safeGet(ctx, "conversation");
|
|
3243
|
+
let scope =
|
|
3244
|
+
ctx.sessions && typeof ctx.sessions.scope === "function"
|
|
3245
|
+
? ctx.sessions.scope(sessionId)
|
|
3246
|
+
: undefined;
|
|
3247
|
+
if (!conversation?.input || typeof conversation.input.for !== "function") return false;
|
|
3248
|
+
if (scope === undefined) return false;
|
|
3249
|
+
let facade = conversation.input.for(scope);
|
|
3250
|
+
if (!facade) return false;
|
|
3251
|
+
if (facade.actions && typeof facade.actions.submit === "function") {
|
|
3252
|
+
facade.actions.submit();
|
|
3253
|
+
return true;
|
|
3254
|
+
}
|
|
3255
|
+
if (typeof facade.submit === "function") {
|
|
3256
|
+
facade.submit("queue");
|
|
3257
|
+
return true;
|
|
3258
|
+
}
|
|
3259
|
+
return false;
|
|
3260
|
+
} catch (_error) {
|
|
3261
|
+
return false;
|
|
3262
|
+
}
|
|
3263
|
+
}
|
|
3264
|
+
|
|
3185
3265
|
function fillComposer(/** @type {any} */ sessionId, /** @type {any} */ text) {
|
|
3186
3266
|
let facade;
|
|
3187
3267
|
try {
|
|
@@ -3298,6 +3378,7 @@ window.__ModuleLoader__.load({
|
|
|
3298
3378
|
previewLoading: false,
|
|
3299
3379
|
previewErrorKey: null,
|
|
3300
3380
|
previewErrorParams: null,
|
|
3381
|
+
resend: false,
|
|
3301
3382
|
busy: false,
|
|
3302
3383
|
executing: false,
|
|
3303
3384
|
loading: false,
|
|
@@ -3393,6 +3474,7 @@ window.__ModuleLoader__.load({
|
|
|
3393
3474
|
previewErrorParams: null,
|
|
3394
3475
|
rewindMode: effectiveMode(prefs.defaultMode, null),
|
|
3395
3476
|
showAllPaths: false,
|
|
3477
|
+
resend: false,
|
|
3396
3478
|
busy: busy,
|
|
3397
3479
|
executing: false,
|
|
3398
3480
|
loading: false,
|
|
@@ -3435,6 +3517,11 @@ window.__ModuleLoader__.load({
|
|
|
3435
3517
|
}
|
|
3436
3518
|
|
|
3437
3519
|
/** 确认框里切换回退范围。 */
|
|
3520
|
+
/** 「回退后立即重发」勾选。 @param {boolean} value */
|
|
3521
|
+
function setResend(/** @type {any} */ value) {
|
|
3522
|
+
patch({ resend: value === true });
|
|
3523
|
+
}
|
|
3524
|
+
|
|
3438
3525
|
function setRewindMode(/** @type {any} */ mode) {
|
|
3439
3526
|
patch({ rewindMode: mode === "both" ? "both" : "chat" });
|
|
3440
3527
|
}
|
|
@@ -3461,6 +3548,7 @@ window.__ModuleLoader__.load({
|
|
|
3461
3548
|
previewLoading: false,
|
|
3462
3549
|
previewErrorKey: null,
|
|
3463
3550
|
previewErrorParams: null,
|
|
3551
|
+
resend: false,
|
|
3464
3552
|
busy: false,
|
|
3465
3553
|
executing: false,
|
|
3466
3554
|
loading: false,
|
|
@@ -3479,6 +3567,7 @@ window.__ModuleLoader__.load({
|
|
|
3479
3567
|
previewLoading: false,
|
|
3480
3568
|
previewErrorKey: null,
|
|
3481
3569
|
previewErrorParams: null,
|
|
3570
|
+
resend: false,
|
|
3482
3571
|
busy: false,
|
|
3483
3572
|
executing: false,
|
|
3484
3573
|
loading: false,
|
|
@@ -3531,6 +3620,8 @@ window.__ModuleLoader__.load({
|
|
|
3531
3620
|
let state = store.getSnapshot();
|
|
3532
3621
|
if (state?.executing) return;
|
|
3533
3622
|
let wasConfirm = state?.mode === "confirm";
|
|
3623
|
+
// 仅对话模式下才有「回退后立即重发」。
|
|
3624
|
+
let resend = wasConfirm && state?.resend === true && mode !== "both";
|
|
3534
3625
|
/** @type {any} */
|
|
3535
3626
|
let busyState = {
|
|
3536
3627
|
origin: origin,
|
|
@@ -3550,7 +3641,7 @@ window.__ModuleLoader__.load({
|
|
|
3550
3641
|
mode: mode === "both" ? "both" : "chat",
|
|
3551
3642
|
})
|
|
3552
3643
|
.then((/** @type {any} */ value) => {
|
|
3553
|
-
handleRewindSuccess(sessionId, target, value);
|
|
3644
|
+
handleRewindSuccess(sessionId, target, value, resend);
|
|
3554
3645
|
})
|
|
3555
3646
|
.catch((/** @type {any} */ error) => {
|
|
3556
3647
|
handleRewindFailure(sessionId, target, origin, wasConfirm, error);
|
|
@@ -3562,6 +3653,7 @@ window.__ModuleLoader__.load({
|
|
|
3562
3653
|
/** @type {any} */ sessionId,
|
|
3563
3654
|
/** @type {any} */ target,
|
|
3564
3655
|
/** @type {any} */ value,
|
|
3656
|
+
/** @type {boolean} */ resend,
|
|
3565
3657
|
) {
|
|
3566
3658
|
let summary = rewindSummaryOf(value);
|
|
3567
3659
|
cutStore.add(sessionId, {
|
|
@@ -3583,6 +3675,12 @@ window.__ModuleLoader__.load({
|
|
|
3583
3675
|
text += t("toast.rewoundFailed", { n: summary.files.failed.length });
|
|
3584
3676
|
}
|
|
3585
3677
|
}
|
|
3678
|
+
if (summary.cancelledTurn) text += t("toast.stoppedTurn");
|
|
3679
|
+
let resent = false;
|
|
3680
|
+
if (resend === true && outcome === "ok") {
|
|
3681
|
+
resent = submitComposer(sessionId);
|
|
3682
|
+
if (resent) text += t("toast.resent");
|
|
3683
|
+
}
|
|
3586
3684
|
if (outcome === "draft") text += t("toast.draftKept");
|
|
3587
3685
|
else if (outcome === "none") text += t("toast.noComposerSuffix");
|
|
3588
3686
|
patch({
|
|
@@ -3593,6 +3691,7 @@ window.__ModuleLoader__.load({
|
|
|
3593
3691
|
previewLoading: false,
|
|
3594
3692
|
previewErrorKey: null,
|
|
3595
3693
|
previewErrorParams: null,
|
|
3694
|
+
resend: false,
|
|
3596
3695
|
busy: false,
|
|
3597
3696
|
executing: false,
|
|
3598
3697
|
loading: false,
|
|
@@ -3655,8 +3754,8 @@ window.__ModuleLoader__.load({
|
|
|
3655
3754
|
function doRewind() {
|
|
3656
3755
|
let state = store.getSnapshot();
|
|
3657
3756
|
if (state?.mode !== "confirm" || !state.target) return;
|
|
3658
|
-
|
|
3659
|
-
|
|
3757
|
+
// 运行中的回合不再由客户端拒绝:宿主会先取消并等到 idle 再回溯(v2.1)。
|
|
3758
|
+
if (state.executing === true || state.previewLoading === true) return;
|
|
3660
3759
|
executeRewind(state.sessionId, state.target, state.rewindMode, state.origin || "row");
|
|
3661
3760
|
}
|
|
3662
3761
|
|
|
@@ -4069,6 +4168,7 @@ window.__ModuleLoader__.load({
|
|
|
4069
4168
|
pick: pick,
|
|
4070
4169
|
doRewind: doRewind,
|
|
4071
4170
|
setRewindMode: setRewindMode,
|
|
4171
|
+
setResend: setResend,
|
|
4072
4172
|
togglePathList: togglePathList,
|
|
4073
4173
|
setListSearch: setListSearch,
|
|
4074
4174
|
loadOlderCandidates: loadOlderCandidates,
|
package/lib/operations.js
CHANGED
|
@@ -124,12 +124,16 @@ function surfaceNodesOf(session) {
|
|
|
124
124
|
*
|
|
125
125
|
* - not-found:没有 live session(agent 也没有);
|
|
126
126
|
* - inert:子代理会话(`header.origin === "subagent"` 或 `delegationDepth > 0`);
|
|
127
|
-
* - busy:agent 存在且 `status !== "idle"
|
|
127
|
+
* - busy:agent 存在且 `status !== "idle"`,且调用方不允许运行中的会话。
|
|
128
|
+
*
|
|
129
|
+
* 只读操作(候选列表、预览、幽灵历史)传 `allowRunning: true`:它们不改日志,
|
|
130
|
+
* 运行中也能安全求值;真正写日志的 rewind 会先停下回合再重新求值。
|
|
128
131
|
* @param {ServiceDeps} deps
|
|
129
132
|
* @param {any} sessionId
|
|
133
|
+
* @param {{allowRunning?: boolean}} [options]
|
|
130
134
|
* @returns {ResolvedSession}
|
|
131
135
|
*/
|
|
132
|
-
export function resolveSession(deps, sessionId) {
|
|
136
|
+
export function resolveSession(deps, sessionId, options = {}) {
|
|
133
137
|
if (deps.available !== true) {
|
|
134
138
|
throw new RewindRequestError(
|
|
135
139
|
ERROR_CODES.failed,
|
|
@@ -158,7 +162,12 @@ export function resolveSession(deps, sessionId) {
|
|
|
158
162
|
`session "${sessionId}" is a subagent session and cannot be rewound`,
|
|
159
163
|
);
|
|
160
164
|
}
|
|
161
|
-
if (
|
|
165
|
+
if (
|
|
166
|
+
options.allowRunning !== true &&
|
|
167
|
+
agent !== undefined &&
|
|
168
|
+
agent !== null &&
|
|
169
|
+
agent.status !== "idle"
|
|
170
|
+
) {
|
|
162
171
|
throw new RewindRequestError(
|
|
163
172
|
ERROR_CODES.busy,
|
|
164
173
|
`session "${sessionId}" is not idle; stop the running turn before rewinding`,
|
|
@@ -218,7 +227,8 @@ function throwPlanError(planError) {
|
|
|
218
227
|
* @returns {{sessionId: string, candidates: any[], nextCursor: number | null}}
|
|
219
228
|
*/
|
|
220
229
|
export function candidatesResponse(deps, request) {
|
|
221
|
-
|
|
230
|
+
// 只读:运行中的会话也能列候选(客户端据此让用户选择「停止并回退」)。
|
|
231
|
+
const { session, sessionId } = resolveSession(deps, request?.sessionId, { allowRunning: true });
|
|
222
232
|
const page = listCandidatesPage(snapshotEventsOf(session), surfaceNodesOf(session), {
|
|
223
233
|
limit: request?.limit,
|
|
224
234
|
cursor: request?.cursor,
|
|
@@ -250,6 +260,8 @@ export function candidatesResponse(deps, request) {
|
|
|
250
260
|
* @returns {{targetSeq: number, markerSeq: number, removedCount: number, text: string, target: {text: string, images: any[]}}}
|
|
251
261
|
*/
|
|
252
262
|
export function rewindResponse(deps, request) {
|
|
263
|
+
// 严格模式:这个 helper 代表「append 时会话必须已 idle」。生产路径
|
|
264
|
+
// (SessionRewindService#executeRewind)会先停下运行中的回合再走到这里。
|
|
253
265
|
const { session, sessionId } = resolveSession(deps, request?.sessionId);
|
|
254
266
|
const targetSeq = request?.targetSeq;
|
|
255
267
|
if (!isEventSeq(targetSeq)) {
|
|
@@ -336,8 +348,8 @@ export function appendRewindMarker(session, plan, deps, extra = {}) {
|
|
|
336
348
|
* @param {any} request `{ sessionId, targetSeq }`
|
|
337
349
|
* @returns {{session: any, sessionId: string, plan: import("./plan.js").RewindPlan, targetEvent: any}}
|
|
338
350
|
*/
|
|
339
|
-
export function resolveRewindTarget(deps, request) {
|
|
340
|
-
const { session, sessionId } = resolveSession(deps, request?.sessionId);
|
|
351
|
+
export function resolveRewindTarget(deps, request, options = {}) {
|
|
352
|
+
const { session, sessionId } = resolveSession(deps, request?.sessionId, options);
|
|
341
353
|
const targetSeq = request?.targetSeq;
|
|
342
354
|
if (!isEventSeq(targetSeq)) {
|
|
343
355
|
throw new RewindRequestError(
|
|
@@ -358,6 +370,6 @@ export function resolveRewindTarget(deps, request) {
|
|
|
358
370
|
* @returns {{session: any, events: any[]}}
|
|
359
371
|
*/
|
|
360
372
|
export function sessionEvents(deps, sessionId) {
|
|
361
|
-
const { session } = resolveSession(deps, sessionId);
|
|
373
|
+
const { session } = resolveSession(deps, sessionId, { allowRunning: true });
|
|
362
374
|
return { session, events: snapshotEventsOf(session) };
|
|
363
375
|
}
|
package/lib/orphans.js
ADDED
|
@@ -0,0 +1,141 @@
|
|
|
1
|
+
// @ts-check
|
|
2
|
+
// 孤儿数据清理:会话被删除后,`<DSH_HOME>/session-rewind/` 里可能还留着它的
|
|
3
|
+
// entries / trash / rescues。本模块只做“找出并删除这些残留”,不碰仍然存在的会话,
|
|
4
|
+
// 也不碰 audit.jsonl(审计是 append-only 的历史记录)。
|
|
5
|
+
//
|
|
6
|
+
// 安全性:
|
|
7
|
+
// - 只扫描 `pluginHome` 下的 sessions/ trash/ rescues/ 三个目录;
|
|
8
|
+
// - 会话 id 必须匹配安全字符集,避免路径穿越;
|
|
9
|
+
// - 只有「在 <dataRoot>/sessions/<workspace>/<sessionId> 里找不到」的 id 才算孤儿;
|
|
10
|
+
// - 删除前可以 dryRun 只统计;删除失败逐条记录,不中断其余清理。
|
|
11
|
+
import { readdir, rm, stat } from "node:fs/promises";
|
|
12
|
+
import { join } from "node:path";
|
|
13
|
+
|
|
14
|
+
/** 目录名安全字符集。 */
|
|
15
|
+
const SAFE_ID = /^[A-Za-z0-9][A-Za-z0-9._-]*$/u;
|
|
16
|
+
|
|
17
|
+
/**
|
|
18
|
+
* @typedef {object} OrphanReport
|
|
19
|
+
* @property {string[]} sessionIds 插件 data 里存在、但会话已不存在的 id
|
|
20
|
+
* @property {string[]} trashIds 孤儿 trash 目录对应的 id
|
|
21
|
+
* @property {string[]} rescueIds 孤儿 rescue 目录对应的 id
|
|
22
|
+
* @property {number} bytes 估算占用字节数
|
|
23
|
+
*/
|
|
24
|
+
|
|
25
|
+
/**
|
|
26
|
+
* 读取目录下的子目录名;不存在时返回空数组。
|
|
27
|
+
* @param {string} dir
|
|
28
|
+
* @param {any} [fs]
|
|
29
|
+
* @returns {Promise<string[]>}
|
|
30
|
+
*/
|
|
31
|
+
async function listDirs(dir, fs = { readdir }) {
|
|
32
|
+
let entries;
|
|
33
|
+
try {
|
|
34
|
+
entries = await fs.readdir(dir, { withFileTypes: true });
|
|
35
|
+
} catch (_error) {
|
|
36
|
+
return [];
|
|
37
|
+
}
|
|
38
|
+
return entries
|
|
39
|
+
.filter((/** @type {any} */ entry) => entry.isDirectory() && SAFE_ID.test(entry.name))
|
|
40
|
+
.map((/** @type {any} */ entry) => entry.name)
|
|
41
|
+
.sort();
|
|
42
|
+
}
|
|
43
|
+
|
|
44
|
+
/**
|
|
45
|
+
* 递归计算目录字节数(估算)。
|
|
46
|
+
* @param {string} target
|
|
47
|
+
* @param {any} [fs]
|
|
48
|
+
* @returns {Promise<number>}
|
|
49
|
+
*/
|
|
50
|
+
async function dirBytes(target, fs = { readdir, stat }) {
|
|
51
|
+
let info;
|
|
52
|
+
try {
|
|
53
|
+
info = await fs.stat(target);
|
|
54
|
+
} catch (_error) {
|
|
55
|
+
return 0;
|
|
56
|
+
}
|
|
57
|
+
if (!info.isDirectory()) return info.size;
|
|
58
|
+
let total = 0;
|
|
59
|
+
let entries;
|
|
60
|
+
try {
|
|
61
|
+
entries = await fs.readdir(target);
|
|
62
|
+
} catch (_error) {
|
|
63
|
+
return 0;
|
|
64
|
+
}
|
|
65
|
+
for (const entry of entries)
|
|
66
|
+
total += await dirBytes(join(target, /** @type {string} */ (entry)), fs);
|
|
67
|
+
return total;
|
|
68
|
+
}
|
|
69
|
+
|
|
70
|
+
/**
|
|
71
|
+
* 找出插件数据里已经没有对应会话的 id。
|
|
72
|
+
* @param {{dataRoot: string, pluginHome?: string, fs?: any}} input
|
|
73
|
+
* @returns {Promise<OrphanReport>}
|
|
74
|
+
*/
|
|
75
|
+
export async function findOrphans(input) {
|
|
76
|
+
/** @type {any} */
|
|
77
|
+
const fs = input.fs ?? { readdir, rm, stat };
|
|
78
|
+
const pluginHome = input.pluginHome ?? join(input.dataRoot, "session-rewind");
|
|
79
|
+
const existing = new Set();
|
|
80
|
+
const workspaceRoot = join(input.dataRoot, "sessions");
|
|
81
|
+
for (const workspace of await listDirs(workspaceRoot, fs)) {
|
|
82
|
+
for (const sessionId of await listDirs(join(workspaceRoot, workspace), fs)) {
|
|
83
|
+
existing.add(sessionId);
|
|
84
|
+
}
|
|
85
|
+
}
|
|
86
|
+
const sessionIds = [];
|
|
87
|
+
const trashIds = [];
|
|
88
|
+
const rescueIds = [];
|
|
89
|
+
let bytes = 0;
|
|
90
|
+
for (const id of await listDirs(join(pluginHome, "sessions"), fs)) {
|
|
91
|
+
if (existing.has(id)) continue;
|
|
92
|
+
sessionIds.push(id);
|
|
93
|
+
bytes += await dirBytes(join(pluginHome, "sessions", id), fs);
|
|
94
|
+
}
|
|
95
|
+
for (const id of await listDirs(join(pluginHome, "trash"), fs)) {
|
|
96
|
+
if (existing.has(id)) continue;
|
|
97
|
+
trashIds.push(id);
|
|
98
|
+
bytes += await dirBytes(join(pluginHome, "trash", id), fs);
|
|
99
|
+
}
|
|
100
|
+
for (const id of await listDirs(join(pluginHome, "rescues"), fs)) {
|
|
101
|
+
if (existing.has(id)) continue;
|
|
102
|
+
rescueIds.push(id);
|
|
103
|
+
bytes += await dirBytes(join(pluginHome, "rescues", id), fs);
|
|
104
|
+
}
|
|
105
|
+
return { sessionIds, trashIds, rescueIds, bytes };
|
|
106
|
+
}
|
|
107
|
+
|
|
108
|
+
/**
|
|
109
|
+
* 删除孤儿数据。`dryRun` 只返回报告、不做任何删除。
|
|
110
|
+
* @param {{dataRoot: string, pluginHome?: string, dryRun?: boolean, fs?: any}} input
|
|
111
|
+
* @returns {Promise<{report: OrphanReport, removed: {sessions: string[], trash: string[], rescues: string[]}, failed: Array<{target: string, error: string}>}>}
|
|
112
|
+
*/
|
|
113
|
+
export async function pruneOrphans(input) {
|
|
114
|
+
/** @type {any} */
|
|
115
|
+
const fs = input.fs ?? { readdir, rm, stat };
|
|
116
|
+
const pluginHome = input.pluginHome ?? join(input.dataRoot, "session-rewind");
|
|
117
|
+
const report = await findOrphans({ ...input, pluginHome });
|
|
118
|
+
/** @type {{sessions: string[], trash: string[], rescues: string[]}} */
|
|
119
|
+
const removed = { sessions: [], trash: [], rescues: [] };
|
|
120
|
+
/** @type {Array<{target: string, error: string}>} */
|
|
121
|
+
const failed = [];
|
|
122
|
+
if (input.dryRun === true) return { report, removed, failed };
|
|
123
|
+
/** @type {Array<[string, string[], string[]]>} */
|
|
124
|
+
const plan = [
|
|
125
|
+
[join(pluginHome, "sessions"), report.sessionIds, removed.sessions],
|
|
126
|
+
[join(pluginHome, "trash"), report.trashIds, removed.trash],
|
|
127
|
+
[join(pluginHome, "rescues"), report.rescueIds, removed.rescues],
|
|
128
|
+
];
|
|
129
|
+
for (const [root, ids, sink] of plan) {
|
|
130
|
+
for (const id of ids) {
|
|
131
|
+
const target = join(root, id);
|
|
132
|
+
try {
|
|
133
|
+
await fs.rm(target, { recursive: true, force: true });
|
|
134
|
+
sink.push(id);
|
|
135
|
+
} catch (error) {
|
|
136
|
+
failed.push({ target, error: error instanceof Error ? error.message : String(error) });
|
|
137
|
+
}
|
|
138
|
+
}
|
|
139
|
+
}
|
|
140
|
+
return { report, removed, failed };
|
|
141
|
+
}
|
package/lib/service.js
CHANGED
|
@@ -14,6 +14,7 @@ import { createUserMessage } from "@deepseek-ai/dsh-llm";
|
|
|
14
14
|
import { deriveEventMessage, isAppendSurfaceEvent } from "@deepseek-ai/dsh-session/surface";
|
|
15
15
|
import { estimateMessage } from "@deepseek-ai/dsh-token-meter/estimate";
|
|
16
16
|
import { Remote, RemoteError, TypertRemoteService } from "@deepseek-ai/dsh-typert-protocol";
|
|
17
|
+
import { settleAgent } from "./agent-settle.js";
|
|
17
18
|
import { installFileTracking } from "./file-tracking.js";
|
|
18
19
|
import { createFileStore } from "./files.js";
|
|
19
20
|
import { collectGhost } from "./ghost.js";
|
|
@@ -23,8 +24,10 @@ import {
|
|
|
23
24
|
ERROR_CODES,
|
|
24
25
|
RewindRequestError,
|
|
25
26
|
resolveRewindTarget,
|
|
27
|
+
resolveSession,
|
|
26
28
|
sessionEvents,
|
|
27
29
|
} from "./operations.js";
|
|
30
|
+
import { findOrphans, pruneOrphans } from "./orphans.js";
|
|
28
31
|
import { cutsOf, hasOpenToolCalls, listCandidatesPage, targetContent } from "./plan.js";
|
|
29
32
|
|
|
30
33
|
/**
|
|
@@ -159,6 +162,8 @@ export class SessionRewindService extends TypertRemoteService {
|
|
|
159
162
|
this._store = null;
|
|
160
163
|
this.installFileTracking();
|
|
161
164
|
this.installCommand();
|
|
165
|
+
// 启动时清一次已删除会话的残留数据(失败只告警,不阻塞插件)。
|
|
166
|
+
if (this.config.cleanupOrphans !== false) void this.cleanupOrphans();
|
|
162
167
|
}
|
|
163
168
|
|
|
164
169
|
/** 惰性创建文件引擎:数据根/配置在服务挂载后解析。 @returns {any} */
|
|
@@ -186,6 +191,44 @@ export class SessionRewindService extends TypertRemoteService {
|
|
|
186
191
|
return this._store;
|
|
187
192
|
}
|
|
188
193
|
|
|
194
|
+
/**
|
|
195
|
+
* 删除已不存在会话的插件数据(entries/trash/rescues)。audit.jsonl 保留。
|
|
196
|
+
* @returns {Promise<void>}
|
|
197
|
+
*/
|
|
198
|
+
async cleanupOrphans() {
|
|
199
|
+
try {
|
|
200
|
+
const result = await pruneOrphans({ dataRoot: this.dataRoot, dryRun: false });
|
|
201
|
+
const removed =
|
|
202
|
+
result.removed.sessions.length +
|
|
203
|
+
result.removed.trash.length +
|
|
204
|
+
result.removed.rescues.length;
|
|
205
|
+
if (removed > 0) {
|
|
206
|
+
this.ctx?.logger?.info?.(
|
|
207
|
+
`session-rewind: cleaned ${removed} orphan data dir(s) (${result.report.bytes} bytes)`,
|
|
208
|
+
);
|
|
209
|
+
try {
|
|
210
|
+
await this.store.appendAudit({
|
|
211
|
+
id: `cleanup-${Date.now()}`,
|
|
212
|
+
action: "cleanup-orphans",
|
|
213
|
+
time: Date.now(),
|
|
214
|
+
sessionId: null,
|
|
215
|
+
sessions: result.removed.sessions,
|
|
216
|
+
trash: result.removed.trash,
|
|
217
|
+
rescues: result.removed.rescues,
|
|
218
|
+
bytes: result.report.bytes,
|
|
219
|
+
failed: result.failed,
|
|
220
|
+
});
|
|
221
|
+
} catch (_error) {
|
|
222
|
+
/* 审计失败不影响清理 */
|
|
223
|
+
}
|
|
224
|
+
}
|
|
225
|
+
} catch (error) {
|
|
226
|
+
this.ctx?.logger?.warn?.(
|
|
227
|
+
`session-rewind: orphan cleanup failed: ${error instanceof Error ? error.message : String(error)}`,
|
|
228
|
+
);
|
|
229
|
+
}
|
|
230
|
+
}
|
|
231
|
+
|
|
189
232
|
/** 文件追踪是否开启。 @returns {boolean} */
|
|
190
233
|
trackingEnabled() {
|
|
191
234
|
return this.config.fileTracking !== false;
|
|
@@ -362,7 +405,9 @@ export class SessionRewindService extends TypertRemoteService {
|
|
|
362
405
|
async preview(request) {
|
|
363
406
|
try {
|
|
364
407
|
const deps = this.deps();
|
|
365
|
-
const { session, sessionId, plan, targetEvent } = resolveRewindTarget(deps, request
|
|
408
|
+
const { session, sessionId, plan, targetEvent } = resolveRewindTarget(deps, request, {
|
|
409
|
+
allowRunning: true,
|
|
410
|
+
});
|
|
366
411
|
const events = session.snapshotEvents();
|
|
367
412
|
const tokens = this.estimateTokens(events, plan.shadowedSeqs);
|
|
368
413
|
const target = targetContent(targetEvent);
|
|
@@ -463,15 +508,36 @@ export class SessionRewindService extends TypertRemoteService {
|
|
|
463
508
|
*/
|
|
464
509
|
async executeRewind(input) {
|
|
465
510
|
const deps = this.deps();
|
|
466
|
-
|
|
467
|
-
|
|
468
|
-
const
|
|
511
|
+
// 先按「允许运行中」解析,拿到 agent;运行中的回合必须先停下,否则 surface 会在
|
|
512
|
+
// 我们求值与 append 之间继续增长,marker 的 replace 范围就失效了。
|
|
513
|
+
const pre = resolveSession(deps, input.sessionId, { allowRunning: true });
|
|
514
|
+
const agent = pre.agent;
|
|
515
|
+
/** @type {{cancelledTurn: boolean, droppedSteering: number}} */
|
|
516
|
+
const settle = { cancelledTurn: false, droppedSteering: 0 };
|
|
469
517
|
if (agent !== undefined && agent !== null && agent.status !== "idle") {
|
|
470
|
-
|
|
471
|
-
|
|
472
|
-
|
|
473
|
-
|
|
518
|
+
if (this.config.cancelRunning === false) {
|
|
519
|
+
throw new RewindRequestError(
|
|
520
|
+
ERROR_CODES.busy,
|
|
521
|
+
`session "${input.sessionId}" is not idle; stop the running turn before rewinding`,
|
|
522
|
+
);
|
|
523
|
+
}
|
|
524
|
+
const outcome = await settleAgent(agent, {
|
|
525
|
+
...(typeof this.config.cancelTimeoutMs === "number"
|
|
526
|
+
? { timeoutMs: this.config.cancelTimeoutMs }
|
|
527
|
+
: {}),
|
|
528
|
+
});
|
|
529
|
+
settle.cancelledTurn = outcome.cancelled;
|
|
530
|
+
settle.droppedSteering = outcome.droppedSteering.length;
|
|
531
|
+
if (!outcome.idle) {
|
|
532
|
+
throw new RewindRequestError(
|
|
533
|
+
ERROR_CODES.busy,
|
|
534
|
+
`session "${input.sessionId}" did not become idle after cancelling the running turn`,
|
|
535
|
+
);
|
|
536
|
+
}
|
|
474
537
|
}
|
|
538
|
+
// 停稳之后重新求值:取消前的快照可能已经被这一轮追加的事件改变。
|
|
539
|
+
const resolved = resolveRewindTarget(deps, input);
|
|
540
|
+
const { session, sessionId, plan, targetEvent } = resolved;
|
|
475
541
|
if (this.inflight.has(sessionId)) {
|
|
476
542
|
throw new RewindRequestError(
|
|
477
543
|
ERROR_CODES.failed,
|
|
@@ -577,6 +643,9 @@ export class SessionRewindService extends TypertRemoteService {
|
|
|
577
643
|
auditId,
|
|
578
644
|
target: targetContent(targetEvent),
|
|
579
645
|
files,
|
|
646
|
+
// v2.1:执行时是否为了回溯而停下了运行中的回合。
|
|
647
|
+
cancelledTurn: settle.cancelledTurn,
|
|
648
|
+
droppedSteering: settle.droppedSteering,
|
|
580
649
|
};
|
|
581
650
|
try {
|
|
582
651
|
await this.store.appendAudit({
|
|
@@ -590,6 +659,8 @@ export class SessionRewindService extends TypertRemoteService {
|
|
|
590
659
|
tokens,
|
|
591
660
|
files,
|
|
592
661
|
requestedBy: input.requestedBy,
|
|
662
|
+
cancelledTurn: settle.cancelledTurn,
|
|
663
|
+
droppedSteering: settle.droppedSteering,
|
|
593
664
|
});
|
|
594
665
|
} catch (error) {
|
|
595
666
|
this.ctx?.logger?.warn?.(
|
|
@@ -766,6 +837,17 @@ export class SessionRewindService extends TypertRemoteService {
|
|
|
766
837
|
} catch (error) {
|
|
767
838
|
push("审计日志", false, error instanceof Error ? error.message : String(error));
|
|
768
839
|
}
|
|
840
|
+
try {
|
|
841
|
+
const orphans = await findOrphans({ dataRoot: this.dataRoot });
|
|
842
|
+
const count = orphans.sessionIds.length + orphans.trashIds.length + orphans.rescueIds.length;
|
|
843
|
+
push(
|
|
844
|
+
"孤儿数据",
|
|
845
|
+
count === 0,
|
|
846
|
+
count === 0 ? "none" : `${count} dir(s), ${orphans.bytes} bytes`,
|
|
847
|
+
);
|
|
848
|
+
} catch (error) {
|
|
849
|
+
push("孤儿数据", false, error instanceof Error ? error.message : String(error));
|
|
850
|
+
}
|
|
769
851
|
if (typeof request?.sessionId === "string" && request.sessionId !== "") {
|
|
770
852
|
try {
|
|
771
853
|
const { events, session } = sessionEvents(this.deps(), request.sessionId);
|
package/locale/en.json
CHANGED
|
@@ -28,7 +28,7 @@
|
|
|
28
28
|
"confirm.pathsLess": "Hide paths",
|
|
29
29
|
"confirm.skipped": "{n} file(s) skipped",
|
|
30
30
|
"confirm.conflictsTitle": "Conflicting files (first 5)",
|
|
31
|
-
"confirm.busy": "The turn is still running
|
|
31
|
+
"confirm.busy": "The turn is still running: rewinding stops it first and drops any queued steering messages.",
|
|
32
32
|
"confirm.openToolCall": "An unfinished tool call follows the target; rewinding truncates it.",
|
|
33
33
|
"toast.done": "Rewound. The original message is back in the composer.",
|
|
34
34
|
"toast.draft": "Rewound. The composer already had content, so it was kept.",
|
|
@@ -116,6 +116,12 @@
|
|
|
116
116
|
"relative.justNow": "just now",
|
|
117
117
|
"relative.minutes": "{n} min ago",
|
|
118
118
|
"relative.hours": "{n} h ago",
|
|
119
|
-
"relative.days": "{n} d ago"
|
|
119
|
+
"relative.days": "{n} d ago",
|
|
120
|
+
"dialog.settling": "Stopping the running turn, then rewinding…",
|
|
121
|
+
"confirm.settling": "Stopping the running turn…",
|
|
122
|
+
"toast.stoppedTurn": ", and the running turn was stopped",
|
|
123
|
+
"confirm.resend": "Send this message again right after rewinding",
|
|
124
|
+
"confirm.resendHint": "Use this to retry a turn; to change the text, edit it in the composer instead.",
|
|
125
|
+
"toast.resent": ", and the message was sent again"
|
|
120
126
|
}
|
|
121
127
|
}
|
package/locale/zh.json
CHANGED
|
@@ -28,7 +28,7 @@
|
|
|
28
28
|
"confirm.pathsLess": "收起路径",
|
|
29
29
|
"confirm.skipped": "{n} 个文件被跳过",
|
|
30
30
|
"confirm.conflictsTitle": "冲突文件(前 5 个)",
|
|
31
|
-
"confirm.busy": "
|
|
31
|
+
"confirm.busy": "当前回合仍在运行:回退时会先停止它,队列中未投递的插话会被丢弃",
|
|
32
32
|
"confirm.openToolCall": "目标之后有未完成的工具调用,回溯会截断它",
|
|
33
33
|
"toast.done": "已回退到此处,原消息已放回输入框",
|
|
34
34
|
"toast.draft": "已回退;输入框已有内容,未覆盖",
|
|
@@ -116,6 +116,12 @@
|
|
|
116
116
|
"relative.justNow": "刚刚",
|
|
117
117
|
"relative.minutes": "{n} 分钟前",
|
|
118
118
|
"relative.hours": "{n} 小时前",
|
|
119
|
-
"relative.days": "{n} 天前"
|
|
119
|
+
"relative.days": "{n} 天前",
|
|
120
|
+
"dialog.settling": "正在停止当前回合并回退…",
|
|
121
|
+
"confirm.settling": "正在停止当前回合…",
|
|
122
|
+
"toast.stoppedTurn": ",已停止运行中的回合",
|
|
123
|
+
"confirm.resend": "回退后立即重发这条消息",
|
|
124
|
+
"confirm.resendHint": "适合「重试这一轮」:回退后自动把原文发出去,想改内容就在输入框里编辑后再发。",
|
|
125
|
+
"toast.resent": ",已重发这条消息"
|
|
120
126
|
}
|
|
121
127
|
}
|
package/package.json
CHANGED