@xcanwin/manyoyo 8.6.6 → 8.6.7
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/lib/web/AGENTS.md +4 -0
- package/lib/web/agent-queue.js +150 -0
- package/lib/web/agent-run-control.js +124 -0
- package/lib/web/index.html +80 -80
- package/lib/web/server.js +689 -292
- package/package.json +1 -1
package/lib/web/AGENTS.md
CHANGED
|
@@ -12,9 +12,13 @@
|
|
|
12
12
|
- **`/agent/stream` 的 NDJSON 事件协议是跨前后端的契约**,改一处必须同步另一处:服务端 `lib/web/server.js`、前端 `frontend/src/lib/api.ts` 的 `StreamEvent` + `workspace-panel.tsx` 的事件分支
|
|
13
13
|
- `content_chunk`:token 级增量,前端**追加**;`reset: true` 表示换了一条 assistant 消息、从空白重新开始。只发新增片段,不要改成重发累计全文(几千个增量就是 O(n²) 流量)。
|
|
14
14
|
- `content_delta`:每条 assistant 消息落地时下发一次的权威全文,前端**整体覆盖**。
|
|
15
|
+
- `queue_next`(新增):这一轮结束后服务端接着发排队的下一条,前端收到后结束这条流、改走恢复轮询(见下面“追加提示词”)。
|
|
15
16
|
- `trace`:**不保证带结构化 `traceEvent`**。stderr 行(`emitStderrTraceLine`)和解析不出 JSON 的 stdout 行只有 `text` + `stream`,前端靠 `toTraceEvent()` 兜底成 `kind="output"` 的事件展示。新增只认 `traceEvent` 的分支等于把自定义 CLI、CLI 报错的输出全吞掉。
|
|
16
17
|
- `ping`:保活心跳,前端忽略。`DEFAULT_AGENT_STREAM_HEARTBEAT_MS` 必须明显小于反向代理的空闲读超时(nginx `proxy_read_timeout` 默认 60s),否则 agent 静默期间流会被 RST,浏览器侧表现为输入框上方冒出 `network error`。
|
|
17
18
|
- token 级增量**不写**事件日志、历史落盘按 `AGENT_STREAM_PARTIAL_PERSIST_INTERVAL_MS` 节流;新增逐事件的落盘/日志动作前先想清楚它会不会被每个 token 触发一次。
|
|
19
|
+
- **追加提示词(排队)**:任务进行中用户可以追加提示词,队列存在 `agentSession.queue`(`lib/web/agent-queue.js`,最多 10 条,`when` 为 `now` / `step` / `turn`;`normalizeWebAgentSession` 的字段白名单里要有它,否则落盘后丢失)。接口 `POST /api/sessions/:name/agent/queue`(clientMessageId 幂等;容器被别的会话占着返回 409)、`DELETE .../agent/queue/:id`、`POST .../agent/queue/continue`;`/messages` 带 `queue`。一轮对话的完整流程抽成 `runWebAgentStreamTurn(..., res)`:前端不在线时传 `createHeadlessResponse()` 顶替 `res`,事件照常落盘。每一轮结束(完成 / 失败 / 被追加打断,**用户点停止除外**,由 `stopWebAgentRun` 置 `runState.userStopped`,并把 `agentSession.queuePaused` **落盘**——只记在内存里的话,serve 重启或用户下一次正常发送、那一轮结束后,停止前的排队会被自动发出去;暂停只有 `POST .../agent/queue/continue` 能解除,队列清空时自动解除,`/messages` 带 `queuePaused`)后若队列可以自动发(`agentQueue.shouldAutoDispatch`),先给连着的流发一个 `queue_next` 事件(前端据此结束这条流、改走恢复轮询),再 `setImmediate` 调 `dispatchNextQueued`(同步取出队首并占锁,开不起来就放回队首)。`now` 立即走进程树信号;`step` 在 `isStepBoundary`(一次工具 / 子 Agent / 说明完成)处打断,`agentStepFallbackMs`(默认 120 秒)等不到就退化成 `now`。serve 重启后 `startContainerServices` 末尾调 `resumeQueuedAgentTurns`,把留在队列里(暂停的除外)又没有任务在跑的内容当作 `turn` 发出;发之前用 `buildLiveRunsCommand`(只读,校验规则同停止脚本)看容器里还有没有活着的 Agent 进程,有就不发、排队留给用户——那可能是上一个 serve 的孤儿任务,也可能是同一 HOME 下另一个 serve 实例正在跑的任务,所以只检测不杀。
|
|
20
|
+
- **停止 / 打断必须进到容器里发信号**(`lib/web/agent-run-control.js`):只杀宿主机上的 `docker exec` 客户端,容器里的 Agent 进程会继续跑(实测)。命令被包成 `echo $$ > /tmp/manyoyo-run-<id>.pid; <命令>; exit $?`,**末尾的 `exit $?` 不能省**——命令是最后一条简单命令时 bash 会 exec 替换自己,`/proc/<pid>/cmdline` 里的 `manyoyo-run-<id>.pid` 标记就没了,停止时会因“认不出进程”而不杀(真实容器测出来的,单测里用 `sleep 61; true` 测不到)。PID 文件容器里可写,按不可信输入处理(纯数字、普通文件、命令行带标记才发信号),再按子孙进程树逐级 INT → TERM → KILL。真实的 claude 收到 SIGINT 会正常收尾、**退出码为 0**,所以 `interrupted` 看“发过信号”(`runState.signaled`)而不只是退出码。已验证 Claude 打断后 `-r <id>` 能接上上下文;Codex / Gemini / OpenCode 没有环境验证,拿不到引擎会话 id 时走既有的历史注入降级。
|
|
21
|
+
- **子 Agent**:Claude 的 `Agent`(旧名 `Task`)工具生成 `kind: "subagent"` 事件,子 Agent 里的事件带 `parentToolId`(取自 `parent_tool_use_id`);带 `parent_tool_use_id` 的 assistant 文本不当作主 Agent 的回复。`--forward-subagent-text` 只在 `claude --help` 里有该参数时才加(命令里就地探测,约 50ms,不缓存)。样本 fixture:`test/fixtures/claude-subagent.ndjson`。
|
|
18
22
|
- **历史读写在 `lib/web/history-store.js`**:进程内缓存 + 原子写(`writeConfigFileSecure`,0600)+ 合并写盘。读靠 `stat` 的 `ino + mtimeMs + size` 判断缓存是否还有效(别的进程原子替换也能识别);`loadWebSessionHistory` 返回的是**缓存里的同一个对象**,改了必须 `saveWebSessionHistory`,只读路由不要改它(`getWebAgentSession(..., { create: true })` 会在缓存里留一个未保存的空 agent,只在马上要写的路径里用)。流式过程中的 trace patch 和控制事件走 `{ defer: true }`(只改内存,最迟 `AGENT_STREAM_PARTIAL_PERSIST_INTERVAL_MS` 后落盘);`process.exit` / serve 关闭会 flush;`result` / `error` 类事件(`WEB_STREAM_FLUSH_EVENT_TYPES`)和所有非 defer 写入立即写盘并带上攒着的内容。`kill -9` 最多丢约 1 秒的 trace。文件解析失败会留一份 `.corrupt-<时间>` 备份再按空历史处理,不要绕过它。磁盘格式只有 `agents.<id>.messages`(不再有顶层 `messages`,紧凑 JSON),老版本(含 `update --rollback`)读取时优先用 `agents`,能读新文件。
|
|
19
23
|
- `GET /api/sessions` 以前是同步 IO 大户(逐容器读 + 解析整份历史),现在命中缓存基本是 0 次解析,但新增的同步操作仍要先实测;`buildSessionSummary()` 支持传入调用方加载好的 history,不要重复读。(`docker ps -a` + 批量 `inspect` 实测仅约 90ms,不是瓶颈)
|
|
20
24
|
- **`/messages` 是分页 + 增量**(`lib/web/messages-view.js`):`?limit=`(1~200,默认 60,起点往前对齐到 user 消息)/ `?after=<id>&epoch=<e>`(tail,锚点之后的消息;epoch 不符、锚点不存在或超过 120 条时返回最近一页并标 `mode: "reset"`)/ `?before=<id>`(更早一页);`GET /messages/<id>/trace` 取完整执行过程。查询参数全部白名单校验,非法返回 400。**稳定前缀约定**:第一条 pending 消息之前的消息不再被修改——违反约定的写操作(删除非 pending 消息、克隆)必须给该 agent 换新的 `epoch`(`removeWebSessionMessage` 与 clone 已处理;新增修改历史消息的写操作要自己判断)。500 条上限从头裁掉**不换** epoch(tail 响应带 `firstId`,前端据此裁掉本地被丢弃的开头)。列表里的 trace 消息会被精简(长字符串截成 200 字符,带 `traceTruncated`),`/audit` 与 trace 接口输出完整内容。
|
|
@@ -0,0 +1,150 @@
|
|
|
1
|
+
'use strict';
|
|
2
|
+
|
|
3
|
+
// 任务进行中追加的提示词队列:存在 agentSession.queue 里,随历史落盘。
|
|
4
|
+
// 只放纯函数,调用方负责 saveWebSessionHistory。
|
|
5
|
+
|
|
6
|
+
const crypto = require('crypto');
|
|
7
|
+
|
|
8
|
+
const QUEUE_MAX = 10;
|
|
9
|
+
const QUEUE_WHEN = ['now', 'step', 'turn'];
|
|
10
|
+
const STEP_FALLBACK_MS = 120 * 1000;
|
|
11
|
+
// 这些 kind 完成不代表“一步”结束(会话 / 回合 / 错误)
|
|
12
|
+
const NON_STEP_KINDS = new Set(['thread', 'turn', 'error']);
|
|
13
|
+
|
|
14
|
+
function normalizeWhen(value) {
|
|
15
|
+
return QUEUE_WHEN.includes(value) ? value : null;
|
|
16
|
+
}
|
|
17
|
+
|
|
18
|
+
function getQueue(agentSession) {
|
|
19
|
+
if (!agentSession) {
|
|
20
|
+
return [];
|
|
21
|
+
}
|
|
22
|
+
if (!Array.isArray(agentSession.queue)) {
|
|
23
|
+
agentSession.queue = [];
|
|
24
|
+
}
|
|
25
|
+
return agentSession.queue;
|
|
26
|
+
}
|
|
27
|
+
|
|
28
|
+
function publicQueue(agentSession) {
|
|
29
|
+
const queue = agentSession && Array.isArray(agentSession.queue) ? agentSession.queue : [];
|
|
30
|
+
return queue.map(item => ({
|
|
31
|
+
id: item.id,
|
|
32
|
+
clientMessageId: item.clientMessageId,
|
|
33
|
+
text: item.text,
|
|
34
|
+
when: item.when,
|
|
35
|
+
createdAt: item.createdAt
|
|
36
|
+
}));
|
|
37
|
+
}
|
|
38
|
+
|
|
39
|
+
// now / step 排在所有 turn 之前(它们之间保持先来先到);turn 排到队尾
|
|
40
|
+
function enqueue(agentSession, { clientMessageId, text, when }) {
|
|
41
|
+
const queue = getQueue(agentSession);
|
|
42
|
+
if (clientMessageId) {
|
|
43
|
+
const existing = queue.find(item => item.clientMessageId === clientMessageId);
|
|
44
|
+
if (existing) {
|
|
45
|
+
return { ok: true, duplicate: true, item: existing };
|
|
46
|
+
}
|
|
47
|
+
}
|
|
48
|
+
if (queue.length >= QUEUE_MAX) {
|
|
49
|
+
return { ok: false, full: true };
|
|
50
|
+
}
|
|
51
|
+
const item = {
|
|
52
|
+
id: `q-${crypto.randomBytes(6).toString('hex')}`,
|
|
53
|
+
clientMessageId: clientMessageId || '',
|
|
54
|
+
text,
|
|
55
|
+
when,
|
|
56
|
+
createdAt: new Date().toISOString()
|
|
57
|
+
};
|
|
58
|
+
if (when === 'turn') {
|
|
59
|
+
queue.push(item);
|
|
60
|
+
} else {
|
|
61
|
+
const firstTurn = queue.findIndex(entry => entry.when === 'turn');
|
|
62
|
+
queue.splice(firstTurn === -1 ? queue.length : firstTurn, 0, item);
|
|
63
|
+
}
|
|
64
|
+
return { ok: true, duplicate: false, item };
|
|
65
|
+
}
|
|
66
|
+
|
|
67
|
+
// 用户点停止时暂停:落盘在 agentSession.queuePaused,serve 重启、下一轮结束后都不自动发,
|
|
68
|
+
// 只有“继续发送”才恢复。队列清空时暂停一并解除(之后新排的提示词照常自动发)
|
|
69
|
+
function isPaused(agentSession) {
|
|
70
|
+
return Boolean(agentSession && agentSession.queuePaused === true && getQueue(agentSession).length);
|
|
71
|
+
}
|
|
72
|
+
|
|
73
|
+
function pauseQueue(agentSession) {
|
|
74
|
+
if (agentSession && getQueue(agentSession).length) agentSession.queuePaused = true;
|
|
75
|
+
}
|
|
76
|
+
|
|
77
|
+
function resumeQueue(agentSession) {
|
|
78
|
+
if (agentSession) agentSession.queuePaused = false;
|
|
79
|
+
}
|
|
80
|
+
|
|
81
|
+
function shouldAutoDispatch(agentSession) {
|
|
82
|
+
return getQueue(agentSession).length > 0 && !isPaused(agentSession);
|
|
83
|
+
}
|
|
84
|
+
|
|
85
|
+
function clearPauseIfEmpty(agentSession) {
|
|
86
|
+
if (agentSession && !getQueue(agentSession).length) agentSession.queuePaused = false;
|
|
87
|
+
}
|
|
88
|
+
|
|
89
|
+
function removeFromQueue(agentSession, id) {
|
|
90
|
+
const queue = getQueue(agentSession);
|
|
91
|
+
const index = queue.findIndex(item => item.id === id);
|
|
92
|
+
if (index === -1) {
|
|
93
|
+
return null;
|
|
94
|
+
}
|
|
95
|
+
const removed = queue.splice(index, 1)[0];
|
|
96
|
+
clearPauseIfEmpty(agentSession);
|
|
97
|
+
return removed;
|
|
98
|
+
}
|
|
99
|
+
|
|
100
|
+
function takeNext(agentSession) {
|
|
101
|
+
const queue = getQueue(agentSession);
|
|
102
|
+
const item = queue.length ? queue.shift() : null;
|
|
103
|
+
clearPauseIfEmpty(agentSession);
|
|
104
|
+
return item;
|
|
105
|
+
}
|
|
106
|
+
|
|
107
|
+
// 一次工具调用 / 子 Agent 结束,或一条 assistant 消息完整落地
|
|
108
|
+
function isStepBoundary(event) {
|
|
109
|
+
const traceEvent = event && event.type === 'trace' ? event.traceEvent : null;
|
|
110
|
+
return Boolean(traceEvent)
|
|
111
|
+
&& typeof traceEvent.kind === 'string'
|
|
112
|
+
&& !NON_STEP_KINDS.has(traceEvent.kind)
|
|
113
|
+
&& traceEvent.phase === 'completed';
|
|
114
|
+
}
|
|
115
|
+
|
|
116
|
+
// 从磁盘读回时的白名单:只留结构合法的条目
|
|
117
|
+
function normalizeStoredQueue(value) {
|
|
118
|
+
if (!Array.isArray(value)) {
|
|
119
|
+
return [];
|
|
120
|
+
}
|
|
121
|
+
return value
|
|
122
|
+
.filter(item => item && typeof item === 'object' && typeof item.id === 'string' && typeof item.text === 'string'
|
|
123
|
+
&& item.text.trim() && QUEUE_WHEN.includes(item.when))
|
|
124
|
+
.slice(0, QUEUE_MAX)
|
|
125
|
+
.map(item => ({
|
|
126
|
+
id: item.id,
|
|
127
|
+
clientMessageId: typeof item.clientMessageId === 'string' ? item.clientMessageId : '',
|
|
128
|
+
text: item.text,
|
|
129
|
+
when: item.when,
|
|
130
|
+
createdAt: typeof item.createdAt === 'string' ? item.createdAt : new Date().toISOString()
|
|
131
|
+
}));
|
|
132
|
+
}
|
|
133
|
+
|
|
134
|
+
module.exports = {
|
|
135
|
+
normalizeStoredQueue,
|
|
136
|
+
QUEUE_MAX,
|
|
137
|
+
QUEUE_WHEN,
|
|
138
|
+
STEP_FALLBACK_MS,
|
|
139
|
+
normalizeWhen,
|
|
140
|
+
getQueue,
|
|
141
|
+
publicQueue,
|
|
142
|
+
enqueue,
|
|
143
|
+
removeFromQueue,
|
|
144
|
+
takeNext,
|
|
145
|
+
isStepBoundary,
|
|
146
|
+
isPaused,
|
|
147
|
+
pauseQueue,
|
|
148
|
+
resumeQueue,
|
|
149
|
+
shouldAutoDispatch
|
|
150
|
+
};
|
|
@@ -0,0 +1,124 @@
|
|
|
1
|
+
'use strict';
|
|
2
|
+
|
|
3
|
+
// 停止 / 打断容器里的 Agent 进程。
|
|
4
|
+
// 宿主机上的 `docker exec` 客户端被杀后,容器里的进程常常还活着(实测:kill 客户端后 `sleep 600` 仍在),
|
|
5
|
+
// 所以要进到容器里,按进程组发信号:INT(让这一轮干净结束)→ TERM → KILL,最后才收宿主机上的客户端。
|
|
6
|
+
//
|
|
7
|
+
// 约定:命令前面加一句把自己的 PID 写进容器里的 /tmp/manyoyo-run-<id>.pid(wrapAgentCommand),再运行真正的命令;
|
|
8
|
+
// 这条命令行本身就带着 `manyoyo-run-<id>.pid`,所以 /proc/<pid>/cmdline 就是“这个进程属于这次运行”的标记。
|
|
9
|
+
// PID 文件在容器里、容器内进程可写,按不可信输入处理:内容必须是纯数字,并且该进程的环境里
|
|
10
|
+
// 命令行里必须有本次运行的标记,才会发信号(见 RUN_KILL_SCRIPT)。
|
|
11
|
+
|
|
12
|
+
const crypto = require('crypto');
|
|
13
|
+
|
|
14
|
+
const RUN_ID_PATTERN = /^[A-Za-z0-9-]{8,64}$/;
|
|
15
|
+
const KILL_SIGNALS = new Set(['INT', 'TERM', 'KILL']);
|
|
16
|
+
const DEFAULT_GRACE_MS = 3000;
|
|
17
|
+
|
|
18
|
+
function isValidRunId(value) {
|
|
19
|
+
return typeof value === 'string' && RUN_ID_PATTERN.test(value);
|
|
20
|
+
}
|
|
21
|
+
|
|
22
|
+
function createRunId() {
|
|
23
|
+
return crypto.randomUUID();
|
|
24
|
+
}
|
|
25
|
+
|
|
26
|
+
function pidFilePath(runId) {
|
|
27
|
+
return `/tmp/manyoyo-run-${runId}.pid`;
|
|
28
|
+
}
|
|
29
|
+
|
|
30
|
+
// 仍然是 `/bin/bash -lc <命令>` 的单个字符串参数(原命令原样保留,不套引号)。
|
|
31
|
+
// 容器里的 exec 进程不是进程组长(实测 PGID/SID 都是 0),所以停的时候走子孙进程树,不依赖进程组
|
|
32
|
+
function wrapAgentCommand(command, runId) {
|
|
33
|
+
if (!isValidRunId(runId)) throw new Error(`runId 非法: ${runId}`);
|
|
34
|
+
// 末尾的 `exit $?` 不能省:命令是最后一条简单命令时 bash 会 exec 替换自己,命令行里的标记随之消失,停止时就认不出这个进程
|
|
35
|
+
return `echo $$ > ${pidFilePath(runId)}; ${command}; exit $?`;
|
|
36
|
+
}
|
|
37
|
+
|
|
38
|
+
|
|
39
|
+
// 在容器里执行(参数:$1 = runId,$2 = 信号)。只认纯数字 PID、普通文件(不跟符号链接)、
|
|
40
|
+
// 且进程命令行里带本次的 manyoyo-run-<id>.pid 标记;先发给整个进程组,失败再发给进程本身
|
|
41
|
+
const RUN_KILL_SCRIPT = [
|
|
42
|
+
'f="/tmp/manyoyo-run-$1.pid"',
|
|
43
|
+
'[ -f "$f" ] && [ ! -L "$f" ] || exit 0',
|
|
44
|
+
'pid="$(head -c 16 "$f" 2>/dev/null | tr -d "\\n")"',
|
|
45
|
+
'case "$pid" in ""|*[!0-9]*) exit 3;; esac',
|
|
46
|
+
'[ "$pid" -gt 1 ] || exit 3',
|
|
47
|
+
'tr "\\0" " " < "/proc/$pid/cmdline" 2>/dev/null | grep -qF "manyoyo-run-$1.pid" || exit 4',
|
|
48
|
+
// 本进程和它的全部子孙(agent 派生的子进程);先收集再发信号
|
|
49
|
+
'descendants() { for c in $(ps -eo pid=,ppid= | awk -v p="$1" \'$2 == p { print $1 }\'); do echo "$c"; descendants "$c"; done; }',
|
|
50
|
+
'all="$pid $(descendants "$pid")"',
|
|
51
|
+
'for p in $all; do kill -"$2" "$p" 2>/dev/null; done',
|
|
52
|
+
'exit 0'
|
|
53
|
+
].join('\n');
|
|
54
|
+
|
|
55
|
+
function buildRunKillCommand(runId, signal) {
|
|
56
|
+
if (!isValidRunId(runId)) throw new Error(`runId 非法: ${runId}`);
|
|
57
|
+
if (!KILL_SIGNALS.has(signal)) throw new Error(`信号不允许: ${signal}`);
|
|
58
|
+
return ['/bin/sh', '-c', RUN_KILL_SCRIPT, 'sh', runId, signal];
|
|
59
|
+
}
|
|
60
|
+
|
|
61
|
+
// 列出容器里还活着的运行 id(每行一个)。和 RUN_KILL_SCRIPT 同样的校验:文件名里的 id 合法、普通文件、
|
|
62
|
+
// PID 是纯数字且大于 1、进程命令行里带这次运行的标记。只读,不发任何信号:
|
|
63
|
+
// 同一个 HOME 可能有另一个 serve 实例在用这个容器,重启后的自动处理不能去杀别人的任务
|
|
64
|
+
const LIVE_RUNS_SCRIPT = [
|
|
65
|
+
'for f in /tmp/manyoyo-run-*.pid; do',
|
|
66
|
+
' [ -f "$f" ] && [ ! -L "$f" ] || continue',
|
|
67
|
+
' id="${f#/tmp/manyoyo-run-}"; id="${id%.pid}"',
|
|
68
|
+
' case "$id" in ""|*[!A-Za-z0-9-]*) continue;; esac',
|
|
69
|
+
' pid="$(head -c 16 "$f" 2>/dev/null | tr -d "\\n")"',
|
|
70
|
+
' case "$pid" in ""|*[!0-9]*) continue;; esac',
|
|
71
|
+
' [ "$pid" -gt 1 ] || continue',
|
|
72
|
+
' tr "\\0" " " < "/proc/$pid/cmdline" 2>/dev/null | grep -qF "manyoyo-run-$id.pid" || continue',
|
|
73
|
+
' echo "$id"',
|
|
74
|
+
'done',
|
|
75
|
+
'exit 0'
|
|
76
|
+
].join('\n');
|
|
77
|
+
|
|
78
|
+
function buildLiveRunsCommand() {
|
|
79
|
+
return ['/bin/sh', '-c', LIVE_RUNS_SCRIPT];
|
|
80
|
+
}
|
|
81
|
+
|
|
82
|
+
/**
|
|
83
|
+
* INT → TERM → KILL 逐级升级,每级等 graceMs,进程退出就停;最后兜底收宿主机上的客户端。
|
|
84
|
+
* @param {{ runState: object, killInContainer: (runId: string, signal: string) => Promise<void>|void, sleep?: Function, graceMs?: number }} options
|
|
85
|
+
* runState.closed 是 Promise(进程 close 时 resolve),runState.process 是宿主机上的客户端子进程
|
|
86
|
+
*/
|
|
87
|
+
async function terminateRun({ runState, killInContainer, graceMs = DEFAULT_GRACE_MS, signals = ['INT', 'TERM', 'KILL'] }) {
|
|
88
|
+
const exited = () => Promise.race([
|
|
89
|
+
runState.closed.then(() => true),
|
|
90
|
+
new Promise(resolve => {
|
|
91
|
+
const timer = setTimeout(() => resolve(false), graceMs);
|
|
92
|
+
if (typeof timer.unref === 'function') timer.unref();
|
|
93
|
+
})
|
|
94
|
+
]);
|
|
95
|
+
if (isValidRunId(runState.runId)) {
|
|
96
|
+
for (const signal of signals) {
|
|
97
|
+
try {
|
|
98
|
+
await killInContainer(runState.runId, signal);
|
|
99
|
+
} catch (error) {
|
|
100
|
+
// 容器可能已经没了:继续往下收客户端
|
|
101
|
+
}
|
|
102
|
+
if (await exited()) return 'exited';
|
|
103
|
+
}
|
|
104
|
+
}
|
|
105
|
+
try {
|
|
106
|
+
if (runState.process) runState.process.kill('SIGKILL');
|
|
107
|
+
} catch (error) {
|
|
108
|
+
// 进程可能已退出
|
|
109
|
+
}
|
|
110
|
+
return 'client-killed';
|
|
111
|
+
}
|
|
112
|
+
|
|
113
|
+
module.exports = {
|
|
114
|
+
RUN_ID_PATTERN,
|
|
115
|
+
RUN_KILL_SCRIPT,
|
|
116
|
+
DEFAULT_GRACE_MS,
|
|
117
|
+
isValidRunId,
|
|
118
|
+
createRunId,
|
|
119
|
+
wrapAgentCommand,
|
|
120
|
+
buildRunKillCommand,
|
|
121
|
+
buildLiveRunsCommand,
|
|
122
|
+
pidFilePath,
|
|
123
|
+
terminateRun
|
|
124
|
+
};
|