dsh-tool-wait-until 0.0.0-stage → 1.0.1
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/LICENSE +21 -0
- package/README.md +162 -2
- package/cordis.patch.yml +4 -0
- package/icon.svg +15 -0
- package/index.js +954 -0
- package/locale/en.json +6 -0
- package/locale/zh.json +6 -0
- package/package.json +50 -4
package/LICENSE
ADDED
|
@@ -0,0 +1,21 @@
|
|
|
1
|
+
MIT License
|
|
2
|
+
|
|
3
|
+
Copyright (c) 2026 tongshuanglong
|
|
4
|
+
|
|
5
|
+
Permission is hereby granted, free of charge, to any person obtaining a copy
|
|
6
|
+
of this software and associated documentation files (the "Software"), to deal
|
|
7
|
+
in the Software without restriction, including without limitation the rights
|
|
8
|
+
to use, copy, modify, merge, publish, distribute, sublicense, and/or sell
|
|
9
|
+
copies of the Software, and to permit persons to whom the Software is
|
|
10
|
+
furnished to do so, subject to the following conditions:
|
|
11
|
+
|
|
12
|
+
The above copyright notice and this permission notice shall be included in all
|
|
13
|
+
copies or substantial portions of the Software.
|
|
14
|
+
|
|
15
|
+
THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
|
|
16
|
+
IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
|
|
17
|
+
FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
|
|
18
|
+
AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
|
|
19
|
+
LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
|
|
20
|
+
OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE
|
|
21
|
+
SOFTWARE.
|
package/README.md
CHANGED
|
@@ -1,3 +1,163 @@
|
|
|
1
|
-
#
|
|
1
|
+
# dsh-tool-wait-until
|
|
2
2
|
|
|
3
|
-
|
|
3
|
+
[](https://www.npmjs.com/package/dsh-tool-wait-until)
|
|
4
|
+
[](LICENSE)
|
|
5
|
+
|
|
6
|
+
**Event-driven waiting for [DeepSeek Harness](https://github.com/deepseek-ai/deepseek-harness) (dsh).** It gives the model a `wait_until` tool that returns the moment a background job finishes, a teammate goes idle, a file appears, or a log line matches — and fails fast on nonzero exit codes, error patterns and stalls, instead of burning a full timeout. It also **blocks the model's favorite empty-wait paths**: `sleep`-polling in bash and over-long `job_output` / `wait_agent` waits.
|
|
7
|
+
|
|
8
|
+
```sh
|
|
9
|
+
dsh plugin --profile web add dsh-tool-wait-until
|
|
10
|
+
```
|
|
11
|
+
|
|
12
|
+
Requires dsh 0.2.1 or later (declared as `engines.dsh`); verified against 0.2.1-rc.5. In the desktop app, install it from its plugin manager instead.
|
|
13
|
+
|
|
14
|
+
- **One call, one verdict**: `status` is `completed` / `failed` / `stalled` / `timeout` / `cancelled`, plus `elapsed`, `failure_reason` and `output_tail` — no second query needed.
|
|
15
|
+
- **Event-driven**: job settle / output growth wakes the wait immediately, independent of `poll_interval_sec`.
|
|
16
|
+
- **Stall detection**: no progress for N seconds returns `stalled` instead of waiting out the timeout.
|
|
17
|
+
- **Behavior guard**: foreground `sleep >= 20s` and waits over 120s are rejected with a ready-to-use replacement.
|
|
18
|
+
- **Zero dependencies**, standard JSON Schema, Node 20+.
|
|
19
|
+
|
|
20
|
+
中文说明见下。
|
|
21
|
+
|
|
22
|
+
消除 DSH 里「空等」的 Host 插件。它给模型一个**事件驱动**的等待原语 `wait_until`,并**主动拦截**模型最容易走的空等路径(`sleep` 轮询、超长 `job_output` / `wait_agent`)。
|
|
23
|
+
|
|
24
|
+
## 要解决的问题
|
|
25
|
+
|
|
26
|
+
我们实际踩过的现象:
|
|
27
|
+
|
|
28
|
+
| 现象 | 现有行为 |
|
|
29
|
+
| --- | --- |
|
|
30
|
+
| 等 teammate | `wait_agent(timeout=30min)` 在对方卡死时等满 30 分钟,且只返回一个布尔值,事后还得查 `list_agents` |
|
|
31
|
+
| 等后台脚本 | `job_output(wait:true, timeout=15min)` 在作业卡住时等满上限 |
|
|
32
|
+
| 手工轮询 | `sleep 90` → 查文件 → 再估时间 → 再 sleep,一轮往返 1.5 分钟 |
|
|
33
|
+
| 等待中不可观测 | 只看得到「在等」,看不到中间日志、产出进度,无法判断「在跑」还是「卡住」 |
|
|
34
|
+
| 超时 ≠ 失败 | 超时返回后还要再查一轮才知道是超时、失败还是成功 |
|
|
35
|
+
|
|
36
|
+
关键观察:**光提供一个新工具不够**。模型在思考里会自己决定「等 180 秒再查」,如果行为不变,工具再好也不会被用。所以本插件做三层:
|
|
37
|
+
|
|
38
|
+
1. **决策前给规则**:往系统提示词注入「等待纪律」,要求等待一律用 `wait_until`,不许 `sleep` 空等。
|
|
39
|
+
2. **决策落地时硬拦截**:注册 `tools.guard`,把 `sleep ≥ 20s` 的前台 bash、超过 120s 的 `job_output` / `wait_agent` 等待直接拒绝,并在拒绝原因里给出可复制的 `wait_until` 替代调用。
|
|
40
|
+
3. **替代原语本身**:`wait_until` 工具,条件满足、目标失败或连续无进展时**立即返回结构化结果**。
|
|
41
|
+
|
|
42
|
+
## wait_until 工具
|
|
43
|
+
|
|
44
|
+
```js
|
|
45
|
+
wait_until({
|
|
46
|
+
conditions: [
|
|
47
|
+
{ kind: 'job', id: '<job_id>' }, // 后台作业
|
|
48
|
+
{ kind: 'agent', target: 'worker' }, // 团队成员
|
|
49
|
+
{ kind: 'file', path: '/tmp/out.zip', until: 'exists' },
|
|
50
|
+
{ kind: 'log', path: '/tmp/build.log', matches: '/EXIT=0/' },
|
|
51
|
+
],
|
|
52
|
+
fail_fast: true, // 或 ['job_failed', 'stderr_matches:/error|failed|Traceback/']
|
|
53
|
+
stall_after_sec: 120, // 连续 120s 无任何进展 → 立刻返回 stalled
|
|
54
|
+
timeout_sec: 900, // 总兜底,不是主要判定手段
|
|
55
|
+
poll_interval_sec: 2,
|
|
56
|
+
})
|
|
57
|
+
```
|
|
58
|
+
|
|
59
|
+
返回(一次调用即可决策,无需二次查询):
|
|
60
|
+
|
|
61
|
+
```json
|
|
62
|
+
{
|
|
63
|
+
"status": "failed",
|
|
64
|
+
"elapsed_ms": 1250,
|
|
65
|
+
"elapsed": "1.2s",
|
|
66
|
+
"matched": ["后台作业 bash-xxxx(结束)"],
|
|
67
|
+
"conditions": [{ "condition": "后台作业 bash-xxxx(结束)", "state": "failed", "detail": "状态 failed — exit code: 3" }],
|
|
68
|
+
"failure_reason": "后台作业 bash-xxxx 失败:exit code: 3",
|
|
69
|
+
"output_tail": "BOOM: intentional failure\n",
|
|
70
|
+
"hint": "目标已失败,不必再等:按 output_tail / failure_reason 决策……"
|
|
71
|
+
}
|
|
72
|
+
```
|
|
73
|
+
|
|
74
|
+
`status` 五态:`completed` / `failed` / `stalled` / `timeout` / `cancelled`。**超时不是失败**,单独一态。
|
|
75
|
+
|
|
76
|
+
多个 `conditions` 是 **wait_all** 语义:全部条件都满足才返回 `completed`;`fail_fast` 命中时任一条件失败立即返回 `failed`。(只想要「任一完成就返回」,请把条件拆成多次 `wait_until`。)
|
|
77
|
+
|
|
78
|
+
### 条件语义
|
|
79
|
+
|
|
80
|
+
| kind | 必填 | until | 说明 |
|
|
81
|
+
| --- | --- | --- | --- |
|
|
82
|
+
| `job` | `id` | `settled`(默认)/ `failed` | 作业结束(含失败)或仅失败 |
|
|
83
|
+
| `agent` | — | `inactive`(默认)/ `failed` / `status_changed` | 成员全部空闲 / 任一失败 / 任意状态变化 |
|
|
84
|
+
| `file` | `path` | `exists`(默认)/ `changed` | 文件出现 / 版本变化 |
|
|
85
|
+
| `log` | `path`, `matches` | `matches` | 日志尾部或新增内容命中正则 |
|
|
86
|
+
|
|
87
|
+
### fail_fast 模式
|
|
88
|
+
|
|
89
|
+
- `true`(默认):`job_failed` + `job_killed` + `agent_failed`
|
|
90
|
+
- `false`:关闭
|
|
91
|
+
- 数组:`job_failed` / `job_killed` / `exit_code_nonzero` / `agent_failed` / `stderr_matches:/re/` / `output_matches:/re/`
|
|
92
|
+
|
|
93
|
+
`exit_code_nonzero` 会从作业输出尾部识别 `EXIT=N`(N≠0),因此**进程还没退出、但日志已宣布失败**时也能提前返回。
|
|
94
|
+
|
|
95
|
+
## 事件驱动到什么程度
|
|
96
|
+
|
|
97
|
+
- 作业条件:订阅 `jobs.events`,作业 settle / 输出增长 / progress 时立刻唤醒主循环,不受 `poll_interval_sec` 限制。
|
|
98
|
+
- 文件 / 日志 / 队友:按 `poll_interval_sec` 采样(默认 2s)。
|
|
99
|
+
- 取消:全程响应 `exec.signal`,调用方打断时立即返回 `cancelled`。
|
|
100
|
+
|
|
101
|
+
## 拦截规则(硬约束)
|
|
102
|
+
|
|
103
|
+
| 拦截对象 | 条件 | 拒绝原因 |
|
|
104
|
+
| --- | --- | --- |
|
|
105
|
+
| `bash` | 前台命令里含 `sleep ≥ 20s`(不含 `run_in_background`、不含 `&` 后台) | 给出 5 条 `wait_until` 替代示例 |
|
|
106
|
+
| `job_output` | `wait:true` 且 `timeout_ms > 120000` | 要求改用 `wait_until` 分段等待 |
|
|
107
|
+
| `wait_agent` | `timeout_ms > 120000` | 要求改用 `wait_until` 的 agent 条件 |
|
|
108
|
+
|
|
109
|
+
其余调用一律放行。`sleep 3`、后台 `sleep`、普通命令不受影响。
|
|
110
|
+
|
|
111
|
+
## 文件结构
|
|
112
|
+
|
|
113
|
+
```
|
|
114
|
+
index.js 插件本体(零依赖,不 import 任何 @deepseek-ai 包)
|
|
115
|
+
cordis.patch.yml 插入 Loader 条目
|
|
116
|
+
package.json bundle 清单(dsh.bundle.patch 指向 patch)
|
|
117
|
+
locale/ 中英文显示元数据(Plugin Manager / Settings 用)
|
|
118
|
+
icon.svg 插件图标
|
|
119
|
+
test/unit.mjs 本地单测(mock 服务,215 条断言,不依赖运行时)
|
|
120
|
+
docs/baseline.md 改造前基线实测报告
|
|
121
|
+
```
|
|
122
|
+
|
|
123
|
+
零依赖的原因:profile 的 `node_modules` 里没有 `@deepseek-ai/dsh-tools` 等包,插件里 import 它们会解析失败。工具 schema 直接手写标准 JSON Schema(`parameters` 原样投影给模型,`output.schema` 由 `tools.register` 校验)。
|
|
124
|
+
|
|
125
|
+
## 本地测试
|
|
126
|
+
|
|
127
|
+
```bash
|
|
128
|
+
node test/unit.mjs
|
|
129
|
+
```
|
|
130
|
+
|
|
131
|
+
覆盖:立即失败、完成事件唤醒(poll 间隔 5s 时仍 <1.5s 返回)、卡住检测、超时、取消、批量 fail_fast、文件出现/变化、日志已命中/新增命中、队友空闲/失败、参数校验、两条 guard 规则、系统提示词注册。
|
|
132
|
+
|
|
133
|
+
## 安装
|
|
134
|
+
|
|
135
|
+
```js
|
|
136
|
+
plugin_manager({ action: 'install_bundle', target: '/Users/loong/dsh-plugins/dsh-tool-wait-until' })
|
|
137
|
+
```
|
|
138
|
+
|
|
139
|
+
安装后由 Plugin Manager 写入 profile 的 `package.json` 与 bundle 列表,`cordis.patch.yml` 插入 `tool-wait-until` 条目。卸载用 `plugin_manager remove_bundle`。
|
|
140
|
+
|
|
141
|
+
## 已知限制
|
|
142
|
+
|
|
143
|
+
- 模型在**思考阶段**是否萌生「等 180 秒」的念头无法直接干预;本插件只保证这个念头一旦落成工具调用就会被拦下并改道。
|
|
144
|
+
- `agent` 条件依赖 Agent Teams 服务;非 Team 会话调用会立即返回 `failed` 并说明原因,不会空等。
|
|
145
|
+
- `wait_agent` / `job_output` 的拦截是硬拒绝,模型会消耗一步重试;拒绝原因里已附可直接复制的替代调用。
|
|
146
|
+
- 日志跨轮读取按字节窗口进行,多字节字符在窗口边界可能短暂出现替换符,不影响 ASCII 正则(如 `EXIT=0`、`error`)匹配。
|
|
147
|
+
- 多 `conditions` 是 `wait_all` 语义(全部满足才 `completed`),`fail_fast` 命中时任一失败即 `failed`。
|
|
148
|
+
|
|
149
|
+
## 修复版生效说明(重要)
|
|
150
|
+
|
|
151
|
+
`index.js` 中「进程退出码非零 → `failed`」的修复只对**新加载的模块**生效:DSH 会在宿主进程内缓存已加载的插件模块,因此只修改本地 link 包的 JS 再重装,运行中的进程仍跑旧代码。
|
|
152
|
+
|
|
153
|
+
本次从 `dsh-tool-watch` 改名为 `dsh-tool-wait-until` 恰好绕开了这个限制——**模块路径变了就是新的 ESM URL**,重装后新代码立即加载,无需重启。实测同一个「退出码 3」的作业已从 `completed` 变为 `failed`(`failure_reason` 含「退出码 3」,`output_tail` 含 stderr)。
|
|
154
|
+
|
|
155
|
+
以后若只改 `index.js` 而不改路径,仍需要重启 Desktop;想免重启,可改包名或目录后重新安装。
|
|
156
|
+
|
|
157
|
+
下面的回归清单保留,供重启后或换环境时验证:
|
|
158
|
+
|
|
159
|
+
1. **退出码识别**:后台启动 `sh -c 'echo BOOM >&2; exit 3'`,拿到 job id 后 `wait_until({conditions:[{kind:"job",id:"<id>"}], fail_fast:true, timeout_sec:60})` → 期望 `status: "failed"`、`failure_reason` 含「退出码 3」、`output_tail` 含 `BOOM`。
|
|
160
|
+
2. **等待中被事件唤醒**:后台启动 `sh -c 'sleep 20; echo "EXIT=3" >&2; exit 3'`,在运行中用 `poll_interval_sec:10` 调 `wait_until` → 期望在约 20 秒的失败瞬间返回,而不是等到 30 秒的轮询边界。
|
|
161
|
+
3. **卡住检测**:后台启动 `sh -c 'sleep 300'`,`wait_until({conditions:[{kind:"job",id:"<id>"}], stall_after_sec:5, timeout_sec:60})` → 期望约 5 秒返回 `stalled`,测完 `job_kill`。
|
|
162
|
+
|
|
163
|
+
`node test/unit.mjs` 覆盖上述语义(249 条断言)。
|
package/cordis.patch.yml
ADDED
package/icon.svg
ADDED
|
@@ -0,0 +1,15 @@
|
|
|
1
|
+
<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 64 64" width="64" height="64" role="img" aria-label="Event-driven watch">
|
|
2
|
+
<defs>
|
|
3
|
+
<linearGradient id="g" x1="0" y1="0" x2="1" y2="1">
|
|
4
|
+
<stop offset="0" stop-color="#2f6df6"/>
|
|
5
|
+
<stop offset="1" stop-color="#12b886"/>
|
|
6
|
+
</linearGradient>
|
|
7
|
+
</defs>
|
|
8
|
+
<rect x="4" y="4" width="56" height="56" rx="14" fill="url(#g)"/>
|
|
9
|
+
<circle cx="32" cy="34" r="15" fill="none" stroke="#ffffff" stroke-width="4"/>
|
|
10
|
+
<path d="M32 34V24" fill="none" stroke="#ffffff" stroke-width="4" stroke-linecap="round"/>
|
|
11
|
+
<path d="M32 34l7 6" fill="none" stroke="#ffffff" stroke-width="4" stroke-linecap="round"/>
|
|
12
|
+
<path d="M25 12h14" fill="none" stroke="#ffffff" stroke-width="4" stroke-linecap="round"/>
|
|
13
|
+
<path d="M32 12v5" fill="none" stroke="#ffffff" stroke-width="4" stroke-linecap="round"/>
|
|
14
|
+
<circle cx="47" cy="19" r="5" fill="#ffd43b"/>
|
|
15
|
+
</svg>
|
package/index.js
ADDED
|
@@ -0,0 +1,954 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* @module @local/dsh-tool-wait-until
|
|
3
|
+
*
|
|
4
|
+
* 「空等」消除插件:注册模型可见的 `wait_until` 工具。
|
|
5
|
+
*
|
|
6
|
+
* 设计要点:
|
|
7
|
+
* - 事件驱动:订阅 `jobs.events`,后台作业 settle / 输出增长时立刻唤醒主循环,
|
|
8
|
+
* 不依赖固定 sleep 轮询;文件、日志、队友条件在 poll_interval 上采样。
|
|
9
|
+
* - 早期失败:作业非零退出、stderr/输出命中错误模式、队友 failed,在等待过程中
|
|
10
|
+
* 就判定 failed,不必等条件满足,也不等到超时。
|
|
11
|
+
* - 卡住检测:任一目标的进展信号(作业输出字节数、作业状态、队友状态、文件版本、
|
|
12
|
+
* 日志大小)连续 stall_after_sec 秒没有变化,就返回 stalled,不等到 timeout。
|
|
13
|
+
* - 统一结果:status / elapsed_ms / matched / failure_reason / output_tail,
|
|
14
|
+
* 调用方凭一次返回即可决策,无需二次查询。
|
|
15
|
+
* - 可取消:全程响应 exec.signal,调用方打断时立即返回 cancelled。
|
|
16
|
+
*
|
|
17
|
+
* 本插件保持零依赖:不 import 任何 @deepseek-ai 包(profile 的 node_modules 里
|
|
18
|
+
* 并没有这些包,导入会解析失败),工具 schema 直接手写标准 JSON Schema。
|
|
19
|
+
*/
|
|
20
|
+
|
|
21
|
+
export const name = 'tool-wait-until'
|
|
22
|
+
|
|
23
|
+
/** 激活所需的服务;fs / agentTeams 通过 ctx.get 可选访问。 */
|
|
24
|
+
export const inject = ['tools', 'jobs', 'workingDirectory']
|
|
25
|
+
|
|
26
|
+
// ---------------------------------------------------------------------------
|
|
27
|
+
// 参数默认值与边界
|
|
28
|
+
// ---------------------------------------------------------------------------
|
|
29
|
+
|
|
30
|
+
const DEFAULT_TIMEOUT_SEC = 60
|
|
31
|
+
const MIN_TIMEOUT_SEC = 1
|
|
32
|
+
const MAX_TIMEOUT_SEC = 900
|
|
33
|
+
|
|
34
|
+
const DEFAULT_POLL_SEC = 2
|
|
35
|
+
const MIN_POLL_SEC = 0.25
|
|
36
|
+
const MAX_POLL_SEC = 30
|
|
37
|
+
|
|
38
|
+
const MIN_STALL_SEC = 5
|
|
39
|
+
|
|
40
|
+
const DEFAULT_TAIL_BYTES = 4096
|
|
41
|
+
const MIN_TAIL_BYTES = 256
|
|
42
|
+
const MAX_TAIL_BYTES = 65536
|
|
43
|
+
|
|
44
|
+
/** 单次读取日志尾部的窗口上限。 */
|
|
45
|
+
const LOG_WINDOW_BYTES = 65536
|
|
46
|
+
|
|
47
|
+
const JOB_SETTLED = new Set(['completed', 'killed', 'failed'])
|
|
48
|
+
const AGENT_INACTIVE = new Set(['inactive', 'failed'])
|
|
49
|
+
const CONDITION_KINDS = new Set(['job', 'agent', 'file', 'log'])
|
|
50
|
+
const SIMPLE_FAIL_FAST = new Set(['job_failed', 'job_killed', 'exit_code_nonzero', 'agent_failed'])
|
|
51
|
+
const DEFAULT_FAIL_FAST = ['job_failed', 'job_killed', 'agent_failed']
|
|
52
|
+
|
|
53
|
+
const encoder = new TextEncoder()
|
|
54
|
+
const decoder = new TextDecoder()
|
|
55
|
+
|
|
56
|
+
// ---------------------------------------------------------------------------
|
|
57
|
+
// 小工具
|
|
58
|
+
// ---------------------------------------------------------------------------
|
|
59
|
+
|
|
60
|
+
function clamp(value, min, max) {
|
|
61
|
+
return Math.min(max, Math.max(min, value))
|
|
62
|
+
}
|
|
63
|
+
|
|
64
|
+
function numberOr(value, fallback) {
|
|
65
|
+
return typeof value === 'number' && Number.isFinite(value) ? value : fallback
|
|
66
|
+
}
|
|
67
|
+
|
|
68
|
+
function formatElapsed(ms) {
|
|
69
|
+
if (ms < 1000) return `${Math.round(ms)}ms`
|
|
70
|
+
if (ms < 60000) return `${(ms / 1000).toFixed(1)}s`
|
|
71
|
+
const minutes = Math.floor(ms / 60000)
|
|
72
|
+
const seconds = Math.round((ms % 60000) / 1000)
|
|
73
|
+
return `${minutes}m${seconds}s`
|
|
74
|
+
}
|
|
75
|
+
|
|
76
|
+
function describeError(error) {
|
|
77
|
+
return error instanceof Error ? error.message : String(error)
|
|
78
|
+
}
|
|
79
|
+
|
|
80
|
+
/**
|
|
81
|
+
* 解析 `/pattern/flags` 或裸字符串形式的正则。
|
|
82
|
+
* @param {string} spec - 正则表达式来源。
|
|
83
|
+
* @param {string} label - 出错信息里指代该来源的名字。
|
|
84
|
+
* @returns {RegExp} 编译后的正则。
|
|
85
|
+
*/
|
|
86
|
+
function parsePattern(spec, label) {
|
|
87
|
+
if (typeof spec !== 'string' || spec.length === 0) throw new Error(`${label} 必须是非空正则表达式`)
|
|
88
|
+
const wrapped = /^\/([\s\S]*)\/([a-z]*)$/.exec(spec)
|
|
89
|
+
try {
|
|
90
|
+
return wrapped === null ? new RegExp(spec) : new RegExp(wrapped[1], wrapped[2])
|
|
91
|
+
} catch (error) {
|
|
92
|
+
throw new Error(`${label} 不是合法正则:${spec}(${describeError(error)})`)
|
|
93
|
+
}
|
|
94
|
+
}
|
|
95
|
+
|
|
96
|
+
/** 重置 g/y 状态后测试一次,避免调用方传带 flag 的正则时出现 lastIndex 串扰。 */
|
|
97
|
+
function matchesRegex(regex, text) {
|
|
98
|
+
regex.lastIndex = 0
|
|
99
|
+
const matched = regex.test(text)
|
|
100
|
+
regex.lastIndex = 0
|
|
101
|
+
return matched
|
|
102
|
+
}
|
|
103
|
+
|
|
104
|
+
/** 按字节上限保留文本尾部,并在 UTF-8 字符边界上裁剪。 */
|
|
105
|
+
function truncateTail(text, maxBytes) {
|
|
106
|
+
if (text.length === 0) return text
|
|
107
|
+
const bytes = encoder.encode(text)
|
|
108
|
+
if (bytes.byteLength <= maxBytes) return text
|
|
109
|
+
return decoder.decode(bytes.subarray(bytes.byteLength - maxBytes)).replace(/^\uFFFD+/, '')
|
|
110
|
+
}
|
|
111
|
+
|
|
112
|
+
/** 把 job 环形缓冲里的 chunk 渲染成可读正文(stderr 单独分段)。 */
|
|
113
|
+
function renderChunks(chunks) {
|
|
114
|
+
const visible = chunks.filter((chunk) => chunk.channel !== 'log')
|
|
115
|
+
const out = visible.filter((chunk) => chunk.channel !== 'stderr').map((chunk) => chunk.text).join('')
|
|
116
|
+
const err = visible.filter((chunk) => chunk.channel === 'stderr').map((chunk) => chunk.text).join('')
|
|
117
|
+
if (err.length === 0) return out
|
|
118
|
+
return `${out}${out.length > 0 && !out.endsWith('\n') ? '\n' : ''}[stderr]\n${err}`
|
|
119
|
+
}
|
|
120
|
+
|
|
121
|
+
/**
|
|
122
|
+
* 单个待唤醒槽:事件到达时唤醒当前等待者;没有等待者时记一次「已触发」,
|
|
123
|
+
* 让紧随其后的等待立即返回,避免事件落在等待与检查的缝隙里被丢掉。
|
|
124
|
+
* @returns {{ setPending: (fn: () => void) => void, clearPending: () => void, wake: () => void }}
|
|
125
|
+
*/
|
|
126
|
+
function createWake() {
|
|
127
|
+
let pending
|
|
128
|
+
let fired = false
|
|
129
|
+
return {
|
|
130
|
+
setPending(fn) {
|
|
131
|
+
if (fired) {
|
|
132
|
+
fired = false
|
|
133
|
+
fn()
|
|
134
|
+
return
|
|
135
|
+
}
|
|
136
|
+
pending = fn
|
|
137
|
+
},
|
|
138
|
+
clearPending() {
|
|
139
|
+
pending = undefined
|
|
140
|
+
},
|
|
141
|
+
wake() {
|
|
142
|
+
const fn = pending
|
|
143
|
+
if (fn === undefined) {
|
|
144
|
+
fired = true
|
|
145
|
+
return
|
|
146
|
+
}
|
|
147
|
+
pending = undefined
|
|
148
|
+
fn()
|
|
149
|
+
},
|
|
150
|
+
}
|
|
151
|
+
}
|
|
152
|
+
|
|
153
|
+
/** 固定时长、事件唤醒、调用方取消三者任一即返回。 */
|
|
154
|
+
function waitForSignal(durationMs, wake, signal) {
|
|
155
|
+
return new Promise((resolve) => {
|
|
156
|
+
let settled = false
|
|
157
|
+
let timer
|
|
158
|
+
const finish = () => {
|
|
159
|
+
if (settled) return
|
|
160
|
+
settled = true
|
|
161
|
+
clearTimeout(timer)
|
|
162
|
+
signal.removeEventListener('abort', onAbort)
|
|
163
|
+
wake.clearPending()
|
|
164
|
+
resolve()
|
|
165
|
+
}
|
|
166
|
+
const onAbort = () => finish()
|
|
167
|
+
timer = setTimeout(finish, durationMs)
|
|
168
|
+
if (signal.aborted) {
|
|
169
|
+
finish()
|
|
170
|
+
return
|
|
171
|
+
}
|
|
172
|
+
signal.addEventListener('abort', onAbort, { once: true })
|
|
173
|
+
wake.setPending(finish)
|
|
174
|
+
})
|
|
175
|
+
}
|
|
176
|
+
|
|
177
|
+
/** 读取会话工作目录;没有 workingDirectory 或调用方不是 Agent 时回落到 undefined。 */
|
|
178
|
+
async function resolveCwd(ctx, exec) {
|
|
179
|
+
const directory = ctx.get('workingDirectory')
|
|
180
|
+
if (directory === undefined || exec.agent === undefined) return undefined
|
|
181
|
+
try {
|
|
182
|
+
return await directory.ensure(exec.agent)
|
|
183
|
+
} catch {
|
|
184
|
+
return undefined
|
|
185
|
+
}
|
|
186
|
+
}
|
|
187
|
+
|
|
188
|
+
function resolveOptions(cwd) {
|
|
189
|
+
return cwd === undefined ? {} : { cwd }
|
|
190
|
+
}
|
|
191
|
+
|
|
192
|
+
// ---------------------------------------------------------------------------
|
|
193
|
+
// 入参归一化
|
|
194
|
+
// ---------------------------------------------------------------------------
|
|
195
|
+
|
|
196
|
+
/**
|
|
197
|
+
* 校验并归一化 conditions。
|
|
198
|
+
* @param {unknown} input - 模型传入的 conditions。
|
|
199
|
+
* @returns {Array<object>} 归一化后的条件数组(保持传入顺序)。
|
|
200
|
+
*/
|
|
201
|
+
function normalizeConditions(input) {
|
|
202
|
+
if (!Array.isArray(input) || input.length === 0) throw new Error('conditions 必须是非空数组')
|
|
203
|
+
return input.map((raw, index) => {
|
|
204
|
+
const label = `conditions[${index}]`
|
|
205
|
+
if (raw === null || typeof raw !== 'object' || Array.isArray(raw)) throw new Error(`${label} 必须是对象`)
|
|
206
|
+
const kind = raw.kind
|
|
207
|
+
if (typeof kind !== 'string' || !CONDITION_KINDS.has(kind)) {
|
|
208
|
+
throw new Error(`${label}.kind 必须是 job / agent / file / log 之一`)
|
|
209
|
+
}
|
|
210
|
+
if (kind === 'job') {
|
|
211
|
+
if (typeof raw.id !== 'string' || raw.id.length === 0) throw new Error(`${label}(job)必须提供非空 id`)
|
|
212
|
+
const until = raw.until ?? 'settled'
|
|
213
|
+
if (until !== 'settled' && until !== 'failed') throw new Error(`${label}.until 对 job 只能是 settled / failed`)
|
|
214
|
+
return { kind, label, id: raw.id, until }
|
|
215
|
+
}
|
|
216
|
+
if (kind === 'agent') {
|
|
217
|
+
const until = raw.until ?? 'inactive'
|
|
218
|
+
if (until !== 'inactive' && until !== 'failed' && until !== 'status_changed') {
|
|
219
|
+
throw new Error(`${label}.until 对 agent 只能是 inactive / failed / status_changed`)
|
|
220
|
+
}
|
|
221
|
+
if (raw.target !== undefined && (typeof raw.target !== 'string' || raw.target.length === 0)) {
|
|
222
|
+
throw new Error(`${label}.target 必须是非空字符串`)
|
|
223
|
+
}
|
|
224
|
+
return { kind, label, target: raw.target, until }
|
|
225
|
+
}
|
|
226
|
+
if (typeof raw.path !== 'string' || raw.path.length === 0) throw new Error(`${label}(${kind})必须提供非空 path`)
|
|
227
|
+
if (kind === 'file') {
|
|
228
|
+
const until = raw.until ?? 'exists'
|
|
229
|
+
if (until !== 'exists' && until !== 'changed') throw new Error(`${label}.until 对 file 只能是 exists / changed`)
|
|
230
|
+
return { kind, label, path: raw.path, until }
|
|
231
|
+
}
|
|
232
|
+
const until = raw.until ?? 'matches'
|
|
233
|
+
if (until !== 'matches') throw new Error(`${label}.until 对 log 只能是 matches`)
|
|
234
|
+
if (typeof raw.matches !== 'string' || raw.matches.length === 0) {
|
|
235
|
+
throw new Error(`${label}(log)必须提供非空 matches 正则`)
|
|
236
|
+
}
|
|
237
|
+
return { kind, label, path: raw.path, until, matches: raw.matches, regex: parsePattern(raw.matches, `${label}.matches`) }
|
|
238
|
+
})
|
|
239
|
+
}
|
|
240
|
+
|
|
241
|
+
/**
|
|
242
|
+
* 归一化 fail_fast 配置。
|
|
243
|
+
* @param {unknown} value - true / false / 模式字符串数组 / 省略。
|
|
244
|
+
* @returns {{ enabled: boolean, simple: Set<string>, textRules: Array<object> }}
|
|
245
|
+
*/
|
|
246
|
+
function normalizeFailFast(value) {
|
|
247
|
+
if (value === false) return { enabled: false, simple: new Set(), textRules: [] }
|
|
248
|
+
const entries = value === undefined || value === true ? DEFAULT_FAIL_FAST : value
|
|
249
|
+
if (!Array.isArray(entries)) throw new Error('fail_fast 必须是布尔值或字符串数组')
|
|
250
|
+
const simple = new Set()
|
|
251
|
+
const textRules = []
|
|
252
|
+
for (const entry of entries) {
|
|
253
|
+
if (typeof entry !== 'string' || entry.length === 0) throw new Error('fail_fast 的每一项必须是非空字符串')
|
|
254
|
+
const wrapped = /^(stderr_matches|output_matches):([\s\S]+)$/.exec(entry)
|
|
255
|
+
if (wrapped !== null) {
|
|
256
|
+
textRules.push({
|
|
257
|
+
channel: wrapped[1] === 'stderr_matches' ? 'stderr' : 'output',
|
|
258
|
+
label: entry,
|
|
259
|
+
regex: parsePattern(wrapped[2], entry),
|
|
260
|
+
})
|
|
261
|
+
continue
|
|
262
|
+
}
|
|
263
|
+
if (!SIMPLE_FAIL_FAST.has(entry)) {
|
|
264
|
+
throw new Error(`未知的 fail_fast 模式:${entry}。支持 job_failed / job_killed / exit_code_nonzero / agent_failed / stderr_matches:/re/ / output_matches:/re/`)
|
|
265
|
+
}
|
|
266
|
+
simple.add(entry)
|
|
267
|
+
}
|
|
268
|
+
return { enabled: true, simple, textRules }
|
|
269
|
+
}
|
|
270
|
+
|
|
271
|
+
// ---------------------------------------------------------------------------
|
|
272
|
+
// 条件探针
|
|
273
|
+
// ---------------------------------------------------------------------------
|
|
274
|
+
|
|
275
|
+
/**
|
|
276
|
+
* 读取作业输出尾部的三份视图:完整正文、stdout 部分、stderr 部分。
|
|
277
|
+
* 用 readAt 读取,不移动模型在 job_output 上的消费游标。
|
|
278
|
+
*/
|
|
279
|
+
function readJobWindow(ctx, id, caller, maxBytes) {
|
|
280
|
+
try {
|
|
281
|
+
const job = ctx.jobs.get(id, caller)
|
|
282
|
+
const total = job.output.total
|
|
283
|
+
const from = Math.max(0, total - Math.max(maxBytes * 2, 4096))
|
|
284
|
+
const read = ctx.jobs.readAt(id, from, caller)
|
|
285
|
+
const visible = read.chunks.filter((chunk) => chunk.channel !== 'log')
|
|
286
|
+
const output = visible.filter((chunk) => chunk.channel !== 'stderr').map((chunk) => chunk.text).join('')
|
|
287
|
+
const stderr = visible.filter((chunk) => chunk.channel === 'stderr').map((chunk) => chunk.text).join('')
|
|
288
|
+
return {
|
|
289
|
+
text: truncateTail(renderChunks(read.chunks), maxBytes),
|
|
290
|
+
output: truncateTail(output, maxBytes),
|
|
291
|
+
stderr: truncateTail(stderr, maxBytes),
|
|
292
|
+
}
|
|
293
|
+
} catch {
|
|
294
|
+
return { text: '', output: '', stderr: '' }
|
|
295
|
+
}
|
|
296
|
+
}
|
|
297
|
+
|
|
298
|
+
/** 作业条件:等待 settle,同时做早期失败判定。 */
|
|
299
|
+
function createJobProbe(ctx, condition, caller, failFast, tailBytes) {
|
|
300
|
+
let lastTotal = -1
|
|
301
|
+
let lastStatus
|
|
302
|
+
let window = { text: '', output: '', stderr: '' }
|
|
303
|
+
return {
|
|
304
|
+
label: condition.label,
|
|
305
|
+
describe: () => `后台作业 ${condition.id}(${condition.until === 'failed' ? '失败' : '结束'})`,
|
|
306
|
+
async poll() {
|
|
307
|
+
let job
|
|
308
|
+
try {
|
|
309
|
+
job = ctx.jobs.get(condition.id, caller)
|
|
310
|
+
} catch (error) {
|
|
311
|
+
return { fatal: `无法读取后台作业 ${condition.id}:${describeError(error)}` }
|
|
312
|
+
}
|
|
313
|
+
const total = job.output.total
|
|
314
|
+
const progressed = total !== lastTotal || job.status !== lastStatus
|
|
315
|
+
lastTotal = total
|
|
316
|
+
lastStatus = job.status
|
|
317
|
+
if (progressed) window = readJobWindow(ctx, condition.id, caller, tailBytes)
|
|
318
|
+
|
|
319
|
+
const status = job.status
|
|
320
|
+
const detailText = job.detail ?? ''
|
|
321
|
+
// 平台把「进程正常退出但退出码非零」记成 completed + detail: "exit code: N",
|
|
322
|
+
// 只有被 kill 或启动失败才是 killed/failed。所以非零退出码必须自己识别,
|
|
323
|
+
// 否则用户抱怨的「脚本报错退出」会伪装成成功。
|
|
324
|
+
const exitMatch = /exit code:\s*(-?\d+)/i.exec(detailText)
|
|
325
|
+
const exitCode = exitMatch === null ? undefined : Number(exitMatch[1])
|
|
326
|
+
const failedExit = exitCode !== undefined && exitCode !== 0
|
|
327
|
+
const failed = status === 'failed' || failedExit
|
|
328
|
+
const killed = status === 'killed'
|
|
329
|
+
const settled = JOB_SETTLED.has(status)
|
|
330
|
+
const detail = `状态 ${status}${job.detail === undefined ? '' : ` — ${job.detail}`}`
|
|
331
|
+
const satisfied = condition.until === 'failed' ? failed : settled
|
|
332
|
+
|
|
333
|
+
let fail
|
|
334
|
+
if (failFast.enabled) {
|
|
335
|
+
if (failed && failFast.simple.has('job_failed')) {
|
|
336
|
+
fail = {
|
|
337
|
+
reason: failedExit
|
|
338
|
+
? `后台作业 ${job.id} 失败:退出码 ${exitCode}`
|
|
339
|
+
: `后台作业 ${job.id} 失败:${job.detail ?? '无 detail'}`,
|
|
340
|
+
tail: window.text,
|
|
341
|
+
}
|
|
342
|
+
} else if (killed && failFast.simple.has('job_killed')) {
|
|
343
|
+
fail = { reason: `后台作业 ${job.id} 被终止:${job.detail ?? '无 detail'}`, tail: window.text }
|
|
344
|
+
} else if (failFast.simple.has('exit_code_nonzero')) {
|
|
345
|
+
if (failedExit) {
|
|
346
|
+
fail = { reason: `后台作业 ${job.id} 退出码 ${exitCode}(非零退出)`, tail: window.text }
|
|
347
|
+
} else {
|
|
348
|
+
const exit = /EXIT=(-?\d+)/.exec(window.text)
|
|
349
|
+
if (exit !== null && Number(exit[1]) !== 0) {
|
|
350
|
+
fail = { reason: `后台作业 ${job.id} 输出 EXIT=${exit[1]}(非零退出)`, tail: window.text }
|
|
351
|
+
}
|
|
352
|
+
}
|
|
353
|
+
}
|
|
354
|
+
if (fail === undefined) {
|
|
355
|
+
for (const rule of failFast.textRules) {
|
|
356
|
+
const haystack = rule.channel === 'stderr' ? window.stderr : window.output
|
|
357
|
+
if (matchesRegex(rule.regex, haystack)) {
|
|
358
|
+
fail = { reason: `后台作业 ${job.id} 的${rule.channel === 'stderr' ? ' stderr' : '输出'}命中失败模式 ${rule.label}`, tail: window.text }
|
|
359
|
+
break
|
|
360
|
+
}
|
|
361
|
+
}
|
|
362
|
+
}
|
|
363
|
+
}
|
|
364
|
+
return { satisfied, progress: progressed, detail, tail: window.text, fail }
|
|
365
|
+
},
|
|
366
|
+
}
|
|
367
|
+
}
|
|
368
|
+
|
|
369
|
+
/** 队友条件:等待成员变为 inactive / failed,或出现状态变化。 */
|
|
370
|
+
function createAgentProbe(ctx, condition, callerAgent) {
|
|
371
|
+
const teams = ctx.get('agentTeams')
|
|
372
|
+
const describe = () => `队友 ${condition.target ?? '(任意成员)'}(${
|
|
373
|
+
condition.until === 'failed' ? '失败' : condition.until === 'status_changed' ? '状态变化' : '空闲'
|
|
374
|
+
})`
|
|
375
|
+
if (teams === undefined || callerAgent === undefined) {
|
|
376
|
+
return {
|
|
377
|
+
label: condition.label,
|
|
378
|
+
describe,
|
|
379
|
+
async poll() {
|
|
380
|
+
return { unsupported: '当前运行环境没有 Agent Teams 服务,agent 条件无法评估(请改用其它条件)' }
|
|
381
|
+
},
|
|
382
|
+
}
|
|
383
|
+
}
|
|
384
|
+
let lastSignature
|
|
385
|
+
return {
|
|
386
|
+
label: condition.label,
|
|
387
|
+
describe,
|
|
388
|
+
async poll() {
|
|
389
|
+
let members
|
|
390
|
+
try {
|
|
391
|
+
members = teams.listMembers(callerAgent)
|
|
392
|
+
} catch (error) {
|
|
393
|
+
return { fatal: `无法读取团队成员:${describeError(error)}` }
|
|
394
|
+
}
|
|
395
|
+
const peers = members.filter((member) => member.id !== callerAgent.id)
|
|
396
|
+
const watched = condition.target === undefined ? peers : peers.filter((member) => member.name === condition.target)
|
|
397
|
+
if (watched.length === 0) {
|
|
398
|
+
return { fatal: `团队中没有名为 ${String(condition.target)} 的成员` }
|
|
399
|
+
}
|
|
400
|
+
const signature = watched.map((member) => `${member.name}:${member.status}:${member.diagnostics.length}`).join('|')
|
|
401
|
+
const progressed = lastSignature !== undefined && signature !== lastSignature
|
|
402
|
+
lastSignature = signature
|
|
403
|
+
const detail = watched
|
|
404
|
+
.map((member) => `${member.name}=${member.status}${member.diagnostics.length === 0 ? '' : `(diagnostics: ${member.diagnostics.join('; ')})`}`)
|
|
405
|
+
.join(', ')
|
|
406
|
+
const failedMember = watched.find((member) => member.status === 'failed')
|
|
407
|
+
const anyFailed = failedMember !== undefined
|
|
408
|
+
let satisfied
|
|
409
|
+
if (condition.until === 'failed') satisfied = anyFailed
|
|
410
|
+
else if (condition.until === 'status_changed') satisfied = progressed
|
|
411
|
+
else satisfied = watched.every((member) => AGENT_INACTIVE.has(member.status))
|
|
412
|
+
const fail = anyFailed
|
|
413
|
+
? { reason: `队友 ${failedMember.name} 失败:${failedMember.diagnostics.join('; ') || '无 diagnostics'}`, tail: detail }
|
|
414
|
+
: undefined
|
|
415
|
+
return { satisfied, progress: progressed, detail, fail }
|
|
416
|
+
},
|
|
417
|
+
}
|
|
418
|
+
}
|
|
419
|
+
|
|
420
|
+
/** 文件条件:等待出现或版本变化。 */
|
|
421
|
+
function createFileProbe(ctx, condition, cwd) {
|
|
422
|
+
const fs = ctx.get('fs')
|
|
423
|
+
const describe = () => `文件 ${condition.path}(${condition.until === 'exists' ? '出现' : '变化'})`
|
|
424
|
+
if (fs === undefined) {
|
|
425
|
+
return {
|
|
426
|
+
label: condition.label,
|
|
427
|
+
describe,
|
|
428
|
+
async poll() {
|
|
429
|
+
return { unsupported: '当前运行环境没有 fs 服务,file 条件无法评估' }
|
|
430
|
+
},
|
|
431
|
+
}
|
|
432
|
+
}
|
|
433
|
+
let target
|
|
434
|
+
let baselineVersion
|
|
435
|
+
let lastVersion
|
|
436
|
+
let initialized = false
|
|
437
|
+
return {
|
|
438
|
+
label: condition.label,
|
|
439
|
+
describe,
|
|
440
|
+
async poll() {
|
|
441
|
+
if (target === undefined) {
|
|
442
|
+
try {
|
|
443
|
+
target = await fs.resolve(condition.path, resolveOptions(cwd))
|
|
444
|
+
} catch (error) {
|
|
445
|
+
return { fatal: `无法解析路径 ${condition.path}:${describeError(error)}` }
|
|
446
|
+
}
|
|
447
|
+
}
|
|
448
|
+
let info
|
|
449
|
+
try {
|
|
450
|
+
info = await fs.stat(target)
|
|
451
|
+
} catch (error) {
|
|
452
|
+
return { error: `读取 ${condition.path} 的状态失败:${describeError(error)}` }
|
|
453
|
+
}
|
|
454
|
+
const exists = info !== undefined
|
|
455
|
+
const version = exists ? String(info.version) : undefined
|
|
456
|
+
const progressed = initialized && version !== lastVersion
|
|
457
|
+
lastVersion = version
|
|
458
|
+
if (!initialized) {
|
|
459
|
+
initialized = true
|
|
460
|
+
baselineVersion = version
|
|
461
|
+
}
|
|
462
|
+
const detail = exists ? `文件存在(${info.size === undefined ? '大小未知' : `${info.size} 字节`})` : '文件不存在'
|
|
463
|
+
let satisfied
|
|
464
|
+
if (condition.until === 'exists') satisfied = exists
|
|
465
|
+
else satisfied = exists && baselineVersion !== undefined && version !== baselineVersion
|
|
466
|
+
return { satisfied, progress: progressed, detail }
|
|
467
|
+
},
|
|
468
|
+
}
|
|
469
|
+
}
|
|
470
|
+
|
|
471
|
+
/** 日志条件:等待文件内容命中正则。 */
|
|
472
|
+
function createLogProbe(ctx, condition, cwd) {
|
|
473
|
+
const fs = ctx.get('fs')
|
|
474
|
+
const describe = () => `日志 ${condition.path} 匹配 ${condition.matches}`
|
|
475
|
+
if (fs === undefined) {
|
|
476
|
+
return {
|
|
477
|
+
label: condition.label,
|
|
478
|
+
describe,
|
|
479
|
+
async poll() {
|
|
480
|
+
return { unsupported: '当前运行环境没有 fs 服务,log 条件无法评估' }
|
|
481
|
+
},
|
|
482
|
+
}
|
|
483
|
+
}
|
|
484
|
+
let target
|
|
485
|
+
let readOffset
|
|
486
|
+
let recentText = ''
|
|
487
|
+
return {
|
|
488
|
+
label: condition.label,
|
|
489
|
+
describe,
|
|
490
|
+
async poll() {
|
|
491
|
+
if (target === undefined) {
|
|
492
|
+
try {
|
|
493
|
+
target = await fs.resolve(condition.path, resolveOptions(cwd))
|
|
494
|
+
} catch (error) {
|
|
495
|
+
return { fatal: `无法解析日志路径 ${condition.path}:${describeError(error)}` }
|
|
496
|
+
}
|
|
497
|
+
}
|
|
498
|
+
let info
|
|
499
|
+
try {
|
|
500
|
+
info = await fs.stat(target)
|
|
501
|
+
} catch (error) {
|
|
502
|
+
return { error: `读取日志 ${condition.path} 的状态失败:${describeError(error)}` }
|
|
503
|
+
}
|
|
504
|
+
if (info === undefined) {
|
|
505
|
+
readOffset = undefined
|
|
506
|
+
recentText = ''
|
|
507
|
+
return { satisfied: false, progress: false, detail: '日志文件还不存在' }
|
|
508
|
+
}
|
|
509
|
+
const size = info.size ?? 0
|
|
510
|
+
// 首轮(或文件被轮转截断后)读取尾部窗口,让启动前已存在的内容也能命中。
|
|
511
|
+
if (readOffset === undefined || size < readOffset) {
|
|
512
|
+
const from = Math.max(0, size - LOG_WINDOW_BYTES)
|
|
513
|
+
let bytes
|
|
514
|
+
try {
|
|
515
|
+
bytes = await fs.readByteRange(target, { offset: from, length: size - from })
|
|
516
|
+
} catch (error) {
|
|
517
|
+
return { error: `读取日志 ${condition.path} 失败:${describeError(error)}` }
|
|
518
|
+
}
|
|
519
|
+
recentText = decoder.decode(bytes)
|
|
520
|
+
readOffset = size
|
|
521
|
+
const matched = matchesRegex(condition.regex, recentText)
|
|
522
|
+
return {
|
|
523
|
+
satisfied: matched,
|
|
524
|
+
progress: false,
|
|
525
|
+
detail: `日志 ${size} 字节${matched ? ',命中' : ',未命中'}`,
|
|
526
|
+
tail: truncateTail(recentText, DEFAULT_TAIL_BYTES),
|
|
527
|
+
}
|
|
528
|
+
}
|
|
529
|
+
if (size === readOffset) {
|
|
530
|
+
return { satisfied: false, progress: false, detail: `日志无新增(${size} 字节)`, tail: truncateTail(recentText, DEFAULT_TAIL_BYTES) }
|
|
531
|
+
}
|
|
532
|
+
let bytes
|
|
533
|
+
try {
|
|
534
|
+
bytes = await fs.readByteRange(target, { offset: readOffset, length: Math.min(size - readOffset, LOG_WINDOW_BYTES) })
|
|
535
|
+
} catch (error) {
|
|
536
|
+
return { error: `读取日志 ${condition.path} 新增内容失败:${describeError(error)}` }
|
|
537
|
+
}
|
|
538
|
+
const grew = bytes.byteLength
|
|
539
|
+
readOffset += grew
|
|
540
|
+
recentText = truncateTail(`${recentText}${decoder.decode(bytes)}`, LOG_WINDOW_BYTES)
|
|
541
|
+
const matched = matchesRegex(condition.regex, recentText)
|
|
542
|
+
return {
|
|
543
|
+
satisfied: matched,
|
|
544
|
+
progress: grew > 0,
|
|
545
|
+
detail: `日志新增 ${grew} 字节${matched ? ',命中' : ''}`,
|
|
546
|
+
tail: truncateTail(recentText, DEFAULT_TAIL_BYTES),
|
|
547
|
+
}
|
|
548
|
+
},
|
|
549
|
+
}
|
|
550
|
+
}
|
|
551
|
+
|
|
552
|
+
// ---------------------------------------------------------------------------
|
|
553
|
+
// 结果构造
|
|
554
|
+
// ---------------------------------------------------------------------------
|
|
555
|
+
|
|
556
|
+
const STATUS_HINTS = {
|
|
557
|
+
completed: undefined,
|
|
558
|
+
failed: '目标已失败,不必再等:按 output_tail / failure_reason 决策(需要完整日志时用 job_output 读取)。',
|
|
559
|
+
stalled: '目标在该窗口内没有任何进展(无新输出、状态未变、文件未更新),可能已卡死:检查进程与日志,必要时先停止再重试。',
|
|
560
|
+
timeout: '本次是超时,不是失败:条件在 timeout_sec 内仍未满足。可增大 timeout_sec 再次 wait_until,或先检查目标与日志。',
|
|
561
|
+
cancelled: '等待已被调用方取消,目标仍在运行(没有被停止)。',
|
|
562
|
+
}
|
|
563
|
+
|
|
564
|
+
/**
|
|
565
|
+
* 组装最终结构化结果。
|
|
566
|
+
* @param {string} status - completed / failed / stalled / timeout / cancelled。
|
|
567
|
+
* @param {number} startedAt - 起始时间戳(毫秒)。
|
|
568
|
+
* @param {object} input - { matched, rows, failureReason, stalledForSec, tail, hint, tailBytes }。
|
|
569
|
+
* @returns {object} 符合 output.schema 的值。
|
|
570
|
+
*/
|
|
571
|
+
function buildResult(status, startedAt, input) {
|
|
572
|
+
const elapsedMs = Date.now() - startedAt
|
|
573
|
+
const value = {
|
|
574
|
+
status,
|
|
575
|
+
elapsed_ms: elapsedMs,
|
|
576
|
+
elapsed: formatElapsed(elapsedMs),
|
|
577
|
+
matched: input.matched ?? [],
|
|
578
|
+
conditions: input.rows ?? [],
|
|
579
|
+
}
|
|
580
|
+
if (input.failureReason !== undefined) value.failure_reason = truncateTail(input.failureReason, 8192)
|
|
581
|
+
if (input.stalledForSec !== undefined) value.stalled_for_sec = input.stalledForSec
|
|
582
|
+
if (input.tail !== undefined && input.tail !== '') value.output_tail = truncateTail(input.tail, input.tailBytes ?? DEFAULT_TAIL_BYTES)
|
|
583
|
+
const hint = input.hint ?? STATUS_HINTS[status]
|
|
584
|
+
if (hint !== undefined) value.hint = hint
|
|
585
|
+
return value
|
|
586
|
+
}
|
|
587
|
+
|
|
588
|
+
// ---------------------------------------------------------------------------
|
|
589
|
+
// 主循环
|
|
590
|
+
// ---------------------------------------------------------------------------
|
|
591
|
+
|
|
592
|
+
/**
|
|
593
|
+
* 执行一次 wait_until:条件驱动返回,带早期失败、卡住检测与统一结果。
|
|
594
|
+
* @param {object} ctx - 插件上下文。
|
|
595
|
+
* @param {object} args - 模型传入的参数。
|
|
596
|
+
* @param {object} exec - 工具执行上下文(提供 agent 与 signal)。
|
|
597
|
+
* @returns {Promise<object>} 结构化结果。
|
|
598
|
+
*/
|
|
599
|
+
async function runWaitUntil(ctx, args, exec) {
|
|
600
|
+
const startedAt = Date.now()
|
|
601
|
+
const conditions = normalizeConditions(args.conditions)
|
|
602
|
+
const failFast = normalizeFailFast(args.fail_fast)
|
|
603
|
+
const timeoutMs = clamp(numberOr(args.timeout_sec, DEFAULT_TIMEOUT_SEC), MIN_TIMEOUT_SEC, MAX_TIMEOUT_SEC) * 1000
|
|
604
|
+
const pollMs = clamp(numberOr(args.poll_interval_sec, DEFAULT_POLL_SEC), MIN_POLL_SEC, MAX_POLL_SEC) * 1000
|
|
605
|
+
const stallRaw = numberOr(args.stall_after_sec, 0)
|
|
606
|
+
const stallMs = stallRaw <= 0 ? 0 : Math.max(stallRaw, MIN_STALL_SEC) * 1000
|
|
607
|
+
const tailBytes = clamp(numberOr(args.output_tail_bytes, DEFAULT_TAIL_BYTES), MIN_TAIL_BYTES, MAX_TAIL_BYTES)
|
|
608
|
+
|
|
609
|
+
const caller = exec.agent?.id
|
|
610
|
+
const callerAgent = exec.agent
|
|
611
|
+
const cwd = await resolveCwd(ctx, exec)
|
|
612
|
+
|
|
613
|
+
const probes = conditions.map((condition) => {
|
|
614
|
+
switch (condition.kind) {
|
|
615
|
+
case 'job': return createJobProbe(ctx, condition, caller, failFast, tailBytes)
|
|
616
|
+
case 'agent': return createAgentProbe(ctx, condition, callerAgent)
|
|
617
|
+
case 'file': return createFileProbe(ctx, condition, cwd)
|
|
618
|
+
case 'log': return createLogProbe(ctx, condition, cwd)
|
|
619
|
+
default: throw new Error(`未知的条件类型:${String(condition.kind)}`)
|
|
620
|
+
}
|
|
621
|
+
})
|
|
622
|
+
|
|
623
|
+
const rows = probes.map((probe) => ({ condition: probe.describe(), state: 'pending', detail: '尚未检查' }))
|
|
624
|
+
const wake = createWake()
|
|
625
|
+
const disposers = []
|
|
626
|
+
|
|
627
|
+
const finish = (status, extra) => buildResult(status, startedAt, { rows: snapshotRows(rows), tailBytes, ...extra })
|
|
628
|
+
const snapshotRows = () => rows.map((row) => ({ condition: row.condition, state: row.state, detail: row.detail }))
|
|
629
|
+
|
|
630
|
+
try {
|
|
631
|
+
if (conditions.some((condition) => condition.kind === 'job')) {
|
|
632
|
+
const filter = caller === undefined ? { owners: 'all' } : { owner: caller }
|
|
633
|
+
disposers.push(ctx.jobs.events.subscribe(filter, () => wake.wake()))
|
|
634
|
+
}
|
|
635
|
+
|
|
636
|
+
let lastProgressAt = Date.now()
|
|
637
|
+
let latestTail = ''
|
|
638
|
+
const deadline = startedAt + timeoutMs
|
|
639
|
+
|
|
640
|
+
for (;;) {
|
|
641
|
+
if (exec.signal.aborted) return finish('cancelled', { tail: latestTail })
|
|
642
|
+
const now = Date.now()
|
|
643
|
+
let progressed = false
|
|
644
|
+
let allSatisfied = true
|
|
645
|
+
const matched = []
|
|
646
|
+
let failure
|
|
647
|
+
let fatal
|
|
648
|
+
|
|
649
|
+
for (let index = 0; index < probes.length; index += 1) {
|
|
650
|
+
const probe = probes[index]
|
|
651
|
+
let state
|
|
652
|
+
try {
|
|
653
|
+
state = await probe.poll()
|
|
654
|
+
} catch (error) {
|
|
655
|
+
state = { fatal: `${probe.describe()}:${describeError(error)}` }
|
|
656
|
+
}
|
|
657
|
+
const row = rows[index]
|
|
658
|
+
if (typeof state?.detail === 'string') row.detail = state.detail
|
|
659
|
+
if (typeof state?.tail === 'string' && state.tail.length > 0) latestTail = state.tail
|
|
660
|
+
if (state?.fatal !== undefined) {
|
|
661
|
+
row.state = 'error'
|
|
662
|
+
fatal = state.fatal
|
|
663
|
+
break
|
|
664
|
+
}
|
|
665
|
+
if (state?.unsupported !== undefined) {
|
|
666
|
+
row.state = 'unsupported'
|
|
667
|
+
row.detail = state.unsupported
|
|
668
|
+
fatal = state.unsupported
|
|
669
|
+
break
|
|
670
|
+
}
|
|
671
|
+
if (state?.error !== undefined) {
|
|
672
|
+
row.state = 'error'
|
|
673
|
+
allSatisfied = false
|
|
674
|
+
continue
|
|
675
|
+
}
|
|
676
|
+
if (state?.progress === true) progressed = true
|
|
677
|
+
if (state?.satisfied === true) {
|
|
678
|
+
row.state = 'satisfied'
|
|
679
|
+
matched.push(probe.describe())
|
|
680
|
+
} else {
|
|
681
|
+
row.state = 'pending'
|
|
682
|
+
allSatisfied = false
|
|
683
|
+
}
|
|
684
|
+
if (state?.fail !== undefined && failFast.enabled) {
|
|
685
|
+
row.state = 'failed'
|
|
686
|
+
failure = state.fail
|
|
687
|
+
break
|
|
688
|
+
}
|
|
689
|
+
}
|
|
690
|
+
|
|
691
|
+
if (fatal !== undefined) return finish('failed', { failureReason: fatal, tail: latestTail, matched })
|
|
692
|
+
if (failure !== undefined) return finish('failed', { failureReason: failure.reason, tail: failure.tail ?? latestTail, matched })
|
|
693
|
+
if (allSatisfied && matched.length === probes.length) return finish('completed', { matched, tail: latestTail })
|
|
694
|
+
if (progressed) lastProgressAt = now
|
|
695
|
+
|
|
696
|
+
if (stallMs > 0 && now - lastProgressAt >= stallMs) {
|
|
697
|
+
return finish('stalled', {
|
|
698
|
+
stalledForSec: Math.round((now - lastProgressAt) / 1000),
|
|
699
|
+
tail: latestTail,
|
|
700
|
+
matched,
|
|
701
|
+
})
|
|
702
|
+
}
|
|
703
|
+
const remaining = deadline - now
|
|
704
|
+
if (remaining <= 0) return finish('timeout', { tail: latestTail, matched })
|
|
705
|
+
await waitForSignal(Math.min(pollMs, remaining), wake, exec.signal)
|
|
706
|
+
}
|
|
707
|
+
} finally {
|
|
708
|
+
for (const dispose of disposers) {
|
|
709
|
+
try {
|
|
710
|
+
dispose()
|
|
711
|
+
} catch {
|
|
712
|
+
// 清理监听器失败不影响已经产出的结果
|
|
713
|
+
}
|
|
714
|
+
}
|
|
715
|
+
}
|
|
716
|
+
}
|
|
717
|
+
|
|
718
|
+
// ---------------------------------------------------------------------------
|
|
719
|
+
// 工具定义(标准 JSON Schema,零依赖)
|
|
720
|
+
// ---------------------------------------------------------------------------
|
|
721
|
+
|
|
722
|
+
const CONDITION_SCHEMA = {
|
|
723
|
+
type: 'object',
|
|
724
|
+
additionalProperties: false,
|
|
725
|
+
properties: {
|
|
726
|
+
kind: {
|
|
727
|
+
type: 'string',
|
|
728
|
+
enum: ['job', 'agent', 'file', 'log'],
|
|
729
|
+
description: '条件类型。job=后台作业;agent=团队成员;file=文件;log=日志文件正则匹配。',
|
|
730
|
+
},
|
|
731
|
+
id: { type: 'string', description: 'kind=job:job_output/job_list 返回的作业 id。' },
|
|
732
|
+
target: { type: 'string', description: 'kind=agent:成员名字(spawn_teammate/list_agents 的名字);省略表示该会话中任意其他成员。' },
|
|
733
|
+
path: { type: 'string', description: 'kind=file/log:文件路径,相对路径按会话工作目录解析。' },
|
|
734
|
+
until: {
|
|
735
|
+
type: 'string',
|
|
736
|
+
description: '完成条件。job:settled(默认,结束即可,含失败)/ failed(仅失败);agent:inactive(默认,全部空闲)/ failed(失败)/ status_changed(任意状态变化);file:exists(默认,出现)/ changed(版本变化);log:matches(默认,必须配 matches)。',
|
|
737
|
+
},
|
|
738
|
+
matches: { type: 'string', description: 'kind=log:正则表达式,支持 /pattern/flags 或裸正则;匹配日志新增内容或尾部窗口。' },
|
|
739
|
+
},
|
|
740
|
+
required: ['kind'],
|
|
741
|
+
}
|
|
742
|
+
|
|
743
|
+
const WAIT_UNTIL_PARAMETERS = {
|
|
744
|
+
type: 'object',
|
|
745
|
+
additionalProperties: false,
|
|
746
|
+
properties: {
|
|
747
|
+
conditions: {
|
|
748
|
+
type: 'array',
|
|
749
|
+
description: '要等待的条件。全部条件都满足才返回 completed(即 wait_all 语义);fail_fast 命中时任一条件失败立即返回 failed。只想要「任一完成就返回」时,把条件拆成多次 wait_until 或配合 timeout_sec 使用。',
|
|
750
|
+
items: CONDITION_SCHEMA,
|
|
751
|
+
},
|
|
752
|
+
fail_fast: {
|
|
753
|
+
oneOf: [
|
|
754
|
+
{ type: 'boolean' },
|
|
755
|
+
{ type: 'array', items: { type: 'string' } },
|
|
756
|
+
],
|
|
757
|
+
description: '早期失败检测。true(默认)= job_failed + job_killed + agent_failed;false=关闭;也可给模式数组:job_failed / job_killed / exit_code_nonzero / agent_failed / stderr_matches:/error|failed|Traceback/ / output_matches:/EXIT=[1-9]/。',
|
|
758
|
+
},
|
|
759
|
+
stall_after_sec: {
|
|
760
|
+
type: 'number',
|
|
761
|
+
description: '卡住判定:连续这么多秒没有任何进展信号(作业输出/状态、队友状态、文件版本、日志大小都没变)就返回 stalled。0(默认)=关闭。长等待时建议 120–600。',
|
|
762
|
+
},
|
|
763
|
+
timeout_sec: {
|
|
764
|
+
type: 'number',
|
|
765
|
+
description: `总兜底超时,默认 ${DEFAULT_TIMEOUT_SEC},范围 ${MIN_TIMEOUT_SEC}–${MAX_TIMEOUT_SEC}。单次建议 30–60 秒,需要更长等待时反复 wait_until 而不是一次等满。`,
|
|
766
|
+
},
|
|
767
|
+
poll_interval_sec: {
|
|
768
|
+
type: 'number',
|
|
769
|
+
description: `采样间隔,默认 ${DEFAULT_POLL_SEC},范围 ${MIN_POLL_SEC}–${MAX_POLL_SEC}。作业事件仍会立即唤醒,不受该间隔限制。`,
|
|
770
|
+
},
|
|
771
|
+
output_tail_bytes: {
|
|
772
|
+
type: 'number',
|
|
773
|
+
description: `返回 output_tail 的字节上限,默认 ${DEFAULT_TAIL_BYTES}。`,
|
|
774
|
+
},
|
|
775
|
+
},
|
|
776
|
+
required: ['conditions'],
|
|
777
|
+
}
|
|
778
|
+
|
|
779
|
+
const WAIT_UNTIL_RESULT_SCHEMA = {
|
|
780
|
+
type: 'object',
|
|
781
|
+
additionalProperties: false,
|
|
782
|
+
properties: {
|
|
783
|
+
status: {
|
|
784
|
+
type: 'string',
|
|
785
|
+
enum: ['completed', 'failed', 'stalled', 'timeout', 'cancelled'],
|
|
786
|
+
description: '最终判定:完成 / 失败 / 卡住 / 超时 / 被取消。超时不是失败。',
|
|
787
|
+
},
|
|
788
|
+
elapsed_ms: { type: 'integer', description: '本次 wait_until 实际耗时(毫秒)。' },
|
|
789
|
+
elapsed: { type: 'string', description: '实际耗时的可读形式。' },
|
|
790
|
+
matched: { type: 'array', items: { type: 'string' }, description: '已完成/失败的条件描述。' },
|
|
791
|
+
conditions: {
|
|
792
|
+
type: 'array',
|
|
793
|
+
description: '每个条件的最终状态快照。',
|
|
794
|
+
items: {
|
|
795
|
+
type: 'object',
|
|
796
|
+
additionalProperties: false,
|
|
797
|
+
properties: {
|
|
798
|
+
condition: { type: 'string', description: '条件描述。' },
|
|
799
|
+
state: { type: 'string', description: 'pending / satisfied / failed / error / unsupported。' },
|
|
800
|
+
detail: { type: 'string', description: '该条件的最近观测细节。' },
|
|
801
|
+
},
|
|
802
|
+
required: ['condition', 'state'],
|
|
803
|
+
},
|
|
804
|
+
},
|
|
805
|
+
failure_reason: { type: 'string', description: 'status=failed 时的失败原因(含早期失败检测命中的模式)。' },
|
|
806
|
+
stalled_for_sec: { type: 'number', description: 'status=stalled 时无进展持续的秒数。' },
|
|
807
|
+
output_tail: { type: 'string', description: '相关目标最后输出的尾部(作业输出、日志窗口、成员状态等)。' },
|
|
808
|
+
hint: { type: 'string', description: '基于 status 的下一步建议。' },
|
|
809
|
+
},
|
|
810
|
+
required: ['status', 'elapsed_ms', 'elapsed', 'matched', 'conditions'],
|
|
811
|
+
}
|
|
812
|
+
|
|
813
|
+
function renderWaitUntilResult(_args, value) {
|
|
814
|
+
const lines = [`[wait_until ${value.status}] 耗时 ${value.elapsed}(${value.elapsed_ms}ms)`]
|
|
815
|
+
if (value.failure_reason !== undefined) lines.push(`失败原因:${value.failure_reason}`)
|
|
816
|
+
if (value.stalled_for_sec !== undefined) lines.push(`无进展持续 ${value.stalled_for_sec}s`)
|
|
817
|
+
if (value.matched.length > 0) lines.push(`已满足:${value.matched.join(';')}`)
|
|
818
|
+
for (const row of value.conditions) {
|
|
819
|
+
lines.push(`- ${row.condition}:${row.state}${row.detail === undefined ? '' : `(${row.detail})`}`)
|
|
820
|
+
}
|
|
821
|
+
if (value.output_tail !== undefined) lines.push('', '--- 输出尾部 ---', value.output_tail)
|
|
822
|
+
if (value.hint !== undefined) lines.push('', value.hint)
|
|
823
|
+
return [{ type: 'text', text: lines.join('\n') }]
|
|
824
|
+
}
|
|
825
|
+
|
|
826
|
+
// ---------------------------------------------------------------------------
|
|
827
|
+
// 行为干预:系统提示词规则 + 「sleep 空等 / 超长等待」拦截
|
|
828
|
+
//
|
|
829
|
+
// 只提供一个新工具不足以改变行为:模型会凭惯性继续「sleep 180 再查一次」。
|
|
830
|
+
// 因此在工具之外再补两层——决策前给规则,决策落地时拦下来并给出替代路径。
|
|
831
|
+
// ---------------------------------------------------------------------------
|
|
832
|
+
|
|
833
|
+
/** 前台 sleep 达到这个秒数就视为空等并拒绝。 */
|
|
834
|
+
const SLEEP_DENY_SEC = 20
|
|
835
|
+
/** job_output / wait_agent 单次等待超过这个毫秒数就要求改用 wait_until 分段等待。 */
|
|
836
|
+
const LONG_WAIT_DENY_MS = 120000
|
|
837
|
+
|
|
838
|
+
const SLEEP_SEGMENT = /(?:^|[;&|]|\n)\s*sleep\s+([0-9]+(?:\.[0-9]+)?)\s*([smhd]?)\b/gi
|
|
839
|
+
const SLEEP_UNITS = { '': 1, s: 1, m: 60, h: 3600, d: 86400 }
|
|
840
|
+
|
|
841
|
+
/**
|
|
842
|
+
* 找出命令里最长的一次前台 sleep(秒)。
|
|
843
|
+
* @param {string} command - bash 命令原文。
|
|
844
|
+
* @returns {number} 最长 sleep 秒数;没有则为 0。
|
|
845
|
+
*/
|
|
846
|
+
function longestSleepSeconds(command) {
|
|
847
|
+
let longest = 0
|
|
848
|
+
for (const match of command.matchAll(SLEEP_SEGMENT)) {
|
|
849
|
+
const unit = SLEEP_UNITS[(match[2] ?? '').toLowerCase()] ?? 1
|
|
850
|
+
longest = Math.max(longest, Number(match[1]) * unit)
|
|
851
|
+
}
|
|
852
|
+
return longest
|
|
853
|
+
}
|
|
854
|
+
|
|
855
|
+
/** 拦截理由里给出的替代调用示例。 */
|
|
856
|
+
const WAIT_UNTIL_USAGE_HINT = `wait_until 用法(条件满足、目标失败或连续无进展时立即返回):
|
|
857
|
+
wait_until({conditions:[{kind:"job",id:"<job_id>"}]}) // 等后台作业结束
|
|
858
|
+
wait_until({conditions:[{kind:"file",path:"<path>",until:"exists"}]}) // 等文件出现
|
|
859
|
+
wait_until({conditions:[{kind:"file",path:"<path>",until:"changed"}], timeout_sec:900, stall_after_sec:120}) // 等文件持续变化,卡住即返回
|
|
860
|
+
wait_until({conditions:[{kind:"log",path:"<path>",matches:"/DONE|EXIT=0/"}]}) // 等日志关键字
|
|
861
|
+
wait_until({conditions:[{kind:"agent",target:"<name>",until:"inactive"}], timeout_sec:600, stall_after_sec:300}) // 等队友`
|
|
862
|
+
|
|
863
|
+
/**
|
|
864
|
+
* 拦截「用 sleep 空等」的前台 bash 调用。
|
|
865
|
+
* @param {object} execution - 工具执行上下文(含 name / arguments)。
|
|
866
|
+
* @returns {string|undefined} 拒绝原因;undefined 表示放行。
|
|
867
|
+
*/
|
|
868
|
+
function guardSleepWait(execution) {
|
|
869
|
+
if (execution.name !== 'bash') return undefined
|
|
870
|
+
const args = execution.arguments
|
|
871
|
+
if (args === null || typeof args !== 'object') return undefined
|
|
872
|
+
if (args.run_in_background === true) return undefined
|
|
873
|
+
if (typeof args.command !== 'string') return undefined
|
|
874
|
+
const command = args.command.trim()
|
|
875
|
+
if (command.length === 0) return undefined
|
|
876
|
+
// 以单个 & 结尾的是 shell 后台任务,本身不阻塞这次调用。
|
|
877
|
+
if (/(?:^|[^&])&$/.test(command)) return undefined
|
|
878
|
+
const seconds = longestSleepSeconds(command)
|
|
879
|
+
if (seconds < SLEEP_DENY_SEC) return undefined
|
|
880
|
+
return `拒绝了 ${seconds} 秒的前台 sleep 空等:等待期间既看不到进展,完成或失败时也不会立即返回。改用 wait_until 工具(事件驱动、可观测、失败与卡住立刻返回):
|
|
881
|
+
${WAIT_UNTIL_USAGE_HINT}
|
|
882
|
+
确需节奏控制时,把 sleep 放进 run_in_background 的作业,或写进带实际工作的脚本。`
|
|
883
|
+
}
|
|
884
|
+
|
|
885
|
+
/**
|
|
886
|
+
* 拦截 job_output / wait_agent 的超长单次等待。
|
|
887
|
+
* @param {object} execution - 工具执行上下文(含 name / arguments)。
|
|
888
|
+
* @returns {string|undefined} 拒绝原因;undefined 表示放行。
|
|
889
|
+
*/
|
|
890
|
+
function guardLongToolWait(execution) {
|
|
891
|
+
const args = execution.arguments
|
|
892
|
+
if (args === null || typeof args !== 'object') return undefined
|
|
893
|
+
if (execution.name === 'job_output' && args.wait === true && typeof args.timeout_ms === 'number') {
|
|
894
|
+
if (args.timeout_ms <= LONG_WAIT_DENY_MS) return undefined
|
|
895
|
+
return `job_output 的单个等待最多 ${LONG_WAIT_DENY_MS / 1000} 秒(本次 ${Math.round(args.timeout_ms / 1000)} 秒):超长等待会在作业卡住时白等,也拿不到中间进展。改用 wait_until 分段等待:
|
|
896
|
+
${WAIT_UNTIL_USAGE_HINT}`
|
|
897
|
+
}
|
|
898
|
+
if (execution.name === 'wait_agent') {
|
|
899
|
+
const timeout = typeof args.timeout_ms === 'number' ? args.timeout_ms : 30000
|
|
900
|
+
if (timeout <= LONG_WAIT_DENY_MS) return undefined
|
|
901
|
+
return `wait_agent 的单个等待最多 ${LONG_WAIT_DENY_MS / 1000} 秒(本次 ${Math.round(timeout / 1000)} 秒):它只回一个布尔值,卡住时要等满窗口且事后还得再查。改用 wait_until 的 agent 条件,卡住即返回并带状态快照:
|
|
902
|
+
wait_until({conditions:[{kind:"agent",target:"<name>",until:"inactive"}], timeout_sec:600, stall_after_sec:300})`
|
|
903
|
+
}
|
|
904
|
+
return undefined
|
|
905
|
+
}
|
|
906
|
+
|
|
907
|
+
/** 进入模型系统提示词的等待纪律,放在 jobs 工具说明之后。 */
|
|
908
|
+
const WAIT_UNTIL_POLICY = `# 等待纪律
|
|
909
|
+
需要等待后台作业、队友、文件或日志时,不要用 sleep 空等或反复轮询:
|
|
910
|
+
- bash 中前台 sleep 达到 ${SLEEP_DENY_SEC} 秒会被拒绝;job_output / wait_agent 单次等待超过 ${LONG_WAIT_DENY_MS / 1000} 秒也会被拒绝。
|
|
911
|
+
- 改用 wait_until:条件满足、目标失败或连续无进展时立即返回 status / elapsed / failure_reason / output_tail,一次调用即可判定结果,无需再查一次。
|
|
912
|
+
${WAIT_UNTIL_USAGE_HINT}
|
|
913
|
+
长等待用 timeout_sec + stall_after_sec 分段,而不是一次设 30 分钟。`
|
|
914
|
+
|
|
915
|
+
/**
|
|
916
|
+
* 注册 `wait_until` 工具、等待纪律提示词,以及两条行为拦截。
|
|
917
|
+
* @param {object} ctx - 插件上下文(tools / jobs / workingDirectory)。
|
|
918
|
+
*/
|
|
919
|
+
export function apply(ctx) {
|
|
920
|
+
// 第一层:决策前的规则。模型在生成「等 180 秒」这个念头之前就应看到它。
|
|
921
|
+
const prompt = ctx.get('systemPrompt')
|
|
922
|
+
if (prompt !== undefined) {
|
|
923
|
+
ctx.effect(() => prompt.section({
|
|
924
|
+
name: 'tool:wait-until',
|
|
925
|
+
order: prompt.getSectionOrder('TOOL_JOBS') + 50,
|
|
926
|
+
text: WAIT_UNTIL_POLICY,
|
|
927
|
+
interpolate: false,
|
|
928
|
+
}), 'tool-wait-until.policy()')
|
|
929
|
+
}
|
|
930
|
+
|
|
931
|
+
// 第二层:决策落地时的硬拦截。即使模型仍决定空等,调用会被拒绝并给出替代路径。
|
|
932
|
+
if (typeof ctx.tools.guard === 'function') {
|
|
933
|
+
ctx.effect(() => ctx.tools.guard(guardSleepWait), 'tool-wait-until.guard.sleep()')
|
|
934
|
+
ctx.effect(() => ctx.tools.guard(guardLongToolWait), 'tool-wait-until.guard.long-wait()')
|
|
935
|
+
}
|
|
936
|
+
|
|
937
|
+
// 第三层:替代原语本身——事件驱动的等待。
|
|
938
|
+
ctx.tools.register({
|
|
939
|
+
name: 'wait_until',
|
|
940
|
+
description: '事件驱动的等待:等到目标完成、失败或卡住就立刻返回,替代「长超时 + sleep 轮询 + 再查一轮」。适用于后台作业(job_output 的 id)、团队成员、文件出现/变化、日志正则命中。返回统一的 status(completed/failed/stalled/timeout/cancelled)、elapsed、failure_reason、output_tail,一次调用即可判定结果,无需二次查询。作业事件会立即唤醒,不受 poll_interval_sec 限制。',
|
|
941
|
+
parameters: WAIT_UNTIL_PARAMETERS,
|
|
942
|
+
output: {
|
|
943
|
+
schema: WAIT_UNTIL_RESULT_SCHEMA,
|
|
944
|
+
render: renderWaitUntilResult,
|
|
945
|
+
},
|
|
946
|
+
// 等待类工具不独占调度,可以与其它工具并行。
|
|
947
|
+
isConcurrencySafe: () => true,
|
|
948
|
+
// 工具级兜底:略大于最长总超时,避免被调用超时策略先截断。
|
|
949
|
+
timeoutMs: MAX_TIMEOUT_SEC * 1000 + 60000,
|
|
950
|
+
async execute(args, exec) {
|
|
951
|
+
return await runWaitUntil(ctx, args, exec)
|
|
952
|
+
},
|
|
953
|
+
})
|
|
954
|
+
}
|
package/locale/en.json
ADDED
package/locale/zh.json
ADDED
package/package.json
CHANGED
|
@@ -1,6 +1,52 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "dsh-tool-wait-until",
|
|
3
|
-
"version": "
|
|
4
|
-
"
|
|
5
|
-
"
|
|
6
|
-
|
|
3
|
+
"version": "1.0.1",
|
|
4
|
+
"description": "Event-driven wait for DeepSeek Harness: returns the moment the target completes, fails early, or stalls — instead of burning a full timeout and querying again. Also blocks sleep-polling and over-long waits.",
|
|
5
|
+
"keywords": [
|
|
6
|
+
"dsh",
|
|
7
|
+
"dsh-plugin",
|
|
8
|
+
"deepseek-harness",
|
|
9
|
+
"wait",
|
|
10
|
+
"timeout",
|
|
11
|
+
"background-jobs",
|
|
12
|
+
"stall-detection",
|
|
13
|
+
"polling",
|
|
14
|
+
"agent-tools"
|
|
15
|
+
],
|
|
16
|
+
"license": "MIT",
|
|
17
|
+
"author": "tongshuanglong <tongsh6@gmail.com>",
|
|
18
|
+
"repository": {
|
|
19
|
+
"type": "git",
|
|
20
|
+
"url": "git+https://github.com/tongsh6/dsh-tool-wait-until.git"
|
|
21
|
+
},
|
|
22
|
+
"homepage": "https://github.com/tongsh6/dsh-tool-wait-until#readme",
|
|
23
|
+
"bugs": {
|
|
24
|
+
"url": "https://github.com/tongsh6/dsh-tool-wait-until/issues"
|
|
25
|
+
},
|
|
26
|
+
"type": "module",
|
|
27
|
+
"engines": {
|
|
28
|
+
"node": ">=20",
|
|
29
|
+
"dsh": ">=0.2.1-rc.1 <0.3.0"
|
|
30
|
+
},
|
|
31
|
+
"exports": {
|
|
32
|
+
".": "./index.js",
|
|
33
|
+
"./locale/*.json": "./locale/*.json",
|
|
34
|
+
"./icon": "./icon.svg"
|
|
35
|
+
},
|
|
36
|
+
"files": [
|
|
37
|
+
"index.js",
|
|
38
|
+
"cordis.patch.yml",
|
|
39
|
+
"locale/*.json",
|
|
40
|
+
"icon.svg",
|
|
41
|
+
"README.md",
|
|
42
|
+
"LICENSE"
|
|
43
|
+
],
|
|
44
|
+
"dsh": {
|
|
45
|
+
"bundle": {
|
|
46
|
+
"patch": "./cordis.patch.yml"
|
|
47
|
+
}
|
|
48
|
+
},
|
|
49
|
+
"publishConfig": {
|
|
50
|
+
"access": "public"
|
|
51
|
+
}
|
|
52
|
+
}
|