@harness-mix/cli 0.2.2 → 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 CHANGED
@@ -1,5 +1,17 @@
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
+
3
15
  ## 0.2.2 — 2026-09-18
4
16
 
5
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.
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@harness-mix/cli",
3
- "version": "0.2.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",
@@ -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':Desktop 回合结算后把此前所有
96
- // progress 段与工具项折叠进「用时」栏,仅保留该段作为唯一正式结论。
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),
@@ -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));
@@ -345,8 +345,9 @@ class HostRuntime {
345
345
  async #send(threadId, text, { commandId, attachments, delegateOf, collaborationOf, isolated, turnPermissions, ticket }) {
346
346
  const thread = this.#requireThread(threadId);
347
347
  if (this.execution.isRunning(thread.id)) throw new Error("任务正在执行,请先停止或等待完成");
348
- // 清理上一代发送遗留的取消登记(票据不匹配说明属于已结算的旧 send)
349
- if (this.cancelRequests.get(threadId) !== ticket) this.cancelRequests.delete(threadId);
348
+ // 不得在此按票据清理 cancelRequests:登记可能属于仍停留在 Turn 启动前阶段(会话
349
+ // 打开/prompt 组装)的在途旧 send——删掉会让旧 send 错过下方的取消结算而继续投递,
350
+ // 与新发送双双进入原生会话。陈旧登记由本次 send 的 finally(票据匹配时)回收。
350
351
  const prepared = this.#prepareAttachments(thread, attachments);
351
352
  const typed = typeof text === "string" ? text.trim() : "";
352
353
  if (!typed && !prepared.images.length && !prepared.texts.length) throw new Error("请输入消息");
@@ -447,12 +448,19 @@ class HostRuntime {
447
448
  thread.updatedAt = Date.now();
448
449
  delete thread.error;
449
450
  delete thread.errorKind;
450
- this.execution.turnStarted(thread, displayPrompt);
451
- // 会话打开/prompt 组装期间用户已按停止(cancel 时 Turn 尚未开始,原生侧无从 abort):直接结算取消,不再投递
452
- if (this.cancelRequests.get(thread.id) === ticket) {
453
- this.cancelRequests.delete(thread.id);
454
- this.#applyEvent({ threadId, event: { kind: 'completed', stopReason: 'cancelled' } });
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;
455
461
  }
462
+ const turn = this.execution.turnStarted(thread, displayPrompt);
463
+ if (cancelledBeforeTurn) this.#applyEvent({ threadId, event: { kind: 'completed', stopReason: 'cancelled' } });
456
464
  this.turnActivity.set(thread.id, Date.now());
457
465
  const message = thread.messages.at(-1);
458
466
  if (hasConcurrentTurn) message.concurrent = true;
@@ -460,8 +468,12 @@ class HostRuntime {
460
468
  this.#syncCore(thread);
461
469
  try { if (!collaborationOf || isolated) message.reviewId = await this.reviews.begin(thread.cwd); else message.reviewOwnerThreadId = collaborationOf; }
462
470
  catch (e) { message.reviewError = '本轮未建立文件快照:' + e.message; }
463
- if (!this.execution.isRunning(thread.id)) {
464
- if (message.reviewId) await this.#settleReview(thread, message);
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);
465
477
  return;
466
478
  }
467
479
  this.startReviewUpdates(thread, message);
@@ -477,14 +489,16 @@ class HostRuntime {
477
489
  await this.#save();
478
490
  this.#broadcast();
479
491
  }
480
- // 投递前最后检查:Turn 可能在 review 快照/保存期间被取消(cancel 已结算)。
492
+ // 投递前最后检查:Turn 可能在 review 快照/保存期间被取消(cancel 已结算),
493
+ // 或已被取消后重发的新回合取代(lastTurn 易主时 isRunning 仍为真)。
481
494
  // 此时再投递,先到的 abort 会在原生侧落空,形成用户看不见的僵尸运行
482
- if (!this.execution.isRunning(thread.id)) {
495
+ const outdated = this.execution.lastTurn(thread.id)?.id !== turn.id;
496
+ if (!this.execution.isRunning(thread.id) || outdated) {
483
497
  if (handoff?.checkpointId) {
484
498
  handoff.phase = 'failed';
485
499
  await this.handoffs.mark(thread.id, handoff.checkpointId, 'failed').catch(() => {});
486
500
  }
487
- if (message.reviewId) await this.#settleReview(thread, message);
501
+ if (message.reviewId && !outdated) await this.#settleReview(thread, message);
488
502
  return;
489
503
  }
490
504
  await session.adapter.send(session, promptText, hooks, { images: prepared.images, turnPermissions });
@@ -498,8 +512,10 @@ class HostRuntime {
498
512
  handoff.phase = 'failed';
499
513
  await this.handoffs.mark(thread.id, handoff.checkpointId, 'failed').catch(() => {});
500
514
  }
501
- // 用户取消造成的 reject 已由 cancel() 结算,不再标错
502
- if (this.execution.isRunning(thread.id)) this.#applyEvent({ threadId, event: { kind: "error", message: error.message } });
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 } });
503
519
  await this.#save().catch(() => {});
504
520
  this.#broadcast();
505
521
  }
@@ -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 || 'final' };
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;
@@ -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 touched = touchedPaths(runtime, thread, message);
89
- if (touched.size) changes = changes.filter(change => touched.has(change.path.toLowerCase()));
91
+ const touchedSet = touchedThisTurn();
92
+ if (touchedSet.size) changes = changes.filter(change => touchedSet.has(change.path.toLowerCase()));
90
93
  }
91
94
 
92
- // 正向归属于其他会话的改动无条件剔除(不依赖 concurrent 标记:对方可能
93
- // 在本回合开始后才启动)。无归属证据的改动保持原样,不虚报归属。
95
+ // 正向归属于其他会话的改动剔除(不依赖 concurrent 标记:对方可能在本回合开始后
96
+ // 才启动)。但本轮工具已明确触碰的文件属本会话自身的正向事实——foreign 集合扫描的是
97
+ // 其他会话的历史全部轮次,同目录旧会话碰过的路径会永久滞留其中,不得据此误剔本轮编辑,
98
+ // 否则该文件会从审查卡片与撤回列表中消失。无归属证据的改动保持原样,不虚报归属。
94
99
  const foreign = foreignPaths(runtime, thread, message);
95
- if (foreign.size) changes = changes.filter(change => !foreign.has(change.path.toLowerCase()));
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,