@migor/cli 0.9.1 → 0.9.3
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/CHANGELOG.md +187 -5
- package/dist/AGENTS.md +15 -2
- package/dist/dev-child.js +84 -0
- package/dist/dev-logic.js +46 -0
- package/dist/dev-machine.js +489 -0
- package/dist/dev-runner.js +8 -1
- package/dist/dev-watch.js +223 -0
- package/dist/dev.js +377 -626
- package/dist/inspector-page.html +295 -17
- package/dist/inspector-routes.js +530 -0
- package/dist/inspector.js +44 -418
- package/dist/markdown.js +262 -0
- package/dist/native-pick.js +168 -0
- package/dist/panel-logic.js +64 -0
- package/dist/templates/package.json +2 -2
- package/dist/templates/src/app.ts +3 -0
- package/dist/templates/src/dev.config.ts +5 -0
- package/dist/templates/src/registry.ts +5 -0
- package/dist/templates/src/session-store.ts +11 -2
- package/dist/templates/src/tools/read-file/index.ts +50 -3
- package/package.json +1 -1
|
@@ -0,0 +1,489 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* dev 环的**显式状态机** —— 方案 docs/plans/2026-09-23-cli-structure.md §3 B 的落点。
|
|
3
|
+
*
|
|
4
|
+
* 这里收编的是 `dev.ts` 里那台隐式状态机(曾经:15 个 `let`、40 处写入点、15 个入口,
|
|
5
|
+
* F1/F4/G1/G2 四条真缺陷全是它的直接后果)。现在**所有「什么情况下该做什么」的判定
|
|
6
|
+
* 都在本文件的 `update()` 里**;`dev.ts` 只剩执行 effects 的薄接线(spawn / kill /
|
|
7
|
+
* IPC / HTTP / fs 这些真副作用)。
|
|
8
|
+
*
|
|
9
|
+
* 纯件纪律(与 dev-logic 同档):零副作用、零 `node:*` import ⇒ 单测直接 import dist 产物。
|
|
10
|
+
* 判据**不另写一份**:受理闸 / 延后重启 / 退出原因 / 中止幂等 / 选择器串行 / 能力比较
|
|
11
|
+
* 仍调 `dev-logic` 的单源(相位先折算回那对布尔 —— `runGateFlags`),多轮缺省与菜单归一
|
|
12
|
+
* 走 `panel-logic`。dev-logic.test.mjs 钉判据本身,dev-machine.test.mjs 钉迁移规则。
|
|
13
|
+
*
|
|
14
|
+
* 关键建模决策(与旧闭包变量的对应):
|
|
15
|
+
* - `child` 与 `run` 是**两个独立相位**:「runner 在不在」和「run 在不在飞」本就正交
|
|
16
|
+
* (启动窗口内可以没有 run;在飞 run 的 runner 可以暴毙)。
|
|
17
|
+
* - `run: 'launching'` 是**类型里的一相**(旧的 `launching` 占位布尔)⇒ 受理闸写成
|
|
18
|
+
* 「非 idle 即拒」,F1 那类「漏看一个布尔」在结构上写不出来。
|
|
19
|
+
* - `run: 'aborting'` 吸收旧的 `abortTimer !== null`:中止幂等判定就是相位判定。
|
|
20
|
+
* - `errBaseline` 从 spawnChild 的局部 const **升为状态字段**(G1:「这一代有没有写出
|
|
21
|
+
* 新原因」这个判据从此有名字、有单测)。
|
|
22
|
+
* - 实际进程句柄(ChildProcess / 计时器 / watcher)**不进状态** —— 它们不可序列化,
|
|
23
|
+
* 由接线层持有;状态里只有「相位 + 数据」。
|
|
24
|
+
*/
|
|
25
|
+
import { DEFAULT_BUDGET, DEV_SESSION_ID, } from './dev-protocol.js';
|
|
26
|
+
import { abortDecision, canAcceptRun, exitReason, pickGate, sameToolSources, shouldDeferRestart, } from './dev-logic.js';
|
|
27
|
+
import { multiTurnDefault, normalizeToolSources } from './panel-logic.js';
|
|
28
|
+
/** 子进程就绪的等待上限(消息文本进状态 ⇒ 常量由本文件持有,接线层回引) */
|
|
29
|
+
export const READY_TIMEOUT_MS = 60_000;
|
|
30
|
+
/**
|
|
31
|
+
* 「中止」发出后等它优雅收尾的宽限:超时就重启进程兜底。取 5s —— 优雅路径要在
|
|
32
|
+
* **回合边界**才生效(在飞的模型请求先被取消),给足一个回合;再长就等于按钮按了没反应。
|
|
33
|
+
*/
|
|
34
|
+
export const ABORT_GRACE_MS = 5_000;
|
|
35
|
+
/** 初始状态(sessionId 由接线层从盘上读出来后传入 —— 读盘是副作用,不进机器) */
|
|
36
|
+
export function createInitialState(init) {
|
|
37
|
+
return {
|
|
38
|
+
projectRoot: init.projectRoot,
|
|
39
|
+
child: 'absent',
|
|
40
|
+
run: 'idle',
|
|
41
|
+
toolSources: null,
|
|
42
|
+
capabilities: [],
|
|
43
|
+
multiTurn: [],
|
|
44
|
+
defaultWorkdir: init.projectRoot,
|
|
45
|
+
budget: { ...DEFAULT_BUDGET },
|
|
46
|
+
warning: null,
|
|
47
|
+
sessionId: init.sessionId,
|
|
48
|
+
pendingRestart: null,
|
|
49
|
+
pendingNote: null,
|
|
50
|
+
lastError: null,
|
|
51
|
+
errBaseline: null,
|
|
52
|
+
picking: false,
|
|
53
|
+
closing: false,
|
|
54
|
+
};
|
|
55
|
+
}
|
|
56
|
+
/** 「清空对话」换到的下一个 id:`dev` → `dev-2` → …(从 dev.ts 迁来;语义与注释见其测试) */
|
|
57
|
+
export function nextSessionId(current, base = DEV_SESSION_ID) {
|
|
58
|
+
// base 是**参数**(导出为单测用)⇒ 进正则前先转义
|
|
59
|
+
const esc = base.replace(/[.*+?^${}()|[\]\\]/g, '\\$&');
|
|
60
|
+
const m = new RegExp(`^${esc}(?:-(\\d+))?$`).exec(current);
|
|
61
|
+
if (m === null)
|
|
62
|
+
return `${base}-2`;
|
|
63
|
+
const n = m[1] === undefined ? 1 : Number(m[1]);
|
|
64
|
+
return `${base}-${n + 1}`;
|
|
65
|
+
}
|
|
66
|
+
/** 工作目录解析:空值回落缺省目录(接线层拿结果去查 fs,再把事实交回机器判 400) */
|
|
67
|
+
export function resolveWorkdir(req, defaultWorkdir) {
|
|
68
|
+
return req.workdir !== undefined && req.workdir.length > 0 ? req.workdir : defaultWorkdir;
|
|
69
|
+
}
|
|
70
|
+
/**
|
|
71
|
+
* 相位 → dev-logic 那对布尔的折算。dev-logic 的判据(F1 的闸、延后重启)是按
|
|
72
|
+
* `{running, launching}` 写的单源;机器不复制条件,只负责折算 ——
|
|
73
|
+
* `aborting` 算 running(旧代码里中止宽限期内 `running` 一直是 true)。
|
|
74
|
+
*/
|
|
75
|
+
export function runGateFlags(state) {
|
|
76
|
+
return {
|
|
77
|
+
running: state.run === 'running' || state.run === 'aborting',
|
|
78
|
+
launching: state.run === 'launching',
|
|
79
|
+
};
|
|
80
|
+
}
|
|
81
|
+
/** 「没等到 ready 就退」的通用文案(机器与接线层的过时进程兜底共用一份,不各写) */
|
|
82
|
+
export function startupExitMessage(how) {
|
|
83
|
+
return `dev runner 退出(${how})—— 多半是用户代码 import 期就抛了,看上面的输出`;
|
|
84
|
+
}
|
|
85
|
+
/** run 相位落下:running/aborting → idle;**launching 不动**(launch 流程自己拥有它 ——
|
|
86
|
+
* 旧代码里这些路径写的是 `running = false`,对 launching 原本就是无操作) */
|
|
87
|
+
function idleUnlessLaunching(run) {
|
|
88
|
+
return run === 'running' || run === 'aborting' ? 'idle' : run;
|
|
89
|
+
}
|
|
90
|
+
/** run 收尾后处理「在飞期间攒下的重启请求」(旧 `afterRun`;关机中不重启) */
|
|
91
|
+
function afterRun(state, effects) {
|
|
92
|
+
if (state.pendingRestart === null || state.closing)
|
|
93
|
+
return state;
|
|
94
|
+
effects.push({ kind: 'restart', reason: state.pendingRestart, toolSources: state.toolSources });
|
|
95
|
+
return { ...state, pendingRestart: null };
|
|
96
|
+
}
|
|
97
|
+
/**
|
|
98
|
+
* 状态机本体:纯函数,同一份 `(state, ev)` 永远给出同一份 `(state, effects)`。
|
|
99
|
+
* 不 mutate 入参(返回新对象),不改写句柄 —— 句柄根本不在状态里。
|
|
100
|
+
*/
|
|
101
|
+
export function update(state, ev) {
|
|
102
|
+
switch (ev.type) {
|
|
103
|
+
case 'boot':
|
|
104
|
+
return { state, effects: [{ kind: 'spawn-child', toolSources: null }] };
|
|
105
|
+
case 'child-spawned':
|
|
106
|
+
// 每一代从干净的事实开始:菜单/预算/告警是初值(ready 再报真值),
|
|
107
|
+
// errBaseline 记下此刻的 lastError(G1:「这一代有没有写出新原因」的基线)
|
|
108
|
+
return {
|
|
109
|
+
state: {
|
|
110
|
+
...state,
|
|
111
|
+
child: 'starting',
|
|
112
|
+
toolSources: ev.toolSources,
|
|
113
|
+
capabilities: [],
|
|
114
|
+
multiTurn: [],
|
|
115
|
+
defaultWorkdir: state.projectRoot,
|
|
116
|
+
budget: { ...DEFAULT_BUDGET },
|
|
117
|
+
warning: null,
|
|
118
|
+
errBaseline: state.lastError,
|
|
119
|
+
},
|
|
120
|
+
effects: [],
|
|
121
|
+
};
|
|
122
|
+
case 'child-spawn-failed':
|
|
123
|
+
// spawn 失败('error' 事件):不会有 exit。摘掉当代(旧代码 fail() 的硬要求:
|
|
124
|
+
// 留着死 handle ⇒ 下次 POST /run 往断通道 send ⇒ running 卡死 ⇒ 面板锁死)
|
|
125
|
+
return {
|
|
126
|
+
state: {
|
|
127
|
+
...state,
|
|
128
|
+
child: 'absent',
|
|
129
|
+
toolSources: null,
|
|
130
|
+
lastError: `启动 dev runner 失败:${ev.message}`,
|
|
131
|
+
},
|
|
132
|
+
effects: [],
|
|
133
|
+
};
|
|
134
|
+
case 'child-ready-timeout':
|
|
135
|
+
return {
|
|
136
|
+
state: {
|
|
137
|
+
...state,
|
|
138
|
+
child: 'absent',
|
|
139
|
+
toolSources: null,
|
|
140
|
+
lastError: `dev runner 启动超时(${READY_TIMEOUT_MS} ms 内没有就绪信号)`,
|
|
141
|
+
},
|
|
142
|
+
effects: [{ kind: 'kill-child' }],
|
|
143
|
+
};
|
|
144
|
+
case 'child-ready': {
|
|
145
|
+
const effects = [];
|
|
146
|
+
if (ev.warning !== null) {
|
|
147
|
+
effects.push({ kind: 'log', level: 'warn', message: `[agentia] ${ev.warning}` });
|
|
148
|
+
}
|
|
149
|
+
// 广播「就绪」:面板加载时那次 /api/dev 拿到的还是初值(空菜单),没有这条它就一直空着
|
|
150
|
+
effects.push({ kind: 'emit', event: { kind: 'runner-ready' } });
|
|
151
|
+
return {
|
|
152
|
+
state: {
|
|
153
|
+
...state,
|
|
154
|
+
child: 'ready',
|
|
155
|
+
capabilities: ev.capabilities,
|
|
156
|
+
multiTurn: ev.multiTurn,
|
|
157
|
+
defaultWorkdir: ev.defaultWorkdir,
|
|
158
|
+
budget: ev.budget,
|
|
159
|
+
warning: ev.warning,
|
|
160
|
+
},
|
|
161
|
+
effects,
|
|
162
|
+
};
|
|
163
|
+
}
|
|
164
|
+
case 'child-exit': {
|
|
165
|
+
if (state.child === 'starting') {
|
|
166
|
+
// 没等到 ready 就退 = **启动失败**。⚠️ 这里刻意**不 emit**(F4):发射权在等
|
|
167
|
+
// spawn 的那一侧 —— 它会以 restart-failed / boot-failed 发出唯一的那一帧;
|
|
168
|
+
// 两边都走就是同一条消息广播两次。
|
|
169
|
+
const reason = exitReason({
|
|
170
|
+
lastError: state.lastError,
|
|
171
|
+
errBaseline: state.errBaseline,
|
|
172
|
+
generic: startupExitMessage(ev.how),
|
|
173
|
+
});
|
|
174
|
+
return {
|
|
175
|
+
state: {
|
|
176
|
+
...state,
|
|
177
|
+
child: 'absent',
|
|
178
|
+
toolSources: null,
|
|
179
|
+
lastError: reason,
|
|
180
|
+
run: idleUnlessLaunching(state.run),
|
|
181
|
+
},
|
|
182
|
+
effects: [{ kind: 'disarm-abort-timer' }],
|
|
183
|
+
};
|
|
184
|
+
}
|
|
185
|
+
if (state.child === 'ready') {
|
|
186
|
+
// 关机中:摘掉当代就够了,不归因、不广播(旧代码 `if (!isCurrent || closing) return`)
|
|
187
|
+
if (state.closing) {
|
|
188
|
+
return { state: { ...state, child: 'absent', toolSources: null }, effects: [] };
|
|
189
|
+
}
|
|
190
|
+
// 跑着跑着意外退出:归因同 G1 判据(这一代没写出新原因才用通用文案)
|
|
191
|
+
const lastError = exitReason({
|
|
192
|
+
lastError: state.lastError,
|
|
193
|
+
errBaseline: state.errBaseline,
|
|
194
|
+
generic: `dev runner 意外退出(${ev.how})`,
|
|
195
|
+
});
|
|
196
|
+
return {
|
|
197
|
+
state: {
|
|
198
|
+
...state,
|
|
199
|
+
child: 'absent',
|
|
200
|
+
toolSources: null,
|
|
201
|
+
lastError,
|
|
202
|
+
run: idleUnlessLaunching(state.run),
|
|
203
|
+
},
|
|
204
|
+
effects: [
|
|
205
|
+
{ kind: 'disarm-abort-timer' },
|
|
206
|
+
{ kind: 'emit', event: { kind: 'runner-error', message: lastError } },
|
|
207
|
+
],
|
|
208
|
+
};
|
|
209
|
+
}
|
|
210
|
+
return { state, effects: [] };
|
|
211
|
+
}
|
|
212
|
+
case 'child-stopped':
|
|
213
|
+
// 主动停(restart / shutdown):不可能再有在飞 run ⇒ run 相位落下。
|
|
214
|
+
// 旧代码这里漏过 running=false,症状是「中止超时 ⇒ 重启兜底」之后面板永久 409。
|
|
215
|
+
return {
|
|
216
|
+
state: {
|
|
217
|
+
...state,
|
|
218
|
+
child: 'absent',
|
|
219
|
+
toolSources: null,
|
|
220
|
+
run: idleUnlessLaunching(state.run),
|
|
221
|
+
},
|
|
222
|
+
effects: [{ kind: 'disarm-abort-timer' }],
|
|
223
|
+
};
|
|
224
|
+
case 'ipc-run-start':
|
|
225
|
+
return { state: { ...state, run: 'running' }, effects: [] };
|
|
226
|
+
case 'ipc-run-done': {
|
|
227
|
+
const note = state.pendingNote;
|
|
228
|
+
let lastError = state.lastError;
|
|
229
|
+
// 中止的 run 也带 error(abortedResult 刻意如此)⇒ 只看 stopReason 判「环坏了」
|
|
230
|
+
if (!ev.ok && ev.stopReason !== 'aborted')
|
|
231
|
+
lastError = ev.error;
|
|
232
|
+
// 成功 ⇒ 环是好的,把旧失败从告警条摘下(也是 G1 所需的清零点)
|
|
233
|
+
if (ev.ok)
|
|
234
|
+
lastError = null;
|
|
235
|
+
const effects = [{ kind: 'disarm-abort-timer' }];
|
|
236
|
+
if (ev.traceId !== null && note !== null) {
|
|
237
|
+
effects.push({ kind: 'note-run', traceId: ev.traceId, note });
|
|
238
|
+
}
|
|
239
|
+
effects.push({
|
|
240
|
+
kind: 'emit',
|
|
241
|
+
event: {
|
|
242
|
+
kind: 'run-done',
|
|
243
|
+
traceId: ev.traceId,
|
|
244
|
+
ok: ev.ok,
|
|
245
|
+
stopReason: ev.stopReason,
|
|
246
|
+
error: ev.error,
|
|
247
|
+
finalText: ev.finalText,
|
|
248
|
+
},
|
|
249
|
+
});
|
|
250
|
+
const next = afterRun({ ...state, run: 'idle', pendingNote: null, lastError }, effects);
|
|
251
|
+
return { state: next, effects };
|
|
252
|
+
}
|
|
253
|
+
case 'ipc-run-error': {
|
|
254
|
+
const effects = [
|
|
255
|
+
{ kind: 'disarm-abort-timer' },
|
|
256
|
+
{ kind: 'emit', event: { kind: 'runner-error', message: ev.message } },
|
|
257
|
+
];
|
|
258
|
+
const next = afterRun({ ...state, run: 'idle', pendingNote: null, lastError: ev.message }, effects);
|
|
259
|
+
return { state: next, effects };
|
|
260
|
+
}
|
|
261
|
+
case 'ui-run-requested': {
|
|
262
|
+
// 闸的判据单源在 dev-logic.canAcceptRun(F1:launching 窄窗口必须拒)
|
|
263
|
+
if (!canAcceptRun(runGateFlags(state))) {
|
|
264
|
+
return {
|
|
265
|
+
state,
|
|
266
|
+
effects: [
|
|
267
|
+
{
|
|
268
|
+
kind: 'reject',
|
|
269
|
+
status: 409,
|
|
270
|
+
message: '上一次 run 还在跑。等它结束,或先「中止」它再发新的(并发语义见计划 §6 待定 5)',
|
|
271
|
+
},
|
|
272
|
+
],
|
|
273
|
+
};
|
|
274
|
+
}
|
|
275
|
+
// 与旧 submitRun 同序:闸之后才清上一轮的兜底计时器、才判目录(400)
|
|
276
|
+
if (!ev.workdirExists) {
|
|
277
|
+
return {
|
|
278
|
+
state,
|
|
279
|
+
effects: [
|
|
280
|
+
{ kind: 'disarm-abort-timer' },
|
|
281
|
+
{
|
|
282
|
+
kind: 'reject',
|
|
283
|
+
status: 400,
|
|
284
|
+
message: `工作目录不存在或不是文件夹:${ev.workdir}`,
|
|
285
|
+
},
|
|
286
|
+
],
|
|
287
|
+
};
|
|
288
|
+
}
|
|
289
|
+
const caps = state.capabilities;
|
|
290
|
+
const toolSources = normalizeToolSources(ev.req.toolSources ?? caps, caps);
|
|
291
|
+
const multiTurn = ev.req.multiTurn ?? multiTurnDefault(toolSources ?? caps, state.multiTurn).value;
|
|
292
|
+
const note = {
|
|
293
|
+
prompt: ev.req.prompt,
|
|
294
|
+
workdir: ev.workdir,
|
|
295
|
+
toolSources: toolSources ?? null,
|
|
296
|
+
multiTurn,
|
|
297
|
+
};
|
|
298
|
+
// 换能力选择 ⇒ 重启进程(D8:进程边界是孤儿 MCP 子进程的唯一回收口);
|
|
299
|
+
// 子进程不在 ⇒ 同样要先起
|
|
300
|
+
const needRestart = state.child === 'absent' || !sameToolSources(state.toolSources, toolSources);
|
|
301
|
+
const effects = [{ kind: 'disarm-abort-timer' }];
|
|
302
|
+
if (needRestart) {
|
|
303
|
+
effects.push({
|
|
304
|
+
kind: 'restart',
|
|
305
|
+
reason: '能力选择变化(toolSources)',
|
|
306
|
+
toolSources: toolSources ?? null,
|
|
307
|
+
});
|
|
308
|
+
}
|
|
309
|
+
// 会话 id 由父进程给:它是面板级状态,且必须跨 runner 重启稳定
|
|
310
|
+
effects.push({
|
|
311
|
+
kind: 'send-ipc',
|
|
312
|
+
message: {
|
|
313
|
+
type: 'run',
|
|
314
|
+
request: {
|
|
315
|
+
prompt: ev.req.prompt,
|
|
316
|
+
workdir: ev.workdir,
|
|
317
|
+
multiTurn,
|
|
318
|
+
sessionId: state.sessionId,
|
|
319
|
+
toolSources: toolSources ?? null,
|
|
320
|
+
},
|
|
321
|
+
},
|
|
322
|
+
});
|
|
323
|
+
// launching 占位:从这里到「run 发出」之间,受理闸必须把第二个请求挡在 409
|
|
324
|
+
return { state: { ...state, run: 'launching', pendingNote: note }, effects };
|
|
325
|
+
}
|
|
326
|
+
case 'run-launched': {
|
|
327
|
+
const note = state.pendingNote;
|
|
328
|
+
return {
|
|
329
|
+
state: { ...state, run: 'running' },
|
|
330
|
+
effects: note === null
|
|
331
|
+
? []
|
|
332
|
+
: [
|
|
333
|
+
{
|
|
334
|
+
kind: 'emit',
|
|
335
|
+
event: {
|
|
336
|
+
kind: 'run-start',
|
|
337
|
+
prompt: note.prompt,
|
|
338
|
+
workdir: note.workdir,
|
|
339
|
+
toolSources: note.toolSources,
|
|
340
|
+
multiTurn: note.multiTurn,
|
|
341
|
+
},
|
|
342
|
+
},
|
|
343
|
+
],
|
|
344
|
+
};
|
|
345
|
+
}
|
|
346
|
+
case 'run-launch-failed': {
|
|
347
|
+
// 任何一步失败都必须复位两处:run 相位(留着 ⇒ 面板永久 409)与 pendingNote
|
|
348
|
+
// (留着 ⇒ 挂到下一轮 run 的 traceId 上,张冠李戴)。没发成功 ⇒ 延后重启在这里
|
|
349
|
+
// 立刻补上(旧 finally 的职责;发成功的情形由 run-done 的 afterRun 接手)。
|
|
350
|
+
const effects = [];
|
|
351
|
+
const next = afterRun({ ...state, run: 'idle', pendingNote: null }, effects);
|
|
352
|
+
effects.push({ kind: 'reject', status: 500, message: ev.message });
|
|
353
|
+
return { state: next, effects };
|
|
354
|
+
}
|
|
355
|
+
case 'ui-abort-requested': {
|
|
356
|
+
if (!runGateFlags(state).running || state.child === 'absent') {
|
|
357
|
+
return {
|
|
358
|
+
state,
|
|
359
|
+
effects: [{ kind: 'reject', status: 409, message: '当前没有在飞的 run' }],
|
|
360
|
+
};
|
|
361
|
+
}
|
|
362
|
+
// 幂等(单源在 dev-logic.abortDecision):升级等待中再点一次,不挂第二个计时器
|
|
363
|
+
if (abortDecision({ hasTimer: state.run === 'aborting' }) === 'idempotent') {
|
|
364
|
+
return { state, effects: [] };
|
|
365
|
+
}
|
|
366
|
+
return {
|
|
367
|
+
state: { ...state, run: 'aborting' },
|
|
368
|
+
effects: [
|
|
369
|
+
{ kind: 'send-ipc', message: { type: 'run-abort' } },
|
|
370
|
+
{ kind: 'arm-abort-timer', ms: ABORT_GRACE_MS },
|
|
371
|
+
],
|
|
372
|
+
};
|
|
373
|
+
}
|
|
374
|
+
case 'abort-send-failed':
|
|
375
|
+
// 中止没发出去 ⇒ run 还在正常飞,相位退回(否则闸会一直 409,却没人能再中止)
|
|
376
|
+
return { state: { ...state, run: 'running' }, effects: [] };
|
|
377
|
+
case 'abort-grace-expired':
|
|
378
|
+
// 兜底计时器只可能在 aborting 相位到点;否则是已被 disarm 的残余(防御性忽略)
|
|
379
|
+
if (state.run !== 'aborting')
|
|
380
|
+
return { state, effects: [] };
|
|
381
|
+
return {
|
|
382
|
+
state: {
|
|
383
|
+
...state,
|
|
384
|
+
run: 'idle',
|
|
385
|
+
// 记进 lastError:被掐掉的 run 是个「发生过的事」,不该只闪一下就没了
|
|
386
|
+
lastError: '中止超时(工具不响应 signal)⇒ 已重启 runner 兜底:这次 run 的 trace 丢了',
|
|
387
|
+
},
|
|
388
|
+
effects: [
|
|
389
|
+
{
|
|
390
|
+
kind: 'restart',
|
|
391
|
+
reason: '中止超时:run 不响应 signal(工具可能不可取消)',
|
|
392
|
+
toolSources: state.toolSources,
|
|
393
|
+
},
|
|
394
|
+
],
|
|
395
|
+
};
|
|
396
|
+
case 'ui-clear-session': {
|
|
397
|
+
// 与旧代码同口径:只看「在飞」(running/aborting)——launching 窗口内允许
|
|
398
|
+
// (sessionId 在 send-ipc 那一刻才读,换 id 会落到新 run 上)
|
|
399
|
+
if (runGateFlags(state).running) {
|
|
400
|
+
return {
|
|
401
|
+
state,
|
|
402
|
+
effects: [
|
|
403
|
+
{
|
|
404
|
+
kind: 'reject',
|
|
405
|
+
status: 409,
|
|
406
|
+
message: '有 run 在飞时不能清空对话(它会写回当前会话)。先中止或等它结束',
|
|
407
|
+
},
|
|
408
|
+
],
|
|
409
|
+
};
|
|
410
|
+
}
|
|
411
|
+
const id = nextSessionId(state.sessionId);
|
|
412
|
+
return {
|
|
413
|
+
state: { ...state, sessionId: id },
|
|
414
|
+
effects: [{ kind: 'persist-session-id', id }],
|
|
415
|
+
};
|
|
416
|
+
}
|
|
417
|
+
case 'ui-pick-requested':
|
|
418
|
+
if (pickGate({ inFlight: state.picking }) === 'reject') {
|
|
419
|
+
return {
|
|
420
|
+
state,
|
|
421
|
+
effects: [
|
|
422
|
+
{
|
|
423
|
+
kind: 'reject',
|
|
424
|
+
status: 409,
|
|
425
|
+
message: '已有一个文件夹选择框在等 —— 先把它选完或取消',
|
|
426
|
+
},
|
|
427
|
+
],
|
|
428
|
+
};
|
|
429
|
+
}
|
|
430
|
+
return { state: { ...state, picking: true }, effects: [{ kind: 'pick-folder' }] };
|
|
431
|
+
case 'ui-pick-resolved':
|
|
432
|
+
return { state: { ...state, picking: false }, effects: [] };
|
|
433
|
+
case 'ui-pick-failed':
|
|
434
|
+
return {
|
|
435
|
+
state: { ...state, picking: false },
|
|
436
|
+
effects: [{ kind: 'reject', status: 501, message: ev.message }],
|
|
437
|
+
};
|
|
438
|
+
case 'ui-pick-cancelled':
|
|
439
|
+
// G2 的出口:客户端断开 ⇒ 收掉在飞选择框(kill 之后 native 侧 resolve null ⇒
|
|
440
|
+
// ui-pick-resolved 把 picking 落下来)。没在飞时它是空操作(旧代码同样无条件 kill)
|
|
441
|
+
return { state, effects: state.picking ? [{ kind: 'kill-pickers' }] : [] };
|
|
442
|
+
case 'file-changed': {
|
|
443
|
+
if (state.closing)
|
|
444
|
+
return { state, effects: [] };
|
|
445
|
+
const reason = `文件变更:${ev.rel}`;
|
|
446
|
+
// 在飞(含 launching 窄窗口)时重启会把刚 send 出去的 run 连进程一起 TERM 掉(F1)——
|
|
447
|
+
// 判据与受理闸同一条单源(shouldDeferRestart 委托 canAcceptRun)
|
|
448
|
+
if (shouldDeferRestart(runGateFlags(state))) {
|
|
449
|
+
return { state: { ...state, pendingRestart: reason }, effects: [] };
|
|
450
|
+
}
|
|
451
|
+
return { state, effects: [{ kind: 'restart', reason, toolSources: state.toolSources }] };
|
|
452
|
+
}
|
|
453
|
+
case 'restart-failed':
|
|
454
|
+
// restart 链上 spawn 失败的**唯一** emit 点(F4:child-exit 的 starting 分支不 emit)
|
|
455
|
+
return {
|
|
456
|
+
state: { ...state, lastError: ev.message },
|
|
457
|
+
effects: [{ kind: 'emit', event: { kind: 'runner-error', message: ev.message } }],
|
|
458
|
+
};
|
|
459
|
+
case 'boot-failed':
|
|
460
|
+
// 首次 spawn 失败的**唯一** emit 点(同上),外加终端一行 error(首次启动没人看面板)
|
|
461
|
+
return {
|
|
462
|
+
state: { ...state, lastError: ev.message },
|
|
463
|
+
effects: [
|
|
464
|
+
{ kind: 'log', level: 'error', message: `[agentia] ${ev.message}` },
|
|
465
|
+
{ kind: 'emit', event: { kind: 'runner-error', message: ev.message } },
|
|
466
|
+
],
|
|
467
|
+
};
|
|
468
|
+
case 'trace-event':
|
|
469
|
+
// 增量记账事件原样广播:父进程不做任何解释(折回是面板的活,插一手就多一处会漂的口径)
|
|
470
|
+
return {
|
|
471
|
+
state,
|
|
472
|
+
effects: [{ kind: 'emit', event: { kind: 'trace-event', event: ev.event } }],
|
|
473
|
+
};
|
|
474
|
+
case 'shutdown':
|
|
475
|
+
if (state.closing) {
|
|
476
|
+
// 第二次 Ctrl+C:立即硬退(不让用户面对一个按不动的 Ctrl+C)
|
|
477
|
+
return { state, effects: [{ kind: 'exit-now' }] };
|
|
478
|
+
}
|
|
479
|
+
return {
|
|
480
|
+
state: { ...state, closing: true },
|
|
481
|
+
effects: [
|
|
482
|
+
{ kind: 'stop-watchers' },
|
|
483
|
+
{ kind: 'kill-pickers' },
|
|
484
|
+
{ kind: 'stop-child' },
|
|
485
|
+
{ kind: 'close-inspector' },
|
|
486
|
+
],
|
|
487
|
+
};
|
|
488
|
+
}
|
|
489
|
+
}
|
package/dist/dev-runner.js
CHANGED
|
@@ -278,8 +278,15 @@ async function build() {
|
|
|
278
278
|
workdir,
|
|
279
279
|
});
|
|
280
280
|
const { names, warning: menuWarning } = listCapabilities(app, mod.CAPABILITY_DIRS);
|
|
281
|
+
// multiTurn 里写了不存在的能力名 ⇒ 静默落空(该能力永远按单轮跑,用户却以为声明生效了)。
|
|
282
|
+
// 求差集、走 warning 通道响亮说清。⚠️ 菜单本身没起来(menuWarning 在场)时**不判** ——
|
|
283
|
+
// 那时 names 是空的,把每个 multiTurn 条目都报成「不认识」是噪声,真原因已在 menuWarning 里。
|
|
284
|
+
const unknownTurns = (config.multiTurn ?? []).filter((n) => !names.includes(n));
|
|
285
|
+
const turnWarning = menuWarning === null && unknownTurns.length > 0
|
|
286
|
+
? `${DEV_CONFIG_REL} 的 multiTurn 里有不认识的能力:${unknownTurns.join('、')} —— 是不是改名了?`
|
|
287
|
+
: null;
|
|
281
288
|
const sessionWarning = await probeSessionFile(mod);
|
|
282
|
-
const warnings = [configWarning, menuWarning, sessionWarning].filter((w) => w !== null);
|
|
289
|
+
const warnings = [configWarning, menuWarning, turnWarning, sessionWarning].filter((w) => w !== null);
|
|
283
290
|
return {
|
|
284
291
|
app,
|
|
285
292
|
capabilities: names,
|