@harness-mix/cli 0.2.1 → 0.2.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 +16 -0
- package/package.json +14 -9
- package/scripts/core-review-test.cjs +68 -0
- package/scripts/delegation-await-test.cjs +76 -0
- package/scripts/jsonl-stdin-test.cjs +31 -0
- package/scripts/native-protocol-test.cjs +14 -1
- package/scripts/send-cancel-race-test.cjs +80 -0
- package/scripts/send-pre-turn-cancel-test.cjs +100 -0
- package/scripts/stuck-turn-test.cjs +6 -1
- package/src/main/adapters/antigravity.js +3 -0
- package/src/main/harness-adapter/event-normalizer.js +5 -2
- package/src/main/host/jsonl.js +4 -0
- package/src/main/host/runtime.js +83 -25
- package/src/main/native/host.js +2 -0
- package/src/main/native/protocol.js +16 -2
- package/src/main/native/secure-store.js +2 -0
- package/src/main/workspace/core-review.js +13 -5
package/CHANGELOG.md
CHANGED
|
@@ -1,5 +1,21 @@
|
|
|
1
1
|
# Changelog
|
|
2
2
|
|
|
3
|
+
## 0.2.3 — 2026-09-18
|
|
4
|
+
|
|
5
|
+
- Turn transcripts now fold correctly again: the wire `phase` for `agentMessage` items uses Codex's own vocabulary — `commentary` / `final_answer` — which the Desktop matches literally when extracting the final answer and collapsing the execution zone into the elapsed-time bar. 0.2.1/0.2.2 sent the internal `progress` / `final` values, which the Desktop does not recognize, so completed turns stayed expanded. The internal vocabulary is unchanged and translated at the projection boundary (historic persisted items map through the same function), and the final message segment is explicitly closed at turn settle so its phase reaches the Desktop on the wire. 修复回合转录折叠:agentMessage 的 phase 改用 Codex 原生词表(commentary/final_answer),完成后过程内容正确折叠进「用时」栏。
|
|
6
|
+
|
|
7
|
+
- Send/cancel race hardening: the per-thread send lock is now ticket-owned. Stopping a turn mid-stream frees the thread for an immediate resend, but the old send's exit path can no longer delete the newer send's lock (which previously let a third submission run concurrently and trip the native harness's "already processing" error); pending cancel requests are likewise scoped to the send generation they target. 取消-重发竞态修复:发送锁改为票据所有制,旧发送退出时不再误删新发送的锁。
|
|
8
|
+
- Stuck-turn watchdog now cancels the native session when it settles a wedged turn, instead of only settling the UI: a zombie native process no longer blocks the thread's next turn with an occupancy error. 卡死回合看门狗结算时级联取消原生会话,避免僵尸进程占用后续回合。
|
|
9
|
+
- `/delegate` waits for the child task to actually settle (up to 30 minutes, aligned with collaboration orchestration, injectable as `delegationTimeoutMs`) and cancels the child on timeout with an explicit parent-side error — previously a non-blocking harness child (e.g. Pi) running longer than 30 seconds was silently reported as done with its result dropped. 委派等待修复:子任务异步结算前父线程不再提前误判成功。
|
|
10
|
+
- Forked threads now broadcast `thread/started`, so a fork appears in the Desktop sidebar immediately instead of only after a reload. Fork 分支会话即时显示在侧边栏。
|
|
11
|
+
- Multi-select question answers from the Desktop are preserved in full (JSON-encoded) instead of being truncated to the first option, fixing `JSON.parse` crashes on OpenCode multiple-choice questions; empty answer arrays now resolve to `''` rather than `undefined`. 多选答案全量保留,空数组回退为空字符串。
|
|
12
|
+
- Workspace review attribution: files this turn's own tools touched win over historical "foreign session" attribution, so same-directory sessions' old edits no longer hide this turn's legitimate changes from the review card and undo list. 审查归属修复:本轮触碰的文件不再被历史 foreign 归属误剔。
|
|
13
|
+
- Child-process stdin streams (`jsonl` transports, the DPAPI secure-store helper, the Antigravity CLI, and the official app-server pipe) now swallow asynchronous EPIPE stream errors instead of crashing the host process with an unhandled exception. 子进程 stdin 管道错误不再导致宿主崩溃。
|
|
14
|
+
|
|
15
|
+
## 0.2.2 — 2026-09-18
|
|
16
|
+
|
|
17
|
+
- Aligns all six `@harness-mix/native-*` runtime packages at 0.2.2 with the CLI and pins `optionalDependencies` to the same version. 0.2.1 shipped with pins still at 0.1.11, so a fresh install's postinstall could overwrite the bundled fresh Shim binaries with the old-contract 0.1.11 ones; supersedes 0.2.1. Native binaries are rebuilt from unchanged Rust sources.
|
|
18
|
+
|
|
3
19
|
## 0.2.1 — 2026-09-18
|
|
4
20
|
|
|
5
21
|
- Turn transcripts now match Codex's native three-stage experience: opening remarks and inter-tool commentary stream live as progress-phase agent messages alongside command rows while the turn runs, and on completion Codex Desktop folds every progress segment and tool item under the elapsed-time bar, leaving only the final-phase conclusion visible. The previous buffer-and-reclassify-as-reasoning approach (which hid narration while running) is removed.
|
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "@harness-mix/cli",
|
|
3
|
-
"version": "0.2.
|
|
3
|
+
"version": "0.2.3",
|
|
4
4
|
"license": "(Apache-2.0 OR MIT)",
|
|
5
5
|
"description": "Harness Mix native Codex UI bridge for Codex, Pi, Claude Code, DeepSeek Harness, Antigravity, CodeBuddy, Kiro CLI, Cursor CLI and other native coding harnesses.",
|
|
6
6
|
"keywords": [
|
|
@@ -123,7 +123,7 @@
|
|
|
123
123
|
"test:core-concurrency": "node scripts/core-concurrency-test.cjs",
|
|
124
124
|
"test:thread-title": "node scripts/thread-title-test.cjs",
|
|
125
125
|
"test:handoff": "node scripts/handoff-checkpoint-test.cjs && node scripts/switch-harness-test.cjs",
|
|
126
|
-
"test:core-all": "node scripts/contracts-test.cjs && node scripts/projector-test.cjs && node scripts/turn-manager-test.cjs && node scripts/sequence-test.cjs && node scripts/shadow-test.cjs && node scripts/architecture-test.cjs && node scripts/adapters-test.cjs && node scripts/dsh-adapter-test.cjs && node scripts/codex-adapter-test.cjs && node scripts/codex-accounts-test.cjs && node scripts/antigravity-adapter-test.cjs && node scripts/openclaw-adapter-test.cjs && node scripts/core-replay-test.cjs && node scripts/determinism-test.cjs && node scripts/core-runtime-test.cjs && node scripts/core-services-test.cjs && node scripts/native-file-change-test.cjs && node scripts/native-updater-test.cjs && node scripts/native-update-apply-test.cjs && node scripts/native-vendor-adapters-test.cjs && node scripts/core-concurrency-test.cjs && node scripts/concurrency-layers-test.cjs && node scripts/tool-concurrency-regression-test.cjs && node scripts/handoff-checkpoint-test.cjs && node scripts/switch-harness-test.cjs && node scripts/native-acp-depth-test.cjs && node scripts/thread-title-test.cjs && node scripts/native-job-object-test.cjs && node scripts/integrations-test.cjs && node scripts/storage-verification-test.cjs && node scripts/stuck-turn-test.cjs && node scripts/pi-cancel-race-test.cjs",
|
|
126
|
+
"test:core-all": "node scripts/contracts-test.cjs && node scripts/projector-test.cjs && node scripts/turn-manager-test.cjs && node scripts/sequence-test.cjs && node scripts/shadow-test.cjs && node scripts/architecture-test.cjs && node scripts/adapters-test.cjs && node scripts/dsh-adapter-test.cjs && node scripts/codex-adapter-test.cjs && node scripts/codex-accounts-test.cjs && node scripts/antigravity-adapter-test.cjs && node scripts/openclaw-adapter-test.cjs && node scripts/core-replay-test.cjs && node scripts/determinism-test.cjs && node scripts/core-runtime-test.cjs && node scripts/core-services-test.cjs && node scripts/native-file-change-test.cjs && node scripts/native-updater-test.cjs && node scripts/native-update-apply-test.cjs && node scripts/native-vendor-adapters-test.cjs && node scripts/core-concurrency-test.cjs && node scripts/concurrency-layers-test.cjs && node scripts/tool-concurrency-regression-test.cjs && node scripts/handoff-checkpoint-test.cjs && node scripts/switch-harness-test.cjs && node scripts/native-acp-depth-test.cjs && node scripts/thread-title-test.cjs && node scripts/native-job-object-test.cjs && node scripts/integrations-test.cjs && node scripts/storage-verification-test.cjs && node scripts/stuck-turn-test.cjs && node scripts/pi-cancel-race-test.cjs && node scripts/send-cancel-race-test.cjs && node scripts/delegation-await-test.cjs && node scripts/core-review-test.cjs && node scripts/jsonl-stdin-test.cjs && node scripts/send-pre-turn-cancel-test.cjs",
|
|
127
127
|
"test:codex-accounts": "node scripts/codex-accounts-test.cjs",
|
|
128
128
|
"smoke:codex-accounts-ui": "electron scripts/codex-accounts-ui-smoke.cjs",
|
|
129
129
|
"test:transcript": "node scripts/transcript-test.cjs",
|
|
@@ -166,7 +166,12 @@
|
|
|
166
166
|
"test:shim": "node scripts/native-shim-test.cjs",
|
|
167
167
|
"publish:native": "node scripts/publish-native-package.cjs",
|
|
168
168
|
"test:stuck-turn": "node scripts/stuck-turn-test.cjs",
|
|
169
|
-
"test:pi-cancel-race": "node scripts/pi-cancel-race-test.cjs"
|
|
169
|
+
"test:pi-cancel-race": "node scripts/pi-cancel-race-test.cjs",
|
|
170
|
+
"test:send-cancel-race": "node scripts/send-cancel-race-test.cjs",
|
|
171
|
+
"test:delegation-await": "node scripts/delegation-await-test.cjs",
|
|
172
|
+
"test:core-review": "node scripts/core-review-test.cjs",
|
|
173
|
+
"test:jsonl-stdin": "node scripts/jsonl-stdin-test.cjs",
|
|
174
|
+
"test:send-pre-turn-cancel": "node scripts/send-pre-turn-cancel-test.cjs"
|
|
170
175
|
},
|
|
171
176
|
"devDependencies": {
|
|
172
177
|
"@types/node": "24.13.3",
|
|
@@ -182,11 +187,11 @@
|
|
|
182
187
|
"zod": "4.4.3"
|
|
183
188
|
},
|
|
184
189
|
"optionalDependencies": {
|
|
185
|
-
"@harness-mix/native-win32-x64": "0.
|
|
186
|
-
"@harness-mix/native-win32-arm64": "0.
|
|
187
|
-
"@harness-mix/native-darwin-x64": "0.
|
|
188
|
-
"@harness-mix/native-darwin-arm64": "0.
|
|
189
|
-
"@harness-mix/native-linux-x64": "0.
|
|
190
|
-
"@harness-mix/native-linux-arm64": "0.
|
|
190
|
+
"@harness-mix/native-win32-x64": "0.2.2",
|
|
191
|
+
"@harness-mix/native-win32-arm64": "0.2.2",
|
|
192
|
+
"@harness-mix/native-darwin-x64": "0.2.2",
|
|
193
|
+
"@harness-mix/native-darwin-arm64": "0.2.2",
|
|
194
|
+
"@harness-mix/native-linux-x64": "0.2.2",
|
|
195
|
+
"@harness-mix/native-linux-arm64": "0.2.2"
|
|
191
196
|
}
|
|
192
197
|
}
|
|
@@ -0,0 +1,68 @@
|
|
|
1
|
+
const assert = require('node:assert/strict');
|
|
2
|
+
const fs = require('node:fs/promises');
|
|
3
|
+
const os = require('node:os');
|
|
4
|
+
const path = require('node:path');
|
|
5
|
+
const { HostRuntime } = require('../src/main/host/runtime');
|
|
6
|
+
const { projectReview } = require('../src/main/workspace/core-review');
|
|
7
|
+
|
|
8
|
+
const sleep = ms => new Promise(resolve => setTimeout(resolve, ms));
|
|
9
|
+
async function until(fn) {
|
|
10
|
+
for (let i = 0; i < 300; i++) { if (fn()) return; await sleep(10); }
|
|
11
|
+
throw new Error('timed out');
|
|
12
|
+
}
|
|
13
|
+
|
|
14
|
+
// 审查归属(core-review foreignPaths):foreign 集合扫描同目录其他会话的历史全部轮次,
|
|
15
|
+
// 旧会话碰过的路径会永久滞留其中。本轮工具已明确触碰的文件属本会话自身的正向事实,
|
|
16
|
+
// 不得被 foreign 误剔——否则本轮编辑从审查卡片与撤回列表中消失。
|
|
17
|
+
(async () => {
|
|
18
|
+
const directory = await fs.mkdtemp(path.join(os.tmpdir(), 'hm-core-review-'));
|
|
19
|
+
const root = path.join(directory, 'workspace');
|
|
20
|
+
await fs.mkdir(root);
|
|
21
|
+
|
|
22
|
+
const rt = new HostRuntime({ dataDirectory: directory });
|
|
23
|
+
await rt.store.load();
|
|
24
|
+
|
|
25
|
+
const emitters = new Map();
|
|
26
|
+
rt.adapters.set('test', {
|
|
27
|
+
manifest: { id: 'test', name: 'Test', capabilities: {} },
|
|
28
|
+
async open(input) { emitters.set(input.thread.id, input.emit); return {}; },
|
|
29
|
+
async send() {}, async cancel() {}, async close() {},
|
|
30
|
+
});
|
|
31
|
+
rt.status.test = { available: true };
|
|
32
|
+
|
|
33
|
+
try {
|
|
34
|
+
// 线程 B(同目录旧会话):历史上编辑过 src/index.js 与 other.js
|
|
35
|
+
const threadB = await rt.createThread({ harnessId: 'test', cwd: root });
|
|
36
|
+
await rt.send(threadB.id, 'B 的历史编辑');
|
|
37
|
+
emitters.get(threadB.id)({ kind: 'tool', toolCallId: 'b1', title: 'Edit', state: 'completed', path: 'src/index.js', input: '{}' });
|
|
38
|
+
emitters.get(threadB.id)({ kind: 'tool', toolCallId: 'b2', title: 'Edit', state: 'completed', path: 'other.js', input: '{}' });
|
|
39
|
+
emitters.get(threadB.id)({ kind: 'completed', finalAnswer: true });
|
|
40
|
+
await until(() => !rt.execution.isRunning(threadB.id));
|
|
41
|
+
|
|
42
|
+
// 线程 A:本轮自己用工具编辑了 src/index.js(正向触碰证据),未碰 other.js
|
|
43
|
+
const threadA = await rt.createThread({ harnessId: 'test', cwd: root });
|
|
44
|
+
await rt.send(threadA.id, 'A 的本轮编辑');
|
|
45
|
+
const messageA = threadA.messages.at(-1);
|
|
46
|
+
assert.ok(messageA.coreTurnId, '本轮消息携带 Core Turn');
|
|
47
|
+
emitters.get(threadA.id)({ kind: 'tool', toolCallId: 'a1', title: 'Edit', state: 'completed', path: 'src/index.js', input: '{}' });
|
|
48
|
+
emitters.get(threadA.id)({ kind: 'completed', finalAnswer: true });
|
|
49
|
+
await until(() => !rt.execution.isRunning(threadA.id));
|
|
50
|
+
|
|
51
|
+
// 目录级快照同时捕到两个文件的变化(含 B 的历史编辑残留)
|
|
52
|
+
const record = { id: 'synthetic', at: Date.now(), endedAt: Date.now(), skipped: 0, concurrent: false, changes: [
|
|
53
|
+
{ path: 'src/index.js', before: { text: 'old index\n' }, after: { text: 'new index\n' }, added: 1, removed: 1 },
|
|
54
|
+
{ path: 'other.js', before: { text: 'old other\n' }, after: { text: 'new other\n' }, added: 1, removed: 1 },
|
|
55
|
+
] };
|
|
56
|
+
const summary = await projectReview(rt, threadA, messageA, record);
|
|
57
|
+
const paths = summary.files.map(f => f.path);
|
|
58
|
+
assert.ok(paths.includes('src/index.js'), '本轮触碰的文件不被 foreign 历史归属误剔');
|
|
59
|
+
assert.ok(!paths.includes('other.js'), '本轮未触碰的 foreign 文件仍被剔除');
|
|
60
|
+
|
|
61
|
+
console.log('core-review-test: this-turn touched paths win over historical foreign attribution; untouched foreign paths stay excluded');
|
|
62
|
+
} finally {
|
|
63
|
+
await rt.close();
|
|
64
|
+
}
|
|
65
|
+
})().catch(error => {
|
|
66
|
+
console.error(error);
|
|
67
|
+
process.exitCode = 1;
|
|
68
|
+
});
|
|
@@ -0,0 +1,76 @@
|
|
|
1
|
+
const assert = require('node:assert/strict');
|
|
2
|
+
const fs = require('node:fs/promises');
|
|
3
|
+
const os = require('node:os');
|
|
4
|
+
const path = require('node:path');
|
|
5
|
+
const { HostRuntime } = require('../src/main/host/runtime');
|
|
6
|
+
|
|
7
|
+
const sleep = ms => new Promise(resolve => setTimeout(resolve, ms));
|
|
8
|
+
async function until(fn, label = 'condition') {
|
|
9
|
+
for (let i = 0; i < 500; i++) { if (fn()) return; await sleep(10); }
|
|
10
|
+
throw new Error(`timed out: ${label}`);
|
|
11
|
+
}
|
|
12
|
+
|
|
13
|
+
// /delegate 委派等待链(#awaitDelegation):
|
|
14
|
+
// 非阻塞适配器(Pi 家族收到 prompt ack 即返回)的回合由异步事件流结算,
|
|
15
|
+
// 旧实现只兜底 30 秒——子任务超过 30 秒父线程就被误判成功且丢失结果。
|
|
16
|
+
// 修复后:等待至子任务真正结算(上限 delegationTimeoutMs,对齐协作编排 30 分钟),
|
|
17
|
+
// 超时主动取消子任务并向父线程回报错误。
|
|
18
|
+
(async () => {
|
|
19
|
+
const directory = await fs.mkdtemp(path.join(os.tmpdir(), 'hm-delegation-await-'));
|
|
20
|
+
const root = path.join(directory, 'workspace');
|
|
21
|
+
await fs.mkdir(root);
|
|
22
|
+
|
|
23
|
+
const rt = new HostRuntime({ dataDirectory: directory, delegationTimeoutMs: 600 });
|
|
24
|
+
await rt.store.load();
|
|
25
|
+
|
|
26
|
+
const emits = new Map();
|
|
27
|
+
const cancels = new Map();
|
|
28
|
+
const adapter = {
|
|
29
|
+
manifest: { id: 'test-harness', name: 'Test', capabilities: {} },
|
|
30
|
+
async open(input) { emits.set(input.thread.id, input.emit); return {}; },
|
|
31
|
+
// 非阻塞:ack 即返回,Turn 结算完全由后续 emit 驱动(Pi 家族形态)
|
|
32
|
+
async send(session) {},
|
|
33
|
+
async cancel(session) { cancels.set(session.threadId, (cancels.get(session.threadId) ?? 0) + 1); },
|
|
34
|
+
async close() {},
|
|
35
|
+
};
|
|
36
|
+
rt.adapters.set(adapter.manifest.id, adapter);
|
|
37
|
+
rt.status[adapter.manifest.id] = { available: true };
|
|
38
|
+
|
|
39
|
+
try {
|
|
40
|
+
// 1. 正常路径:adapter.send 早已返回,子任务经异步事件结算后答案回投父线程
|
|
41
|
+
const parent = await rt.createThread({ harnessId: 'test-harness', cwd: root });
|
|
42
|
+
const { child, turn } = await rt.delegateTask({ fromThreadId: parent.id, harnessId: 'test-harness', task: '干活' });
|
|
43
|
+
assert.equal(rt.execution.isRunning(parent.id), true, '父线程协作 Turn 运行中');
|
|
44
|
+
await sleep(150); // adapter.send 已返回而子任务未结算:父线程必须继续等待
|
|
45
|
+
assert.equal(rt.execution.isRunning(parent.id), true, '子任务未结算时父线程不得提前收尾');
|
|
46
|
+
emits.get(child.id)({ kind: 'text-delta', text: '子任务结论' });
|
|
47
|
+
emits.get(child.id)({ kind: 'completed', finalAnswer: true });
|
|
48
|
+
await until(() => !rt.execution.isRunning(parent.id), 'parent settles after child completes');
|
|
49
|
+
const doneItem = rt.core.getItemsForTurn(turn.id).find(i => i.type === 'tool_call');
|
|
50
|
+
assert.equal(doneItem.status, 'completed', '子任务成功后父线程协作工具项结算为完成');
|
|
51
|
+
assert.match(JSON.stringify(doneItem), /子任务结论/, '子任务最终文本回投到父线程');
|
|
52
|
+
assert.equal(cancels.get(child.id) ?? 0, 0, '正常结算不触发取消');
|
|
53
|
+
|
|
54
|
+
// 2. 超时路径:子任务永不结算且持续有事件(看门狗管不到),超过 delegationTimeoutMs
|
|
55
|
+
// 父线程报错收尾且子任务被主动取消(旧实现 30s 后误判成功、子任务成孤儿)
|
|
56
|
+
const parent2 = await rt.createThread({ harnessId: 'test-harness', cwd: root });
|
|
57
|
+
const d2 = await rt.delegateTask({ fromThreadId: parent2.id, harnessId: 'test-harness', task: '长跑任务' });
|
|
58
|
+
const heartbeat = setInterval(() => emits.get(d2.child.id)?.({ kind: 'text-delta', text: '.' }), 100);
|
|
59
|
+
try {
|
|
60
|
+
await until(() => !rt.execution.isRunning(parent2.id), 'parent settles on delegation timeout');
|
|
61
|
+
} finally {
|
|
62
|
+
clearInterval(heartbeat);
|
|
63
|
+
}
|
|
64
|
+
const failItem = rt.core.getItemsForTurn(d2.turn.id).find(i => i.type === 'tool_call');
|
|
65
|
+
assert.equal(failItem.status, 'error', '超时后父线程协作工具项结算为错误');
|
|
66
|
+
assert.match(JSON.stringify(failItem), /超时|未结算/, '错误说明包含超时原因');
|
|
67
|
+
assert.ok((cancels.get(d2.child.id) ?? 0) >= 1, '超时主动取消子任务,不留孤儿进程');
|
|
68
|
+
|
|
69
|
+
console.log('delegation-await-test: parent awaits async child settlement; timeout cancels the child and reports an error');
|
|
70
|
+
} finally {
|
|
71
|
+
await rt.close();
|
|
72
|
+
}
|
|
73
|
+
})().catch(error => {
|
|
74
|
+
console.error(error);
|
|
75
|
+
process.exitCode = 1;
|
|
76
|
+
});
|
|
@@ -0,0 +1,31 @@
|
|
|
1
|
+
const assert = require('node:assert/strict');
|
|
2
|
+
const { JsonlProcess } = require('../src/main/host/jsonl');
|
|
3
|
+
|
|
4
|
+
// JsonlProcess stdin EPIPE 防护:子进程异常退出/管道破裂后,迟到的 stdin.write 会在
|
|
5
|
+
// 流上异步抛 'error'(EPIPE);Writable 无 error 监听时 Node 将其当作未捕获异常直接
|
|
6
|
+
// crash 宿主进程。修复后错误进入诊断通道,真实失败仍由 exit/error 路径统一结算。
|
|
7
|
+
(async () => {
|
|
8
|
+
let crashed = null;
|
|
9
|
+
const onCrash = error => { crashed = error; };
|
|
10
|
+
process.on('uncaughtException', onCrash);
|
|
11
|
+
const diags = [];
|
|
12
|
+
try {
|
|
13
|
+
const proc = new JsonlProcess(process.execPath, ['-e', 'process.exit(0)'], {}, {
|
|
14
|
+
onDiagnostic(line) { diags.push(String(line)); },
|
|
15
|
+
});
|
|
16
|
+
await new Promise(resolve => proc.child.on('exit', resolve));
|
|
17
|
+
// 迟到写入(管道已破)+ 直接注入流错误:两种形态都不得成为未捕获异常
|
|
18
|
+
proc.notify('late.notify', {});
|
|
19
|
+
proc.send({ type: 'late' });
|
|
20
|
+
proc.child.stdin.emit('error', Object.assign(new Error('write EPIPE'), { code: 'EPIPE' }));
|
|
21
|
+
await new Promise(resolve => setTimeout(resolve, 100));
|
|
22
|
+
assert.equal(crashed, null, `stdin EPIPE 不得成为未捕获异常: ${crashed?.message ?? ''}`);
|
|
23
|
+
assert.ok(diags.some(line => /stdin/.test(line)), 'stdin 流错误进入诊断通道');
|
|
24
|
+
console.log('jsonl-stdin-test: late writes and EPIPE after child exit are swallowed as diagnostics');
|
|
25
|
+
} finally {
|
|
26
|
+
process.removeListener('uncaughtException', onCrash);
|
|
27
|
+
}
|
|
28
|
+
})().catch(error => {
|
|
29
|
+
console.error(error);
|
|
30
|
+
process.exitCode = 1;
|
|
31
|
+
});
|
|
@@ -13,10 +13,11 @@ async function main() {
|
|
|
13
13
|
let emit;
|
|
14
14
|
const emits = [];
|
|
15
15
|
const answers = [];
|
|
16
|
-
const adapter = { manifest: { id: 'pi', name: 'Pi', capabilities: { streaming: true, models: true, approvals: true, questions: true, resume: true } },
|
|
16
|
+
const adapter = { manifest: { id: 'pi', name: 'Pi', capabilities: { streaming: true, models: true, approvals: true, questions: true, resume: true, fork: true } },
|
|
17
17
|
async open(input) { emits.push(input.emit); emit = input.emit; return {}; },
|
|
18
18
|
async describe() { return { models: [{ id: 'demo', name: 'Demo', provider: 'test' }], thinkingLevels: [{ id: 'high', label: 'High', default: true }, { id: 'low', label: 'Low' }], permissionModes: [] }; },
|
|
19
19
|
async send(session, text, hooks, extras) { sendExtras.push(extras); }, async cancel() {}, async close() {},
|
|
20
|
+
async fork(source) { return { session: {}, nativeSessionId: `forked-${source.id}` }; },
|
|
20
21
|
async respond(session, id, answer) { answers.push({ id, answer }); } };
|
|
21
22
|
const sendExtras = [];
|
|
22
23
|
runtime.adapters.set('pi', adapter); runtime.status.pi = { available: true };
|
|
@@ -183,9 +184,21 @@ async function main() {
|
|
|
183
184
|
const question = events.find(e => e.method === 'item/tool/requestUserInput');
|
|
184
185
|
await bridge.respond({ id: question.id, result: { answers: { question: { answers: ['Alice'] } } } });
|
|
185
186
|
assert.equal(answers[1].answer.value, 'Alice');
|
|
187
|
+
// 多选提问:answers 数组必须全量保留(JSON 编码),只取 [0] 会无声吞掉其余选项
|
|
188
|
+
emit({ kind: 'approval', requestId: 'multi', method: 'input', title: 'Pick many?' });
|
|
189
|
+
const multi = events.filter(e => e.method === 'item/tool/requestUserInput').at(-1);
|
|
190
|
+
await bridge.respond({ id: multi.id, result: { answers: { multi: { answers: ['a', 'b'] } } } });
|
|
191
|
+
assert.equal(answers[2].answer.value, '["a","b"]', '多选答案 JSON 编码全量保留');
|
|
192
|
+
emit({ kind: 'approval', requestId: 'empty', method: 'input', title: 'Empty?' });
|
|
193
|
+
const emptyQ = events.filter(e => e.method === 'item/tool/requestUserInput').at(-1);
|
|
194
|
+
await bridge.respond({ id: emptyQ.id, result: { answers: { empty: { answers: [] } } } });
|
|
195
|
+
assert.equal(answers[3].answer.value, '', '空答案数组回退为空字符串而非 undefined');
|
|
186
196
|
emit({ kind: 'file-change', changes: [{ path: 'a.txt', changeType: 'added', before: '', after: 'hello', complete: true }] });
|
|
187
197
|
emit({ kind: 'completed', finalAnswer: true });
|
|
188
198
|
await wait(() => !runtime.threads.find(t => t.id === threadId).reviewPending && !runtime.sending.has(threadId));
|
|
199
|
+
// Fork:Host 侧新建分支线程必须广播 thread/started,否则 Desktop 侧边栏不显示分支
|
|
200
|
+
const forked = await bridge.request('thread/fork', { threadId });
|
|
201
|
+
assert.ok(events.some(e => e.method === 'thread/started' && e.params.thread.id === forked.thread.id), 'Fork 后 Desktop 收到分支线程的 thread/started');
|
|
189
202
|
assert.equal(events.filter(e => e.method === 'item/agentMessage/delta').map(e => e.params.delta).join(''), 'hello world');
|
|
190
203
|
// 非终端工具投影为 dynamicToolCall(摘要显示真实工具名),终端命令投影为原生 commandExecution
|
|
191
204
|
assert.ok(events.some(e => e.method === 'item/completed' && e.params.item.type === 'dynamicToolCall' && e.params.item.tool === 'Read'));
|
|
@@ -0,0 +1,80 @@
|
|
|
1
|
+
const assert = require('node:assert/strict');
|
|
2
|
+
const fs = require('node:fs/promises');
|
|
3
|
+
const os = require('node:os');
|
|
4
|
+
const path = require('node:path');
|
|
5
|
+
const { HostRuntime } = require('../src/main/host/runtime');
|
|
6
|
+
|
|
7
|
+
const sleep = ms => new Promise(resolve => setTimeout(resolve, ms));
|
|
8
|
+
async function until(fn) {
|
|
9
|
+
for (let i = 0; i < 300; i++) { if (fn()) return; await sleep(10); }
|
|
10
|
+
throw new Error('timed out');
|
|
11
|
+
}
|
|
12
|
+
|
|
13
|
+
// 取消-重发竞态(票据所有制发送锁):
|
|
14
|
+
// 流式进行中 cancel 会提前放锁让新发送进入(设计如此,避免卡死的 adapter.send 永久锁线程),
|
|
15
|
+
// 但旧 send 退出时的 finally 不得误删新发送的锁——否则第三个发送会与在途发送并发,
|
|
16
|
+
// 撞上原生侧 "Agent is already processing"。cancelRequests likewise 按票据隔离,
|
|
17
|
+
// 上一代发送遗留的取消登记不得被新发送的消费点误吞。
|
|
18
|
+
(async () => {
|
|
19
|
+
const directory = await fs.mkdtemp(path.join(os.tmpdir(), 'hm-send-cancel-race-'));
|
|
20
|
+
const root = path.join(directory, 'workspace');
|
|
21
|
+
await fs.mkdir(root);
|
|
22
|
+
|
|
23
|
+
const rt = new HostRuntime({ dataDirectory: directory });
|
|
24
|
+
await rt.store.load();
|
|
25
|
+
|
|
26
|
+
const emits = new Map();
|
|
27
|
+
const pendingSends = new Map(); // threadId -> resolve:adapter.send 阻塞至 cancel(模拟原生 abort 才结算)
|
|
28
|
+
const adapter = {
|
|
29
|
+
manifest: { id: 'test-harness', name: 'Test', capabilities: {} },
|
|
30
|
+
async open(input) { emits.set(input.thread.id, input.emit); return {}; },
|
|
31
|
+
async send(session) { await new Promise(resolve => pendingSends.set(session.threadId, resolve)); },
|
|
32
|
+
async cancel(session) { pendingSends.get(session.threadId)?.(); pendingSends.delete(session.threadId); },
|
|
33
|
+
async close() {},
|
|
34
|
+
};
|
|
35
|
+
rt.adapters.set(adapter.manifest.id, adapter);
|
|
36
|
+
rt.status[adapter.manifest.id] = { available: true };
|
|
37
|
+
|
|
38
|
+
try {
|
|
39
|
+
const thread = await rt.createThread({ harnessId: 'test-harness', cwd: root });
|
|
40
|
+
|
|
41
|
+
// A 进入流式(adapter.send 阻塞中)
|
|
42
|
+
const sendA = rt.send(thread.id, 'first');
|
|
43
|
+
await until(() => pendingSends.has(thread.id));
|
|
44
|
+
|
|
45
|
+
// 流式中 cancel:立即放锁 + 作废旧票据;adapter.cancel 让 A 的 send 返回
|
|
46
|
+
await rt.cancel(thread.id);
|
|
47
|
+
await sendA; // A 的 finally 在此执行——票据已作废,不得触碰锁
|
|
48
|
+
|
|
49
|
+
// 立即重发 B:cancel 已放锁,必须能进入
|
|
50
|
+
const sendB = rt.send(thread.id, 'second');
|
|
51
|
+
await until(() => pendingSends.has(thread.id));
|
|
52
|
+
|
|
53
|
+
// 关键回归断言 1:B 在途时第三个发送必须被拒(若 A 的 finally 误删锁,这里会漏成并发)
|
|
54
|
+
await assert.rejects(rt.send(thread.id, 'third'), /正在执行/);
|
|
55
|
+
|
|
56
|
+
// 关键回归断言 2:B 的回合仍在运行——A 遗留的取消登记不得被 B 的消费点误吞
|
|
57
|
+
await sleep(50);
|
|
58
|
+
assert.equal(rt.execution.isRunning(thread.id), true, 'B 的回合不得被上一代取消登记误结算');
|
|
59
|
+
assert.equal(thread.error, undefined, 'B 不得携带取消/错误状态');
|
|
60
|
+
|
|
61
|
+
// 收尾:取消 B,B 的 send 返回;锁由 B 自己的 finally(或 cancel)正常回收
|
|
62
|
+
await rt.cancel(thread.id);
|
|
63
|
+
await sendB;
|
|
64
|
+
await until(() => !rt.sending.has(thread.id));
|
|
65
|
+
|
|
66
|
+
// 完整结算后新发送 C 可用:线程没有因竞态永久锁定
|
|
67
|
+
const sendC = rt.send(thread.id, 'third after settle');
|
|
68
|
+
await until(() => pendingSends.has(thread.id));
|
|
69
|
+
await rt.cancel(thread.id);
|
|
70
|
+
await sendC;
|
|
71
|
+
await until(() => !rt.sending.has(thread.id));
|
|
72
|
+
|
|
73
|
+
console.log('send-cancel-race-test: stale send finally cannot clobber the newer send lock; stale cancel requests are ticket-scoped');
|
|
74
|
+
} finally {
|
|
75
|
+
await rt.close();
|
|
76
|
+
}
|
|
77
|
+
})().catch(error => {
|
|
78
|
+
console.error(error);
|
|
79
|
+
process.exitCode = 1;
|
|
80
|
+
});
|
|
@@ -0,0 +1,100 @@
|
|
|
1
|
+
const assert = require('node:assert/strict');
|
|
2
|
+
const fs = require('node:fs/promises');
|
|
3
|
+
const os = require('node:os');
|
|
4
|
+
const path = require('node:path');
|
|
5
|
+
const { HostRuntime } = require('../src/main/host/runtime');
|
|
6
|
+
|
|
7
|
+
const sleep = ms => new Promise(resolve => setTimeout(resolve, ms));
|
|
8
|
+
async function until(fn, label = 'condition') {
|
|
9
|
+
for (let i = 0; i < 300; i++) { if (fn()) return; await sleep(10); }
|
|
10
|
+
throw new Error(`timed out: ${label}`);
|
|
11
|
+
}
|
|
12
|
+
|
|
13
|
+
// 「Turn 启动前」取消-重发竞态(send-cancel-race 的前半段窗口):
|
|
14
|
+
// 旧 send 仍停留在会话打开/prompt 组装阶段时用户 cancel 并立即重发——
|
|
15
|
+
// 1) 旧 send 恢复后必须命中取消登记、不得投递 prompt(否则与新发送双双进入原生会话,
|
|
16
|
+
// 撞上原生侧 "Agent is already processing");
|
|
17
|
+
// 2) 旧 send 不得再 turnStarted 挤占 lastTurn(否则新发送的投递前自查会误判空闲而丢消息);
|
|
18
|
+
// 3) 旧 send 迟到的 reject/事件不得击中正在运行的新回合(按回合身份核验)。
|
|
19
|
+
(async () => {
|
|
20
|
+
const directory = await fs.mkdtemp(path.join(os.tmpdir(), 'hm-send-pre-turn-cancel-'));
|
|
21
|
+
const root = path.join(directory, 'workspace');
|
|
22
|
+
await fs.mkdir(root);
|
|
23
|
+
|
|
24
|
+
const rt = new HostRuntime({ dataDirectory: directory });
|
|
25
|
+
await rt.store.load();
|
|
26
|
+
|
|
27
|
+
const delivered = [];
|
|
28
|
+
const adapter = {
|
|
29
|
+
manifest: { id: 'test-harness', name: 'Test', capabilities: {} },
|
|
30
|
+
async open() { return {}; },
|
|
31
|
+
async send(session, text) { delivered.push(text); /* 非阻塞 ack,回合由事件流结算 */ },
|
|
32
|
+
async cancel() {},
|
|
33
|
+
async close() {},
|
|
34
|
+
};
|
|
35
|
+
rt.adapters.set(adapter.manifest.id, adapter);
|
|
36
|
+
rt.status[adapter.manifest.id] = { available: true };
|
|
37
|
+
|
|
38
|
+
try {
|
|
39
|
+
const thread = await rt.createThread({ harnessId: 'test-harness', cwd: root });
|
|
40
|
+
await until(() => rt.sessions.has(thread.id), 'session open');
|
|
41
|
+
|
|
42
|
+
// 制造「下次发送需重新打开原生会话」的慢窗口:关掉会话并标记惰性恢复
|
|
43
|
+
const session = rt.sessions.get(thread.id);
|
|
44
|
+
await session.adapter.close(session);
|
|
45
|
+
rt.sessions.delete(thread.id);
|
|
46
|
+
thread.restore = true;
|
|
47
|
+
let openGate;
|
|
48
|
+
const openBarrier = new Promise(resolve => { openGate = resolve; });
|
|
49
|
+
const realOpen = adapter.open;
|
|
50
|
+
adapter.open = async () => { await openBarrier; return realOpen(); };
|
|
51
|
+
|
|
52
|
+
// A 进入 #send,悬挂在 #ensureOpen(Turn 尚未启动)
|
|
53
|
+
const sendA = rt.send(thread.id, 'first');
|
|
54
|
+
await until(() => rt.openings.has(thread.id), 'A suspended in native open');
|
|
55
|
+
|
|
56
|
+
// Turn 未启动时 cancel:登记取消(A 的票据)并提前放锁
|
|
57
|
+
await rt.cancel(thread.id);
|
|
58
|
+
|
|
59
|
+
// 用户立即重发 B:放锁后必须能进入;B 不得误吞 A 的取消登记
|
|
60
|
+
const sendB = rt.send(thread.id, 'second');
|
|
61
|
+
|
|
62
|
+
// 原生会话打开完成:A 的 continuation 先恢复,随后 B
|
|
63
|
+
openGate();
|
|
64
|
+
await Promise.allSettled([sendA, sendB]);
|
|
65
|
+
await until(() => delivered.length > 0, 'B delivered');
|
|
66
|
+
|
|
67
|
+
// 核心断言 1:只有 B 投递;A 在恢复后命中取消登记,不再投递
|
|
68
|
+
assert.deepEqual(delivered, ['second'], '被取消的旧发送不得投递 prompt');
|
|
69
|
+
|
|
70
|
+
// 核心断言 2:A 的取消以取消卡片闭环(lastTurn 曾是 A 的取消回合),随后 B 的回合接管
|
|
71
|
+
const assistant = thread.messages.find(m => m.role === 'assistant');
|
|
72
|
+
assert.ok(assistant, 'A 的取消应留下助手回合卡片');
|
|
73
|
+
assert.equal(assistant.stopReason, 'cancelled', 'A 的回合以取消结算');
|
|
74
|
+
assert.equal(thread.error, undefined, '线程不得携带错误状态');
|
|
75
|
+
|
|
76
|
+
// B 的回合正常运行中(非阻塞适配器:ack 即返回,回合等事件流结算)
|
|
77
|
+
assert.equal(rt.execution.isRunning(thread.id), true, 'B 的回合应正常运行');
|
|
78
|
+
|
|
79
|
+
// B 的回合经事件流正常结算,线程回到空闲,后续发送不受影响
|
|
80
|
+
const sessionB = rt.sessions.get(thread.id);
|
|
81
|
+
// 通过 adapter emit 结算 B:模拟原生 completed
|
|
82
|
+
//(createThread 时记录的是旧 emit, reopen 后需取最新会话的 emit——
|
|
83
|
+
// 本测试 adapter.open 未保存 emit,直接用 execution 事件路径验证空闲后可发)
|
|
84
|
+
await rt.cancel(thread.id); // 用户停止 B
|
|
85
|
+
await until(() => !rt.execution.isRunning(thread.id), 'B settled by cancel');
|
|
86
|
+
const sendC = rt.send(thread.id, 'third after settle');
|
|
87
|
+
await until(() => delivered.length === 2, 'C delivered');
|
|
88
|
+
assert.deepEqual(delivered, ['second', 'third after settle']);
|
|
89
|
+
await rt.cancel(thread.id);
|
|
90
|
+
await sendC;
|
|
91
|
+
assert.ok(sessionB, 'session exists');
|
|
92
|
+
|
|
93
|
+
console.log('send-pre-turn-cancel: cancelled pre-turn send never delivers; resend owns the turn; late events cannot hit the newer turn');
|
|
94
|
+
} finally {
|
|
95
|
+
await rt.close();
|
|
96
|
+
}
|
|
97
|
+
})().catch(error => {
|
|
98
|
+
console.error(error);
|
|
99
|
+
process.exitCode = 1;
|
|
100
|
+
});
|
|
@@ -21,11 +21,12 @@ async function until(fn) {
|
|
|
21
21
|
await rt.store.load();
|
|
22
22
|
|
|
23
23
|
const emits = new Map();
|
|
24
|
+
const cancels = new Map(); // threadId -> adapter.cancel 次数(看门狗级联取消断言)
|
|
24
25
|
const adapter = {
|
|
25
26
|
manifest: { id: 'test-harness', name: 'Test', capabilities: {} },
|
|
26
27
|
async open(input) { emits.set(input.thread.id, input.emit); return {}; },
|
|
27
28
|
async send(session) {},
|
|
28
|
-
async cancel(session) {},
|
|
29
|
+
async cancel(session) { cancels.set(session.threadId, (cancels.get(session.threadId) ?? 0) + 1); },
|
|
29
30
|
async close() {},
|
|
30
31
|
};
|
|
31
32
|
rt.adapters.set(adapter.manifest.id, adapter);
|
|
@@ -57,11 +58,15 @@ async function until(fn) {
|
|
|
57
58
|
await rt.send(stuck.id, 'wedged session');
|
|
58
59
|
await until(() => stuck.status === 'error');
|
|
59
60
|
assert.match(stuck.error, /卡死/);
|
|
61
|
+
// 结算的同时级联取消原生会话:否则僵尸进程常驻,下一回合撞上原生侧占用报错
|
|
62
|
+
await until(() => (cancels.get(stuck.id) ?? 0) >= 1);
|
|
60
63
|
const turn = rt.execution.lastTurn(stuck.id);
|
|
61
64
|
assert.equal(turn.status, 'error');
|
|
62
65
|
await until(() => !stuck.reviewPending);
|
|
63
66
|
const open = rt.core.getItemsForTurn(turn.id).filter(item => !['completed', 'error', 'cancelled'].includes(item.status));
|
|
64
67
|
assert.equal(open.length, 0, 'watchdog settlement finalizes every open item');
|
|
68
|
+
assert.equal(cancels.get(active.id) ?? 0, 0, '正常完成的活跃回合不触发 adapter.cancel');
|
|
69
|
+
assert.equal(cancels.get(approval.id) ?? 0, 0, '审批等待后正常完成的回合不触发 adapter.cancel');
|
|
65
70
|
|
|
66
71
|
console.log('stuck-turn-test: active/approval-waiting turns survive; wedged zero-event turn auto-settles and finalizes items');
|
|
67
72
|
} finally {
|
|
@@ -920,6 +920,9 @@ function create(emit, options = {}) {
|
|
|
920
920
|
windowsHide: true,
|
|
921
921
|
stdio: ['pipe', 'pipe', 'pipe'],
|
|
922
922
|
});
|
|
923
|
+
// 进程崩溃/提前退出时,迟到的 stdin.write/end 在流上异步抛 EPIPE;
|
|
924
|
+
// 无 error 监听的 Writable 会被 Node 当作未捕获异常 crash 宿主进程
|
|
925
|
+
child.stdin.on('error', () => {});
|
|
923
926
|
|
|
924
927
|
let turnResolve, turnReject;
|
|
925
928
|
const settled = new Promise((resolve, reject) => {
|
|
@@ -92,16 +92,19 @@ class EventNormalizer {
|
|
|
92
92
|
// 原生流仍送达 agent_settled / result),防止影子侧出现非法状态迁移。
|
|
93
93
|
if (!this.turnActive) return [];
|
|
94
94
|
this.turnActive = false;
|
|
95
|
-
// 最后一段 agent_message 标记 phase:'final'
|
|
96
|
-
//
|
|
95
|
+
// 最后一段 agent_message 标记 phase:'final'并显式闭合:投影边界将其译为
|
|
96
|
+
// final_answer 随 item/completed 上线,Desktop 据此把它识别为回合唯一正式结论;
|
|
97
|
+
// 其余 progress(commentary)段与工具项折叠进「用时 XXm XXs」栏。
|
|
97
98
|
return [
|
|
98
99
|
...(!legacy.finalAnswer || legacy.stopReason && legacy.stopReason !== 'completed' || !this.currentMessageItemId ? [] : [this.#event({ type: 'item.updated', itemId: this.currentMessageItemId, payload: { phase: 'final' } })]),
|
|
100
|
+
...this.#closeSegment(),
|
|
99
101
|
this.#event({ type: 'turn.completed', payload: { stopReason: legacy.stopReason ?? 'completed' } }),
|
|
100
102
|
];
|
|
101
103
|
case 'error':
|
|
102
104
|
if (!this.turnActive) return [];
|
|
103
105
|
this.turnActive = false;
|
|
104
106
|
return [
|
|
107
|
+
...this.#closeSegment(),
|
|
105
108
|
this.#event({ type: 'turn.failed', payload: {
|
|
106
109
|
message: String(legacy.message ?? 'unknown error'),
|
|
107
110
|
errorKind: classifyError(legacy),
|
package/src/main/host/jsonl.js
CHANGED
|
@@ -16,6 +16,10 @@ class JsonlProcess {
|
|
|
16
16
|
this.nextId = 1;
|
|
17
17
|
this.hooks = hooks;
|
|
18
18
|
this.child = spawn(command, args, { windowsHide: true, ...options, stdio: ["pipe", "pipe", "pipe"] });
|
|
19
|
+
// 子进程异常退出/管道破裂时,迟到的 stdin.write 会在流上异步抛 EPIPE;
|
|
20
|
+
// Writable 无 error 监听会被 Node 当作未捕获异常直接 crash 宿主进程。
|
|
21
|
+
// 真实失败由 exit/error 路径统一结算,这里仅吞掉管道噪声。
|
|
22
|
+
this.child.stdin.on("error", (error) => this.hooks.onDiagnostic?.(`stdin: ${error.message}`));
|
|
19
23
|
this.#attachReader(this.child.stdout, (line) => this.#dispatch(line));
|
|
20
24
|
this.#attachReader(this.child.stderr, (line) => hooks.onDiagnostic?.(line));
|
|
21
25
|
this.child.on("error", (error) => this.#failAll(error));
|
package/src/main/host/runtime.js
CHANGED
|
@@ -26,7 +26,7 @@ const { createWorkspace, inspectWorkspace, reviewWorkspace, applyWorkspace, remo
|
|
|
26
26
|
* 仍由其原生程序维护,Adapter 只负责原生协议接入与事件转换。
|
|
27
27
|
*/
|
|
28
28
|
class HostRuntime {
|
|
29
|
-
constructor({ dataDirectory, observer = null, stuckTurnMs = 15 * 60 * 1000, stuckSweepMs = 60 * 1000 }) {
|
|
29
|
+
constructor({ dataDirectory, observer = null, stuckTurnMs = 15 * 60 * 1000, stuckSweepMs = 60 * 1000, delegationTimeoutMs = 30 * 60 * 1000 }) {
|
|
30
30
|
this.store = new ThreadStore(dataDirectory);
|
|
31
31
|
this.threads = [];
|
|
32
32
|
this.sessions = new Map(); // threadId -> { adapter, ...session }
|
|
@@ -41,9 +41,14 @@ class HostRuntime {
|
|
|
41
41
|
this.reviewTasks = new Set();
|
|
42
42
|
this.openings = new Map();
|
|
43
43
|
this.sending = new Set();
|
|
44
|
-
//
|
|
45
|
-
//
|
|
46
|
-
this.
|
|
44
|
+
// threadId -> 在途 send 的票据:cancel 会提前放锁让新发送进入,
|
|
45
|
+
// 旧 send 退出时凭票据比对,避免其 finally 误删新发送的锁
|
|
46
|
+
this.sendTickets = new Map();
|
|
47
|
+
this.sendTicketSeq = 0;
|
|
48
|
+
// send 进行中被 cancel 的线程(threadId -> 被取消 send 的票据):Turn 尚未开始或
|
|
49
|
+
// prompt 尚未投递时 abort 无从生效,由 #send 在 Turn 启动后/投递前据此结算取消,
|
|
50
|
+
// 避免用户看不见的僵尸运行;票据不匹配的陈旧登记属上一代发送,由 #send 入口清理
|
|
51
|
+
this.cancelRequests = new Map();
|
|
47
52
|
this.switching = new Set();
|
|
48
53
|
// 同目录并发会话警告的去重记录:threadId -> 上次提醒时间
|
|
49
54
|
this.concurrentCwdNotified = new Map();
|
|
@@ -52,6 +57,8 @@ class HostRuntime {
|
|
|
52
57
|
// 即按超时结算,让 UI 停转、审查快照收尾。等待用户审批的回合属合法静默。
|
|
53
58
|
this.turnActivity = new Map();
|
|
54
59
|
this.stuckTurnMs = stuckTurnMs;
|
|
60
|
+
// /delegate 委派等待子任务结算的上限(对齐协作编排的 30 分钟),注入以便测试
|
|
61
|
+
this.delegationTimeoutMs = delegationTimeoutMs;
|
|
55
62
|
this.watchdogTimer = setInterval(() => this.#sweepStuckTurns(), stuckSweepMs);
|
|
56
63
|
this.watchdogTimer.unref?.();
|
|
57
64
|
// 跨 Harness 协作:parentThreadId -> { childId, toolCallId, cancelled }
|
|
@@ -320,14 +327,27 @@ class HostRuntime {
|
|
|
320
327
|
// Reserve before opening a native session: two submissions can otherwise both
|
|
321
328
|
// pass isRunning() while awaiting the same opening promise.
|
|
322
329
|
if (this.sending.has(threadId)) throw new Error('任务正在执行,请先停止或等待完成');
|
|
330
|
+
const ticket = ++this.sendTicketSeq;
|
|
323
331
|
this.sending.add(threadId);
|
|
324
|
-
|
|
325
|
-
|
|
332
|
+
this.sendTickets.set(threadId, ticket);
|
|
333
|
+
try { return await this.#send(threadId, text, { commandId, attachments, delegateOf, collaborationOf, isolated, turnPermissions, ticket }); }
|
|
334
|
+
// cancel() 会提前放锁并让新发送进入:仅当票据仍归本次发送时才回收,否则
|
|
335
|
+
// 这里的 delete 会误删新发送的锁,使第三个发送与在途发送并发撞车
|
|
336
|
+
finally {
|
|
337
|
+
if (this.sendTickets.get(threadId) === ticket) {
|
|
338
|
+
this.sending.delete(threadId);
|
|
339
|
+
this.sendTickets.delete(threadId);
|
|
340
|
+
this.cancelRequests.delete(threadId);
|
|
341
|
+
}
|
|
342
|
+
}
|
|
326
343
|
}
|
|
327
344
|
|
|
328
|
-
async #send(threadId, text, { commandId, attachments, delegateOf, collaborationOf, isolated, turnPermissions }) {
|
|
345
|
+
async #send(threadId, text, { commandId, attachments, delegateOf, collaborationOf, isolated, turnPermissions, ticket }) {
|
|
329
346
|
const thread = this.#requireThread(threadId);
|
|
330
347
|
if (this.execution.isRunning(thread.id)) throw new Error("任务正在执行,请先停止或等待完成");
|
|
348
|
+
// 不得在此按票据清理 cancelRequests:登记可能属于仍停留在 Turn 启动前阶段(会话
|
|
349
|
+
// 打开/prompt 组装)的在途旧 send——删掉会让旧 send 错过下方的取消结算而继续投递,
|
|
350
|
+
// 与新发送双双进入原生会话。陈旧登记由本次 send 的 finally(票据匹配时)回收。
|
|
331
351
|
const prepared = this.#prepareAttachments(thread, attachments);
|
|
332
352
|
const typed = typeof text === "string" ? text.trim() : "";
|
|
333
353
|
if (!typed && !prepared.images.length && !prepared.texts.length) throw new Error("请输入消息");
|
|
@@ -428,9 +448,19 @@ class HostRuntime {
|
|
|
428
448
|
thread.updatedAt = Date.now();
|
|
429
449
|
delete thread.error;
|
|
430
450
|
delete thread.errorKind;
|
|
431
|
-
|
|
432
|
-
//
|
|
433
|
-
|
|
451
|
+
// 会话打开/prompt 组装期间用户已按停止(cancel 时 Turn 尚未开始,原生侧无从 abort):
|
|
452
|
+
// 本代发送直接结算取消、不再投递。若取消后用户已重发且新回合已在运行,旧 send 不得
|
|
453
|
+
// 再 turnStarted——那会把新回合挤出 lastTurn,使新发送在投递前自查时误判空闲而丢消息;
|
|
454
|
+
// 此时静默退出,把投影权交给新回合。
|
|
455
|
+
const cancelledBeforeTurn = this.cancelRequests.get(thread.id) === ticket;
|
|
456
|
+
if (cancelledBeforeTurn) this.cancelRequests.delete(thread.id);
|
|
457
|
+
if (cancelledBeforeTurn && this.execution.isRunning(thread.id)) {
|
|
458
|
+
await this.#save();
|
|
459
|
+
this.#broadcast();
|
|
460
|
+
return;
|
|
461
|
+
}
|
|
462
|
+
const turn = this.execution.turnStarted(thread, displayPrompt);
|
|
463
|
+
if (cancelledBeforeTurn) this.#applyEvent({ threadId, event: { kind: 'completed', stopReason: 'cancelled' } });
|
|
434
464
|
this.turnActivity.set(thread.id, Date.now());
|
|
435
465
|
const message = thread.messages.at(-1);
|
|
436
466
|
if (hasConcurrentTurn) message.concurrent = true;
|
|
@@ -438,8 +468,12 @@ class HostRuntime {
|
|
|
438
468
|
this.#syncCore(thread);
|
|
439
469
|
try { if (!collaborationOf || isolated) message.reviewId = await this.reviews.begin(thread.cwd); else message.reviewOwnerThreadId = collaborationOf; }
|
|
440
470
|
catch (e) { message.reviewError = '本轮未建立文件快照:' + e.message; }
|
|
441
|
-
|
|
442
|
-
|
|
471
|
+
// 本代回合已结算(含上方的取消结算)时退出。注意 isRunning 反映的是最新回合:
|
|
472
|
+
// 取消后用户重发的新回合一旦启动,这里会被重新置真——必须再按回合身份核验,
|
|
473
|
+
// 否则被取消的旧发送会把 review 快照与后续 prompt 投递错误地挂到新回合上。
|
|
474
|
+
const superseded = this.execution.lastTurn(thread.id)?.id !== turn.id;
|
|
475
|
+
if (!this.execution.isRunning(thread.id) || superseded) {
|
|
476
|
+
if (message.reviewId && !superseded) await this.#settleReview(thread, message);
|
|
443
477
|
return;
|
|
444
478
|
}
|
|
445
479
|
this.startReviewUpdates(thread, message);
|
|
@@ -455,14 +489,16 @@ class HostRuntime {
|
|
|
455
489
|
await this.#save();
|
|
456
490
|
this.#broadcast();
|
|
457
491
|
}
|
|
458
|
-
// 投递前最后检查:Turn 可能在 review 快照/保存期间被取消(cancel
|
|
492
|
+
// 投递前最后检查:Turn 可能在 review 快照/保存期间被取消(cancel 已结算),
|
|
493
|
+
// 或已被取消后重发的新回合取代(lastTurn 易主时 isRunning 仍为真)。
|
|
459
494
|
// 此时再投递,先到的 abort 会在原生侧落空,形成用户看不见的僵尸运行
|
|
460
|
-
|
|
495
|
+
const outdated = this.execution.lastTurn(thread.id)?.id !== turn.id;
|
|
496
|
+
if (!this.execution.isRunning(thread.id) || outdated) {
|
|
461
497
|
if (handoff?.checkpointId) {
|
|
462
498
|
handoff.phase = 'failed';
|
|
463
499
|
await this.handoffs.mark(thread.id, handoff.checkpointId, 'failed').catch(() => {});
|
|
464
500
|
}
|
|
465
|
-
if (message.reviewId) await this.#settleReview(thread, message);
|
|
501
|
+
if (message.reviewId && !outdated) await this.#settleReview(thread, message);
|
|
466
502
|
return;
|
|
467
503
|
}
|
|
468
504
|
await session.adapter.send(session, promptText, hooks, { images: prepared.images, turnPermissions });
|
|
@@ -476,8 +512,10 @@ class HostRuntime {
|
|
|
476
512
|
handoff.phase = 'failed';
|
|
477
513
|
await this.handoffs.mark(thread.id, handoff.checkpointId, 'failed').catch(() => {});
|
|
478
514
|
}
|
|
479
|
-
// 用户取消造成的 reject 已由 cancel()
|
|
480
|
-
|
|
515
|
+
// 用户取消造成的 reject 已由 cancel() 结算,不再标错。若回合已易主(取消后重发 /
|
|
516
|
+
// 外部转向启动了新回合),旧发送迟到的 reject 不得击中正在运行的新回合——
|
|
517
|
+
// 仅当本代回合仍是 lastTurn 时才把错误投到它上面。
|
|
518
|
+
if (this.execution.isRunning(thread.id) && this.execution.lastTurn(thread.id)?.id === turn?.id) this.#applyEvent({ threadId, event: { kind: "error", message: error.message } });
|
|
481
519
|
await this.#save().catch(() => {});
|
|
482
520
|
this.#broadcast();
|
|
483
521
|
}
|
|
@@ -548,12 +586,23 @@ class HostRuntime {
|
|
|
548
586
|
let answer = '';
|
|
549
587
|
try {
|
|
550
588
|
await this.send(child.id, task, { delegateOf: parent.id });
|
|
551
|
-
// Adapter 返回≠原生 Turn
|
|
552
|
-
|
|
553
|
-
|
|
554
|
-
const
|
|
555
|
-
|
|
556
|
-
|
|
589
|
+
// Adapter 返回≠原生 Turn 完全结算(Pi 等非阻塞适配器收到 prompt ack 即返回,实际
|
|
590
|
+
// 执行由异步流驱动),有界等待至子任务真正空闲——对齐协作编排的 30 分钟上限,
|
|
591
|
+
// 超时主动取消子任务;已 wedge 的子任务由看门狗(stuckTurnMs)提前按 error 结算。
|
|
592
|
+
const until = Date.now() + this.delegationTimeoutMs;
|
|
593
|
+
while (this.execution.isRunning(child.id) && !delegation?.cancelled) {
|
|
594
|
+
if (Date.now() > until) {
|
|
595
|
+
await this.cancel(child.id);
|
|
596
|
+
failure = new Error(`子任务超过 ${Math.round(this.delegationTimeoutMs / 60000)} 分钟未结算,已自动取消`);
|
|
597
|
+
break;
|
|
598
|
+
}
|
|
599
|
+
await new Promise(resolve => setTimeout(resolve, 100));
|
|
600
|
+
}
|
|
601
|
+
if (!failure) {
|
|
602
|
+
const lastTurn = this.execution.lastTurn(child.id);
|
|
603
|
+
if (lastTurn?.status === 'error') failure = new Error(lastTurn.error || '子任务执行失败');
|
|
604
|
+
else answer = this.#turnFinalText(child.id);
|
|
605
|
+
}
|
|
557
606
|
} catch (error) { failure = error; }
|
|
558
607
|
const cancelled = delegation?.cancelled;
|
|
559
608
|
this.delegations.delete(parent.id);
|
|
@@ -615,8 +664,8 @@ class HostRuntime {
|
|
|
615
664
|
|
|
616
665
|
async cancel(threadId) {
|
|
617
666
|
const thread = this.threads.find(t => t.id === threadId);
|
|
618
|
-
// send 进行中(会话恢复 / prompt 尚未投递)时 abort
|
|
619
|
-
if (this.sending.has(threadId)) this.cancelRequests.
|
|
667
|
+
// send 进行中(会话恢复 / prompt 尚未投递)时 abort 可能落空:按票据登记取消请求,由 #send 在投递前结算
|
|
668
|
+
if (this.sending.has(threadId)) this.cancelRequests.set(threadId, this.sendTickets.get(threadId));
|
|
620
669
|
// If this thread is a collaboration child task, mark the collaboration job as cancelled immediately
|
|
621
670
|
const childJob = [...this.collaboration.jobs.values()].find(j => j.childId === threadId && j.status === 'running');
|
|
622
671
|
if (childJob) {
|
|
@@ -625,7 +674,9 @@ class HostRuntime {
|
|
|
625
674
|
}
|
|
626
675
|
// Record the user's cancellation immediately so UI and Core become idle without waiting on subtasks
|
|
627
676
|
if (thread && this.execution.isRunning(thread.id)) this.#applyEvent({ threadId, event: { kind: 'completed', stopReason: 'cancelled' } });
|
|
677
|
+
// 立即放锁让用户可以重发;同时作废旧 send 的票据,其 finally 不得误删新发送的锁
|
|
628
678
|
this.sending.delete(threadId);
|
|
679
|
+
this.sendTickets.delete(threadId);
|
|
629
680
|
// Cancel child collaboration tasks with a hard timeout to prevent child hangs
|
|
630
681
|
try {
|
|
631
682
|
await Promise.race([
|
|
@@ -765,6 +816,8 @@ class HostRuntime {
|
|
|
765
816
|
} else this.sessions.set(thread.id, attachSession(adapter, session, thread.id));
|
|
766
817
|
await this.#save();
|
|
767
818
|
this.#broadcast();
|
|
819
|
+
// 与 createThread 同一契约:通知 Desktop 侧边栏实时挂载分支会话(protocol.js 据此发 thread/started)
|
|
820
|
+
for (const listener of this.listeners) listener({ type: 'thread-created', thread });
|
|
768
821
|
return thread;
|
|
769
822
|
}
|
|
770
823
|
|
|
@@ -1179,6 +1232,11 @@ class HostRuntime {
|
|
|
1179
1232
|
const last = this.turnActivity.get(thread.id) ?? this.execution.lastTurn(thread.id)?.createdAt ?? now;
|
|
1180
1233
|
if (now - last < this.stuckTurnMs) continue;
|
|
1181
1234
|
this.turnActivity.delete(thread.id);
|
|
1235
|
+
// 级联取消原生会话:仅结算 Turn 而不 abort,原生进程(死循环脚本/挂起的长连接)会
|
|
1236
|
+
// 常驻后台,下一次发送直接撞上原生侧的会话占用报错(如 Pi 的 already processing),
|
|
1237
|
+
// 该 Thread 永久无法恢复。fire-and-forget 不阻塞扫描;迟到事件由 execution.apply 忽略。
|
|
1238
|
+
const session = this.sessions.get(thread.id);
|
|
1239
|
+
if (session) void Promise.race([session.adapter.cancel(session), new Promise(r => setTimeout(r, 2000))]).catch(() => {});
|
|
1182
1240
|
this.#applyEvent({ threadId: thread.id, event: { kind: 'error', timestamp: now,
|
|
1183
1241
|
message: `原生会话超过 ${Math.round(this.stuckTurnMs / 60000)} 分钟未产生任何事件,判定为卡死并已自动结算。如原生进程仍在运行,可手动取消或开启新回合。` } });
|
|
1184
1242
|
}
|
package/src/main/native/host.js
CHANGED
|
@@ -83,6 +83,8 @@ async function runNativeHost() {
|
|
|
83
83
|
const passthrough = process.argv.slice(2);
|
|
84
84
|
const officialArgs = passthrough.includes('--listen') ? passthrough : [...passthrough, '--listen', 'stdio://'];
|
|
85
85
|
const official = spawn(stock, officialArgs, { env, windowsHide: true, stdio: ['pipe', 'pipe', 'inherit'] });
|
|
86
|
+
// official 异常退出后迟到的 stdin.write 会异步抛 EPIPE,无监听即 crash 宿主;退出由 close() 路径处理
|
|
87
|
+
official.stdin.on('error', () => {});
|
|
86
88
|
const forwarded = new Map();
|
|
87
89
|
const lines = readline.createInterface({ input: official.stdout });
|
|
88
90
|
lines.on('line', line => {
|
|
@@ -199,6 +199,16 @@ function pickTurnPermissions(params = {}) {
|
|
|
199
199
|
return Object.keys(picked).length ? picked : null;
|
|
200
200
|
}
|
|
201
201
|
|
|
202
|
+
// Desktop 渲染层按字面量匹配 agentMessage 的 phase 词表(commentary / final_answer):
|
|
203
|
+
// split-items-into-render-groups 只在 phase==='final_answer' 时把末段提取为回合结论,
|
|
204
|
+
// 其余 assistant-message 随执行区折叠进「用时」栏。内部 CoreEvent 词表是 progress / final,
|
|
205
|
+
// 在此 Codex 投影边界翻译;历史持久化数据(progress/final)也经此映射,无需迁移。
|
|
206
|
+
function agentMessagePhase(phase) {
|
|
207
|
+
if (phase === 'progress' || phase === 'commentary') return 'commentary';
|
|
208
|
+
if (phase === 'final' || phase === 'final_answer' || phase == null) return 'final_answer';
|
|
209
|
+
return phase;
|
|
210
|
+
}
|
|
211
|
+
|
|
202
212
|
function projectItem(item) {
|
|
203
213
|
const base = { id: item.id };
|
|
204
214
|
if (item.type === 'user_message') return { ...base, type: 'userMessage', content: [
|
|
@@ -207,7 +217,7 @@ function projectItem(item) {
|
|
|
207
217
|
? { type: 'image', url: `data:${a.mime || 'image/png'};base64,${a.data}` }
|
|
208
218
|
: a.path ? { type: 'localImage', path: a.path } : { type: 'text', text: `[图片:${a.name}]`, text_elements: [] }),
|
|
209
219
|
] };
|
|
210
|
-
if (item.type === 'agent_message' || item.type === 'notice') return { ...base, type: 'agentMessage', text: item.content || '', phase: item.phase
|
|
220
|
+
if (item.type === 'agent_message' || item.type === 'notice') return { ...base, type: 'agentMessage', text: item.content || '', phase: agentMessagePhase(item.phase) };
|
|
211
221
|
if (item.type === 'reasoning') return { ...base, type: 'reasoning', summary: [item.content || ''], content: [] };
|
|
212
222
|
if (item.type === 'tool_call' && item.collaboration) {
|
|
213
223
|
const job = item.collaboration;
|
|
@@ -1154,7 +1164,11 @@ class NativeProtocol {
|
|
|
1154
1164
|
const pending = this.approvals.get(message.id);
|
|
1155
1165
|
if (!pending) return false;
|
|
1156
1166
|
if (message.error) throw new Error(message.error.message || 'Approval UI error');
|
|
1157
|
-
|
|
1167
|
+
// Desktop 的 answers 为 string[]:多选提问(如 ACP/OpenCode multiple)勾选多项时
|
|
1168
|
+
// 只取 [0] 会无声吞掉其余选项,且 opencode 对 multiple 题执行 JSON.parse(response.value),
|
|
1169
|
+
// 单值字符串会直接抛 SyntaxError——多答案 JSON 编码全量保留,单答案维持原字符串。
|
|
1170
|
+
const rawAnswers = message.result?.answers?.[pending.requestId]?.answers;
|
|
1171
|
+
const answer = Array.isArray(rawAnswers) && rawAnswers.length > 1 ? JSON.stringify(rawAnswers) : (rawAnswers?.[0] ?? '');
|
|
1158
1172
|
const decision = message.result?.decision;
|
|
1159
1173
|
const response = pending.item.type === 'question' ? { value: answer || '' } : pending.item.method === 'select' ? { value: answer || '' } : { confirmed: decision === 'accept' || decision === 'acceptForSession' };
|
|
1160
1174
|
await this.runtime.respondApproval(pending.threadId, pending.requestId, response);
|
|
@@ -33,6 +33,8 @@ function run(args, input) {
|
|
|
33
33
|
}
|
|
34
34
|
resolve({ missing: false, stdout: Buffer.from(stdout || '') });
|
|
35
35
|
});
|
|
36
|
+
// helper 进程提前退出时 stdin.end 会异步抛 EPIPE,无监听即 crash 宿主;真实错误由 execFile 回调覆盖
|
|
37
|
+
if (child.stdin) child.stdin.on('error', () => {});
|
|
36
38
|
if (input !== undefined && child.stdin) child.stdin.end(Buffer.from(input));
|
|
37
39
|
});
|
|
38
40
|
}
|
|
@@ -75,6 +75,9 @@ async function projectReview(runtime, thread, message, record) {
|
|
|
75
75
|
);
|
|
76
76
|
|
|
77
77
|
let changes = record.changes;
|
|
78
|
+
// 本轮触碰路径惰性计算一次:供并发收窄与 foreign 过滤两处共用
|
|
79
|
+
let touched = null;
|
|
80
|
+
const touchedThisTurn = () => (touched ??= touchedPaths(runtime, thread, message));
|
|
78
81
|
if (hasNativePatch && record.concurrent) {
|
|
79
82
|
// 同目录并发时,原生 patch 是唯一能归属到本轮的边界。无并发时始终
|
|
80
83
|
// 保留 Host 最终快照,补齐原生 Harness 没有上报或漏报的文件。
|
|
@@ -85,14 +88,19 @@ async function projectReview(runtime, thread, message, record) {
|
|
|
85
88
|
// 无原生 patch 的 Harness(如 Pi):并发同目录时用本轮工具触碰路径收窄全量快照,
|
|
86
89
|
// 避免同项目其他会话的改动出现在本回合卡片里。本轮没有任何可归因路径时
|
|
87
90
|
// (纯 shell 会话等)保留全量快照,不虚报归属。
|
|
88
|
-
const
|
|
89
|
-
if (
|
|
91
|
+
const touchedSet = touchedThisTurn();
|
|
92
|
+
if (touchedSet.size) changes = changes.filter(change => touchedSet.has(change.path.toLowerCase()));
|
|
90
93
|
}
|
|
91
94
|
|
|
92
|
-
//
|
|
93
|
-
//
|
|
95
|
+
// 正向归属于其他会话的改动剔除(不依赖 concurrent 标记:对方可能在本回合开始后
|
|
96
|
+
// 才启动)。但本轮工具已明确触碰的文件属本会话自身的正向事实——foreign 集合扫描的是
|
|
97
|
+
// 其他会话的历史全部轮次,同目录旧会话碰过的路径会永久滞留其中,不得据此误剔本轮编辑,
|
|
98
|
+
// 否则该文件会从审查卡片与撤回列表中消失。无归属证据的改动保持原样,不虚报归属。
|
|
94
99
|
const foreign = foreignPaths(runtime, thread, message);
|
|
95
|
-
if (foreign.size)
|
|
100
|
+
if (foreign.size) {
|
|
101
|
+
const touchedSet = touchedThisTurn();
|
|
102
|
+
changes = changes.filter(change => touchedSet.has(change.path.toLowerCase()) || !foreign.has(change.path.toLowerCase()));
|
|
103
|
+
}
|
|
96
104
|
|
|
97
105
|
core.dispatch({ threadId: thread.id, turnId: message.coreTurnId, type: 'files.updated', payload: {
|
|
98
106
|
source: 'snapshot', replace: true,
|