@danceiny/gotry 0.2.0-rc.28 → 0.2.0-rc.30

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.
Files changed (44) hide show
  1. package/README.md +2 -2
  2. package/README.zh-CN.md +2 -2
  3. package/bin/gotry-bootstrap.js +9 -9
  4. package/bin/gotry-inner.js +7 -1
  5. package/cordis.gotry-patch.yml +2 -0
  6. package/data/yunnan-pack.json +30 -27
  7. package/dist/capabilities/artifacts.js +41 -4
  8. package/dist/capabilities/channel-registry.js +4 -4
  9. package/dist/capabilities/doctor.js +3 -3
  10. package/dist/capabilities/effect.js +1 -1
  11. package/dist/capabilities/session/extension-bridge.js +3 -3
  12. package/dist/capabilities/session-search.js +1 -1
  13. package/dist/scripts/agent-planning-turn-deadline-e2e.js +2 -0
  14. package/dist/scripts/agent-planning-turn-deadline-tests.js +135 -2
  15. package/dist/scripts/bootstrap-tests.js +11 -6
  16. package/dist/scripts/doctor-tests.js +2 -2
  17. package/dist/scripts/gotry-backend-kernel-tests.js +1 -1
  18. package/dist/scripts/issue-271-liveness-tests.js +6 -0
  19. package/dist/scripts/persona-sim-tests.js +19 -1
  20. package/dist/scripts/persona-sim.js +1 -1
  21. package/dist/scripts/session-dida-live-e2e.js +1 -1
  22. package/dist/scripts/turn-handoff-collect-tests.js +7 -1
  23. package/dist/scripts/turn-handoff-collect.js +68 -20
  24. package/dist/scripts/turn-handoff-job-tests.js +222 -0
  25. package/dist/scripts/turn-handoff-web-e2e.js +240 -0
  26. package/dist/scripts/unified-tests.js +71 -30
  27. package/dist/src/backend/modules/session-search.js +1 -1
  28. package/dist/src/index.js +30 -14
  29. package/dist/src/turn-deadline.js +113 -16
  30. package/dist/src/turn-handoff-job.js +124 -0
  31. package/extension/README.md +1 -1
  32. package/extension/README.zh-CN.md +1 -1
  33. package/extension/background.js +1 -1
  34. package/extension/content-bridge.js +1 -1
  35. package/extension/content-main.js +1 -1
  36. package/extension/manifest.json +2 -2
  37. package/package.json +2 -1
  38. package/ts/capabilities/artifacts.ts +40 -5
  39. package/ts/capabilities/session/extension-bridge.ts +3 -3
  40. package/ts/capabilities/session-search.ts +1 -1
  41. package/ts/scripts/turn-handoff-collect.ts +63 -21
  42. package/ts/src/index.ts +30 -14
  43. package/ts/src/turn-deadline.ts +111 -18
  44. package/ts/src/turn-handoff-job.ts +120 -0
@@ -0,0 +1,240 @@
1
+ /** Isolated DSH Web handoff journey. Synthetic model/planner; real host, jobs,
2
+ * processes, composer, completion notice, presentation and artifact tools.
3
+ * Never a live travel-research or published-package acceptance receipt. */
4
+ import assert from 'node:assert/strict';
5
+ import { execFileSync, spawn } from 'node:child_process';
6
+ import { createServer } from 'node:http';
7
+ import { existsSync, mkdirSync, mkdtempSync, readFileSync, readdirSync, rmSync, symlinkSync, writeFileSync } from 'node:fs';
8
+ import { tmpdir } from 'node:os';
9
+ import { basename, join } from 'node:path';
10
+ import puppeteer from 'puppeteer-core';
11
+ const ROOT = join(import.meta.dirname, '..', '..');
12
+ const sourceSha = execFileSync('git', ['rev-parse', 'HEAD'], { cwd: ROOT, encoding: 'utf8' }).trim();
13
+ const sourceDirty = execFileSync('git', ['status', '--porcelain'], { cwd: ROOT, encoding: 'utf8' }).trim() !== '';
14
+ const CHROME = '/Applications/Google Chrome.app/Contents/MacOS/Google Chrome';
15
+ const evidence = process.env.GOTRY_HANDOFF_WEB_E2E_OUT ?? mkdtempSync(join(tmpdir(), 'gotry-handoff-web-evidence-'));
16
+ const isolated = mkdtempSync(join(tmpdir(), 'gotry-handoff-web-'));
17
+ const product = join(isolated, 'product');
18
+ const hostCwd = join(isolated, 'host');
19
+ const workspace = join(isolated, 'workspace');
20
+ const stateRoot = join(isolated, 'handoff-state');
21
+ const home = join(isolated, 'home');
22
+ const dshHome = join(isolated, 'dsh');
23
+ for (const dir of [evidence, product, hostCwd, workspace, stateRoot, home, dshHome])
24
+ mkdirSync(dir, { recursive: true });
25
+ const planner = join(isolated, 'planner.mjs');
26
+ writeFileSync(planner, `console.error('DIAGNOSTIC_NOT_A_PLAN');setTimeout(()=>console.log('# Completed synthetic plan\\nWorkspace: '+process.cwd()+'\\n'+process.argv[2]),15000)`);
27
+ const bodies = [];
28
+ const served = [];
29
+ let phase = 0;
30
+ let serverLog = '';
31
+ let notified = false;
32
+ let relayError;
33
+ let browser;
34
+ let page;
35
+ let child;
36
+ const pageErrors = [];
37
+ const sse = (data) => `data: ${JSON.stringify(data)}\n\n`;
38
+ function response(name, input = {}, text = '') {
39
+ const block = name ? { type: 'tool_use', id: `handoff-${phase}`, name, input } : { type: 'text', text };
40
+ return sse({ type: 'message_start', message: { usage: { input_tokens: 10 } } })
41
+ + sse({ type: 'content_block_start', index: 0, content_block: block })
42
+ + sse({ type: 'content_block_stop', index: 0 })
43
+ + sse({ type: 'message_delta', delta: { stop_reason: name ? 'tool_use' : 'end_turn' }, usage: { output_tokens: 10 } })
44
+ + sse({ type: 'message_stop' }) + 'data: [DONE]\n\n';
45
+ }
46
+ const ticketDir = join(stateRoot, 'gotry-state', 'turn-handoffs');
47
+ function ticket() {
48
+ const names = existsSync(ticketDir) ? readdirSync(ticketDir).filter(name => name.endsWith('.json')) : [];
49
+ assert.equal(names.length, 1, 'one durable ticket');
50
+ return JSON.parse(readFileSync(join(ticketDir, names[0]), 'utf8'));
51
+ }
52
+ const relay = createServer((req, res) => {
53
+ if (req.method !== 'POST' || !req.url?.endsWith('/messages')) {
54
+ res.writeHead(404).end();
55
+ return;
56
+ }
57
+ const chunks = [];
58
+ req.on('data', chunk => chunks.push(Buffer.from(chunk)));
59
+ req.on('end', () => {
60
+ try {
61
+ const body = JSON.parse(Buffer.concat(chunks).toString('utf8'));
62
+ bodies.push(body);
63
+ res.writeHead(200, { 'content-type': 'text/event-stream', 'cache-control': 'no-cache' });
64
+ const names = (body.tools ?? []).map((tool) => tool.name ?? tool.function?.name);
65
+ const messages = JSON.stringify(body.messages ?? []);
66
+ const isPlanner = names.includes('write') || names.includes('present') || names.includes('job_output');
67
+ if (!isPlanner) {
68
+ res.end(response(undefined, {}, 'Synthetic title'));
69
+ return;
70
+ }
71
+ let name;
72
+ let input = {};
73
+ let final = '';
74
+ let delay = 0;
75
+ if (phase === 0) {
76
+ name = 'write';
77
+ input = { file_path: 'exploration.md', content: '# Synthetic exploration\nWork hours 9-18 CST; original request retained.' };
78
+ // Cross the foreground deadline deliberately, while giving the later
79
+ // completion/query turn enough time for several native tool round trips.
80
+ delay = 5500;
81
+ }
82
+ else if (phase === 1) {
83
+ name = 'present';
84
+ input = { files: [{ path: join(workspace, 'exploration.md'), description: '探索稿' }] };
85
+ }
86
+ else if (phase === 2) {
87
+ final = '探索稿已保存,后台任务已启动。';
88
+ }
89
+ else if (phase === 3) {
90
+ assert.match(messages, /finished.*completed/, 'native completion notice wakes the owning session');
91
+ notified = true;
92
+ name = 'job_output';
93
+ input = { job_id: ticket().jobId, wait: false };
94
+ }
95
+ else if (phase === 4) {
96
+ assert.ok(messages.includes('Planning completed:'), 'native job result reaches the model');
97
+ name = 'present';
98
+ input = { files: [{ path: join(ticketDir, ticket().deliverableFile), description: '最终交付物' }] };
99
+ }
100
+ else if (phase === 5) {
101
+ name = 'gotry_artifacts_list';
102
+ input = { limit: 20 };
103
+ }
104
+ else if (phase === 6) {
105
+ assert.ok(messages.includes(ticket().id), 'artifact list includes the same ticket');
106
+ name = 'gotry_artifacts_read';
107
+ input = { path: ticket().id };
108
+ }
109
+ else {
110
+ final = '最终规划已完成并展示,可按工单重新读取。';
111
+ }
112
+ if (name) {
113
+ assert.ok(names.includes(name), `${name} remains available in phase ${phase}: ${names}`);
114
+ served.push(name);
115
+ }
116
+ phase++;
117
+ setTimeout(() => res.end(response(name, input, final)), delay);
118
+ }
119
+ catch (error) {
120
+ relayError = String(error);
121
+ res.end(response(undefined, {}, 'RELAY_TEST_FAILURE'));
122
+ }
123
+ });
124
+ });
125
+ try {
126
+ assert.ok(existsSync(CHROME), 'Chrome is required for the browser receipt');
127
+ assert.ok(existsSync(join(ROOT, 'dist/src/turn-handoff-job.js')), 'build dist before the browser journey');
128
+ // The real source launcher normally uses ts/dsh-runtime as host cwd. Run the
129
+ // exact built package in a private layout instead, so even legacy state reads
130
+ // and process-guard logs cannot touch the founder's runtime directory.
131
+ const packed = JSON.parse(execFileSync('npm', ['pack', '--ignore-scripts', '--json', '--pack-destination', isolated], { cwd: ROOT, encoding: 'utf8' }))[0];
132
+ execFileSync('tar', ['xzf', join(isolated, packed.filename), '--strip-components=1', '-C', product]);
133
+ symlinkSync(join(ROOT, 'node_modules'), join(product, 'node_modules'), 'dir');
134
+ await new Promise(resolve => relay.listen(0, '127.0.0.1', resolve));
135
+ const port = relay.address().port;
136
+ child = spawn(process.execPath, [join(product, 'bin/gotry-inner.js'), 'web', '--no-open', '--port', '0'], {
137
+ cwd: hostCwd,
138
+ env: { ...process.env, HOME: home, DSH_HOME: dshHome, GOTRY_SETUP_SKIP: '1', GOTRY_ONBOARDING_SKIP: '1',
139
+ GOTRY_SESSION_LIVE: '0', GOTRY_HBCLI_LIVE: '0', GOTRY_HOTELBYTE_SKILLS_LIVE: '0',
140
+ LLM_API_KEY: 'synthetic-only', LLM_BASE_URL: `http://127.0.0.1:${port}/v1`, LLM_MODEL: 'synthetic-handoff-web',
141
+ GOTRY_LLM_MODEL: 'synthetic-handoff-web', DEEPSEEK_API_KEY: 'synthetic-only', DEEPSEEK_BASE_URL: `http://127.0.0.1:${port}/v1`,
142
+ GOTRY_TURN_HANDOFF_ROOT: stateRoot, GOTRY_HANDOFF_PLANNER_BIN: planner,
143
+ GOTRY_TURN_DEADLINE_SOFT_MS: '2000', GOTRY_TURN_DEADLINE_HARD_MS: '5000' },
144
+ stdio: ['ignore', 'pipe', 'pipe'],
145
+ });
146
+ const url = await new Promise((resolve, reject) => {
147
+ const timer = setTimeout(() => reject(new Error(`Web startup timed out: ${serverLog.slice(-4000)}`)), 30000);
148
+ const collect = (chunk) => {
149
+ serverLog += chunk.toString();
150
+ const match = serverLog.match(/dsh web: (http:\/\/127\.0\.0\.1:\d+\/\?token=[^\s)]+)/);
151
+ if (match) {
152
+ clearTimeout(timer);
153
+ resolve(match[1]);
154
+ }
155
+ };
156
+ child.stdout.on('data', collect);
157
+ child.stderr.on('data', collect);
158
+ child.once('error', reject);
159
+ });
160
+ browser = await puppeteer.launch({ executablePath: CHROME, headless: true, userDataDir: join(isolated, 'chrome'), args: ['--no-sandbox', '--disable-gpu'] });
161
+ page = await browser.newPage();
162
+ page.setDefaultTimeout(30000);
163
+ page.on('pageerror', (error) => pageErrors.push(error.message));
164
+ await page.goto(url, { waitUntil: 'networkidle2' });
165
+ const dismiss = async () => {
166
+ await page.waitForFunction(() => [...document.querySelectorAll('button')].some(node => ['继续', 'Continue'].includes(node.textContent?.trim() ?? '')));
167
+ await page.evaluate(() => [...document.querySelectorAll('button')].find(node => ['继续', 'Continue'].includes(node.textContent?.trim() ?? ''))?.click());
168
+ };
169
+ await dismiss();
170
+ const registered = await page.evaluate(async (path) => {
171
+ const response = await fetch('/api/workspace/create', { method: 'POST', headers: { 'content-type': 'application/json' },
172
+ body: JSON.stringify({ type: 'client-request', rpcId: crypto.randomUUID(), method: 'workspace/create', payload: { args: { request: { path } } } }) });
173
+ return response.json();
174
+ }, workspace);
175
+ assert.equal(registered.result?.ok, true, 'register the isolated workspace through public RPC');
176
+ await page.reload({ waitUntil: 'networkidle2' });
177
+ await page.waitForFunction(() => [...document.querySelectorAll('button')].some(node => /Default workspace|默认工作区/.test(node.textContent ?? '')));
178
+ await page.evaluate(() => [...document.querySelectorAll('button')].find(node => /Default workspace|默认工作区/.test(node.textContent ?? ''))?.click());
179
+ await page.waitForFunction((title) => [...document.querySelectorAll('[role="menuitem"], [role="option"], button')].some(node => node.textContent?.trim() === title), {}, basename(workspace));
180
+ await page.evaluate((title) => [...document.querySelectorAll('[role="menuitem"], [role="option"], button')].find(node => node.textContent?.trim() === title)?.click(), basename(workspace));
181
+ await page.waitForSelector('[contenteditable="true"]');
182
+ await page.click('[contenteditable="true"]');
183
+ await page.keyboard.type('12月初阿联酋国庆,借5天IRW远程办公和请假回国十几天,去东北长春泡澡,女朋友从南京过去,请探索具体规划。工作时间9-18 CST。');
184
+ await page.click('button[aria-label="发送消息"], button[aria-label="Send message"]');
185
+ await page.waitForFunction(() => /探索稿已保存|RELAY_TEST_FAILURE/.test(document.body.innerText), { timeout: 30000 });
186
+ assert.equal(relayError, undefined);
187
+ assert.ok(existsSync(join(workspace, 'exploration.md')), 'deadline draft really exists in the original workspace');
188
+ const running = ticket();
189
+ assert.equal(running.status, 'running');
190
+ assert.ok(running.jobId);
191
+ await page.screenshot({ path: join(evidence, 'running.png'), fullPage: true });
192
+ await page.waitForFunction(() => /最终规划已完成并展示|RELAY_TEST_FAILURE/.test(document.body.innerText), { timeout: 60000 });
193
+ assert.equal(relayError, undefined);
194
+ const settled = ticket();
195
+ assert.equal(settled.status, 'settled');
196
+ const outputPath = join(ticketDir, settled.deliverableFile);
197
+ const output = readFileSync(outputPath, 'utf8');
198
+ assert.ok(output.includes('Completed synthetic plan') && output.includes(workspace));
199
+ assert.ok(output.includes('9-18 CST'), 'prior constraints travel to the worker');
200
+ assert.ok(!output.includes('DIAGNOSTIC_NOT_A_PLAN'));
201
+ assert.ok(notified);
202
+ assert.deepEqual(pageErrors, []);
203
+ const fileCards = await page.evaluate(() => [...document.querySelectorAll('button')].map(node => ({ text: node.textContent, label: node.getAttribute('aria-label'), title: node.getAttribute('title') })));
204
+ const openLabel = await page.evaluate((path) => {
205
+ const button = [...document.querySelectorAll('button')].find(node => [node.getAttribute('title'), node.getAttribute('aria-label'), node.textContent].some(text => text?.includes(path) || text?.includes(path.split('/').at(-1))));
206
+ if (!button)
207
+ return false;
208
+ button.click();
209
+ return true;
210
+ }, outputPath);
211
+ assert.ok(openLabel, `native file card must offer the final output: ${JSON.stringify(fileCards).slice(-4000)}`);
212
+ await page.waitForFunction(() => document.body.innerText.includes('Completed synthetic plan'), { timeout: 30000 });
213
+ await page.screenshot({ path: join(evidence, 'completed-preview.png'), fullPage: true });
214
+ writeFileSync(join(evidence, 'receipt.json'), JSON.stringify({ passed: true, evidence: 'synthetic_fixture',
215
+ runtime: 'source-built DSH Web', layout: 'isolated tarball with repository dependencies', sourceSha, sourceDirty, nodeVersion: process.version, served, notified, ticketId: settled.id, jobId: settled.jobId,
216
+ draftWritten: true, finalPresented: true, previewOpened: true, status: settled.status, pageErrors }, null, 2));
217
+ console.log(`handoff web E2E: OK; synthetic receipt: ${evidence}`);
218
+ }
219
+ catch (error) {
220
+ if (page) {
221
+ await page.screenshot({ path: join(evidence, 'failure.png'), fullPage: true }).catch(() => { });
222
+ writeFileSync(join(evidence, 'failure-dom.txt'), await page.evaluate(() => document.body.innerText + '\n' + JSON.stringify([...document.querySelectorAll('button')].map(node => ({ text: node.textContent, label: node.getAttribute('aria-label'), title: node.getAttribute('title') })))).catch(() => ''));
223
+ }
224
+ writeFileSync(join(evidence, 'failure.json'), JSON.stringify({ error: String(error), phase, served, pageErrors,
225
+ serverLog: serverLog.replace(/token=[^\s)]+/g, 'token=[redacted]').slice(-5000) }, null, 2));
226
+ writeFileSync(join(evidence, 'requests.json'), JSON.stringify(bodies, null, 2));
227
+ throw error;
228
+ }
229
+ finally {
230
+ await browser?.close();
231
+ if (child && child.exitCode === null) {
232
+ const closed = new Promise(resolve => child.once('close', () => resolve()));
233
+ child.kill('SIGTERM');
234
+ const timer = setTimeout(() => child.kill('SIGKILL'), 5000);
235
+ await closed;
236
+ clearTimeout(timer);
237
+ }
238
+ await new Promise(resolve => relay.close(() => resolve()));
239
+ rmSync(isolated, { recursive: true, force: true });
240
+ }
@@ -3,7 +3,7 @@
3
3
  * 运行(在 ts/ 下):npx tsx scripts/unified-tests.ts
4
4
  */
5
5
  import assert from 'node:assert/strict';
6
- import { readFile } from 'node:fs/promises';
6
+ import { readdir, readFile } from 'node:fs/promises';
7
7
  import { join } from 'node:path';
8
8
  import { parseFlightPackToSpec, solveUnified } from '../src/unified.js';
9
9
  import { hhmmToMin } from '../src/model.js';
@@ -50,23 +50,62 @@ off.budgetCny = 9000;
50
50
  off.workWindow = undefined;
51
51
  const r4 = await solveUnified(off);
52
52
  assert.deepEqual(r4.work_window_exclusions, []); // 关掉窗口,周五班恢复可选
53
- // 5. Issue #620:yunnan-pack 的 yn0 段缺 buffer_min/origin_transfer_min/dest_transfer_min
53
+ // 5. Issue #620 / #635:云南包的 yn0 衔接段缺 buffer_min/origin_transfer_min/dest_transfer_min
54
54
  // → 旧实现 Number(undefined)=NaN → Int.val(NaN) 在 z3 WASM 里 `Assertion failed`
55
- // → 被兜底翻译成「不可行 + unsat_core:['wasm_runtime_error']」。三条红线:
56
- // ① parse 边界必须点名拒收(不默认补 0);② 绕过 parse 的 NaN spec 必须以
57
- // solver_error 返回而**绝不**是 infeasible;③ 补齐那三个字段后整包必须真能判定。
58
- const yunnanRaw = JSON.parse(await readFile(join('..', 'data', 'yunnan-pack.json'), 'utf-8'));
59
- // ① 真实数据包:yn0 的三个缺字段 → NaN → 必须是显式 solver_error,绝不是「不可行」
60
- const yunnanSpecRaw = parseFlightPackToSpec(structuredClone(yunnanRaw));
61
- yunnanSpecRaw.budgetCny = 9000;
62
- assert.ok(Number.isNaN(yunnanSpecRaw.segments[0].options[0].move.bufferMin), 'yn0 缺 buffer_min 的事实前提');
63
- const rYunnanRaw = await solveUnified(yunnanSpecRaw);
64
- assert.equal(rYunnanRaw.solver_error?.code, 'solver_input_not_integer', `yunnan 包必须以 solver_error 返回(got ${JSON.stringify(rYunnanRaw)})`);
65
- assert.ok(rYunnanRaw.solver_error.message.includes('yn0.bufferMin'), `solver_error 必须点名出问题的段与字段(got ${rYunnanRaw.solver_error.message})`);
66
- assert.equal(rYunnanRaw.feasible, false, 'solver_error 轮次没有产出可行解');
67
- assert.deepEqual(rYunnanRaw.unsat_core, undefined, '求解器失败不得伪造 unsat_core(机器 token 不进冲突位)');
68
- assert.ok(!JSON.stringify(rYunnanRaw).includes('wasm_runtime_error'), '不得再出现 wasm_runtime_error 这个伪冲突 token');
69
- // ② 最小输入:健康包里塞一个 NaN 接驳分钟 → 同样是 solver_error,点名到字段
55
+ // → 被兜底翻译成「不可行 + unsat_core:['wasm_runtime_error']」。五条红线:
56
+ // ① 出厂数据包里挂接引擎的每一段(`legs`)三字段必须是非负整数;缺真值的段只能记在
57
+ // `advisory_legs`(带理由、不挂接)——替它填数等于编造真实世界的事实(ADR-10);
58
+ // ② 出厂云南包整包可判定,yn0 不进求解(founder 2026-10-05 选「标 advisory」);
59
+ // ③ 防线仍在:缺字段的包(由测试构造)必须以 solver_error 返回而**绝不**是 infeasible;
60
+ // ④ 最小输入:健康包里塞一个 NaN 接驳分钟 → 同样是 solver_error;
61
+ // ⑤ 晋升路径:founder 给出三个真值后,把 advisory 段搬回 legs 即可整包判定。
62
+ const DATA_DIR = join('..', 'data');
63
+ const TRANSFER_FIELDS = ['buffer_min', 'origin_transfer_min', 'dest_transfer_min'];
64
+ const yunnanRaw = JSON.parse(await readFile(join(DATA_DIR, 'yunnan-pack.json'), 'utf-8'));
65
+ // ① 数据完整性:data/ 下每个带 legs 的包,挂接引擎的段三字段齐全;advisory 段自带理由且不与 legs 重名
66
+ let auditedPacks = 0;
67
+ for (const file of (await readdir(DATA_DIR)).filter(f => f.endsWith('.json')).sort()) {
68
+ const raw = JSON.parse(await readFile(join(DATA_DIR, file), 'utf-8'));
69
+ if (!Array.isArray(raw['legs']))
70
+ continue;
71
+ auditedPacks += 1;
72
+ const legs = raw['legs'];
73
+ for (const leg of legs) {
74
+ for (const field of TRANSFER_FIELDS) {
75
+ const value = leg[field];
76
+ assert.ok(typeof value === 'number' && Number.isInteger(value) && value >= 0, `${file}: leg ${String(leg['id'])}.${field} 必须是非负整数(got ${JSON.stringify(value)});缺真值的段请放进 advisory_legs,不要补编造值(issue #635)`);
77
+ }
78
+ }
79
+ const engineIds = new Set(legs.map(leg => String(leg['id'])));
80
+ for (const advisory of (raw['advisory_legs'] ?? [])) {
81
+ const reason = advisory['advisory_reason'];
82
+ assert.ok(typeof reason === 'string' && reason.trim() !== '', `${file}: advisory 段 ${String(advisory['id'])} 必须写明 advisory_reason`);
83
+ assert.ok(!engineIds.has(String(advisory['id'])), `${file}: ${String(advisory['id'])} 不能同时在 legs 与 advisory_legs`);
84
+ }
85
+ }
86
+ assert.ok(auditedPacks >= 2, `数据完整性审计应覆盖航班包与云南包(审计了 ${auditedPacks} 个)`);
87
+ // ② 出厂云南包按产品同款路径整包可判定;advisory 段 yn0 不进引擎段链,也不参与求解
88
+ const yunnanSpec = parseFlightPackToSpec(structuredClone(yunnanRaw));
89
+ assert.deepEqual(yunnanSpec.segments.map(s => s.id), ['yn1', 'yn2', 'yn3', 'yn4'], 'advisory 段 yn0 不得进引擎段链');
90
+ yunnanSpec.budgetCny = 9000;
91
+ const rYunnan = await solveUnified(yunnanSpec);
92
+ assert.equal(rYunnan.solver_error, undefined, `出厂云南包不得再有求解器失败(got ${JSON.stringify(rYunnan.solver_error)})`);
93
+ assert.equal(rYunnan.feasible, true, `出厂云南包应可行(unsat_core=${JSON.stringify(rYunnan.unsat_core)})`);
94
+ assert.deepEqual(rYunnan.legs.map(l => l.leg), ['yn1', 'yn2', 'yn3', 'yn4'], '四段全判定,yn0 不在其中');
95
+ // ③ 防线仍在:缺三个字段的包(由测试从出厂包构造)→ NaN → 必须是显式 solver_error,绝不是「不可行」
96
+ const yunnanGap = structuredClone(yunnanRaw);
97
+ for (const field of TRANSFER_FIELDS)
98
+ delete yunnanGap['legs'][0][field];
99
+ const yunnanGapSpec = parseFlightPackToSpec(yunnanGap);
100
+ yunnanGapSpec.budgetCny = 9000;
101
+ assert.ok(Number.isNaN(yunnanGapSpec.segments[0].options[0].move.bufferMin), '缺 buffer_min 的事实前提');
102
+ const rYunnanGap = await solveUnified(yunnanGapSpec);
103
+ assert.equal(rYunnanGap.solver_error?.code, 'solver_input_not_integer', `缺字段的包必须以 solver_error 返回(got ${JSON.stringify(rYunnanGap)})`);
104
+ assert.ok(rYunnanGap.solver_error.message.includes('yn1.bufferMin'), `solver_error 必须点名出问题的段与字段(got ${rYunnanGap.solver_error.message})`);
105
+ assert.equal(rYunnanGap.feasible, false, 'solver_error 轮次没有产出可行解');
106
+ assert.deepEqual(rYunnanGap.unsat_core, undefined, '求解器失败不得伪造 unsat_core(机器 token 不进冲突位)');
107
+ assert.ok(!JSON.stringify(rYunnanGap).includes('wasm_runtime_error'), '不得再出现 wasm_runtime_error 这个伪冲突 token');
108
+ // ④ 最小输入:健康包里塞一个 NaN 接驳分钟 → 同样是 solver_error,点名到字段
70
109
  const nanSpec = parseFlightPackToSpec(pack);
71
110
  nanSpec.budgetCny = 9000;
72
111
  nanSpec.segments[0].options[0].move.bufferMin = Number.NaN;
@@ -74,16 +113,18 @@ const rNan = await solveUnified(nanSpec);
74
113
  assert.equal(rNan.solver_error?.code, 'solver_input_not_integer', `NaN 接驳必须以 solver_error 返回(got ${JSON.stringify(rNan)})`);
75
114
  assert.ok(rNan.solver_error.message.includes('f1.bufferMin'), `solver_error 必须点名字段(got ${rNan.solver_error.message})`);
76
115
  assert.equal(rNan.feasible, false, 'solver_error 轮次没有产出可行解');
77
- // ③ 补齐 yn0 的三个字段(由测试提供,不改数据包:真值待 founder 校准)→ 整包真能判定,
78
- // 证明断言失败的最小输入正是这三个字段,其余四段编码本身没问题
79
- const yunnanFixed = structuredClone(yunnanRaw);
80
- Object.assign(yunnanFixed['legs'][0], {
81
- buffer_min: 60, origin_transfer_min: 30, dest_transfer_min: 30,
82
- });
83
- const yunnanSpec = parseFlightPackToSpec(yunnanFixed);
84
- yunnanSpec.budgetCny = 9000;
85
- const rYunnan = await solveUnified(yunnanSpec);
86
- assert.equal(rYunnan.solver_error, undefined, `补齐三字段后不得再有求解器失败(got ${JSON.stringify(rYunnan.solver_error)})`);
87
- assert.equal(rYunnan.feasible, true, `yunnan 整包应可行(unsat_core=${JSON.stringify(rYunnan.unsat_core)})`);
88
- assert.equal(rYunnan.legs.length, 5, '五段全判定');
89
- console.log(`TS UNIFIED TESTS: 5/5 OK(工作窗口生效,f2=VZ303,¥${r1.money_cny};#620 yunnan NaN→solver_error 点名 yn0,补齐三字段后可行 ¥${rYunnan.money_cny})`);
116
+ // ⑤ 晋升路径:三个真值由测试提供(数据包里不写——真值待 founder 校准),把 advisory 段搬回 legs 的头部
117
+ // → 五段全判定,证明「补齐三个整数」正是 yn0 从 advisory 晋升所需的全部
118
+ const yunnanPromoted = structuredClone(yunnanRaw);
119
+ const [promotedYn0] = yunnanPromoted['advisory_legs'];
120
+ delete promotedYn0['advisory_reason'];
121
+ Object.assign(promotedYn0, { buffer_min: 60, origin_transfer_min: 30, dest_transfer_min: 30 });
122
+ yunnanPromoted['legs'] = [promotedYn0, ...yunnanPromoted['legs']];
123
+ delete yunnanPromoted['advisory_legs'];
124
+ const promotedSpec = parseFlightPackToSpec(yunnanPromoted);
125
+ promotedSpec.budgetCny = 9000;
126
+ const rPromoted = await solveUnified(promotedSpec);
127
+ assert.equal(rPromoted.solver_error, undefined, `补齐三字段后不得再有求解器失败(got ${JSON.stringify(rPromoted.solver_error)})`);
128
+ assert.equal(rPromoted.feasible, true, `晋升后的云南包应可行(unsat_core=${JSON.stringify(rPromoted.unsat_core)})`);
129
+ assert.equal(rPromoted.legs.length, 5, '晋升后五段全判定');
130
+ console.log(`TS UNIFIED TESTS: 5/5 OK(工作窗口生效,f2=VZ303,¥${r1.money_cny};#620/#635 ${auditedPacks} 个包三字段齐全,出厂云南包四段可行,缺字段→solver_error 点名 yn1,晋升后五段可行)`);
@@ -384,7 +384,7 @@ export function startSessionSearchModule(options) {
384
384
  if (!queue.extensionConnected()) {
385
385
  sendJson(res, 200, {
386
386
  ok: true,
387
- suppliers: [{ supplier: 'dida-portal', loggedIn: false, reason: 'extension-not-connected', detail: '会话执行环境=管理员浏览器扩展(GoTry Session Bridge);扩展未连接桥——在管理员浏览器安装扩展并配置本服务桥地址' }],
387
+ suppliers: [{ supplier: 'dida-portal', loggedIn: false, reason: 'extension-not-connected', detail: '会话执行环境=管理员浏览器扩展(Stai Travel Bridge);扩展未连接桥——在管理员浏览器安装扩展并配置本服务桥地址' }],
388
388
  bridge: queue.stats(),
389
389
  checkedAt,
390
390
  });
package/dist/src/index.js CHANGED
@@ -48,6 +48,7 @@ import { factsFromFlyai, factsFromHotel, factsFromSession, factsFromSessionTrain
48
48
  import { hasRecognizedAvailableSeat } from '../capabilities/session/adapters/rail-12306.js';
49
49
  import { gateArtifact } from './artifact-gate.js';
50
50
  import { installTurnDeadline, listTurnHandoffTickets } from './turn-deadline.js';
51
+ import { startTurnHandoffJob } from './turn-handoff-job.js';
51
52
  import { noteChannelVerdict, recordChannelEvent, readLatestChannelEvents } from '../capabilities/channel-health.js';
52
53
  import { routingAdvice, renderRoutingCard, toolRoutingHeadline, persistedDownChannels } from '../capabilities/channel-registry.js';
53
54
  import { registerBenchmarkEnvironmentBridge } from './benchmark-environment-bridge.js';
@@ -433,7 +434,11 @@ export function apply(ctx, config, seams = {}) {
433
434
  ? { fixedPolicy: { softMs: 300_000, hardMs: 900_000, exit: 'converge' } }
434
435
  : rawBenchmarkEnvironmentConfigPath.trim()
435
436
  ? { fixedPolicy: benchmarkPinnedPolicy() }
436
- : { stateRoot: process.env.GOTRY_TURN_HANDOFF_ROOT ?? config.stateRoot ?? '.' });
437
+ : { stateRoot: process.env.GOTRY_TURN_HANDOFF_ROOT ?? config.stateRoot ?? '.',
438
+ // A one-shot headless process exits after its final response and cannot
439
+ // own ongoing work. Keep that ticket queued for explicit collection.
440
+ startHandoff: (ticket, agent, root) => typeof ctx.get === 'function' && ctx.get('connection')
441
+ ? startTurnHandoffJob(ctx, ticket, agent, root) : Promise.resolve(ticket) });
437
442
  if (rawBenchmarkEnvironmentConfigPath.trim()) {
438
443
  // Benchmark mode is a deliberately minimal kernel: only the model
439
444
  // override and the environment bridge are installed besides the pinned
@@ -987,8 +992,8 @@ export function apply(ctx, config, seams = {}) {
987
992
  registerGuarded(defineTool({
988
993
  name: 'gotry_turn_handoff_list',
989
994
  description: 'List background deep-planning handoff tickets (read-only). Call this whenever the user asks about '
990
- + 'a previously handed-off plan («规划好了吗» / «上次那个行程»): open = still being worked in the '
991
- + 'background (ETA ' + '约 1 小时' + '), settled = deliverable ready (excerpt included, full text in the '
995
+ + 'a previously handed-off plan («规划好了吗» / «上次那个行程»): open = queued, worker not started; '
996
+ + 'running = started with a native job id (use job_list for live status); settled = deliverable ready (excerpt included, full text in the '
992
997
  + 'ticket\'s .deliverable.md), failed = honest failure note. Never fabricate a deliverable that is not here.',
993
998
  // D-30 第三刀(issue #112):query blob → 平铺 typed;全字段可选 → interpretArgs 容忍层(同 session 刀法)
994
999
  parameters: {
@@ -998,9 +1003,10 @@ export function apply(ctx, config, seams = {}) {
998
1003
  schema: { type: 'json' },
999
1004
  render: (_args, value) => [{ type: 'text', text: String(value.summary ?? '') }],
1000
1005
  },
1001
- async execute(args, _exec) {
1006
+ async execute(args, exec) {
1002
1007
  const q = interpretArgs(args);
1003
- const root = process.env.GOTRY_TURN_HANDOFF_ROOT ?? config.stateRoot ?? '.';
1008
+ const configuredRoot = process.env.GOTRY_TURN_HANDOFF_ROOT ?? config.stateRoot ?? '.';
1009
+ const root = configuredRoot === '.' ? sessionCwd(exec) ?? '.' : configuredRoot;
1004
1010
  const all = await listTurnHandoffTickets(root);
1005
1011
  const tickets = (q.ticketId ? all.filter(t => t.id === q.ticketId) : all).slice(0, 10);
1006
1012
  if (tickets.length === 0) {
@@ -1014,10 +1020,19 @@ export function apply(ctx, config, seams = {}) {
1014
1020
  const lines = tickets.map(t => {
1015
1021
  const head = `[${t.status}] ${t.id} ${t.objective.slice(0, 40)}`;
1016
1022
  if (t.status === 'open')
1017
- return `${head}——后台规划中,ETA ${t.etaLabel}`;
1023
+ return `${head}——待启动,后台尚未运行`;
1024
+ if (t.status === 'running') {
1025
+ const owner = exec.agent;
1026
+ // Native counters restart with the host. A recycled id alone is not
1027
+ // the durable ticket's identity and must not offer another trip's job.
1028
+ const live = owner && ctx.get('jobs')?.list(owner.id).some(job => job.id === t.jobId
1029
+ && job.owner === owner.id && job.kind === 'gotry-planning' && job.label.startsWith(`${t.id}: `)
1030
+ && (job.status === 'running' || job.status === 'stopping'));
1031
+ return `${head}——${live ? `后台任务 ${t.jobId},预计 ${t.etaLabel}` : '运行记录已中断,当前没有活跃后台任务'} `;
1032
+ }
1018
1033
  if (t.status === 'failed')
1019
1034
  return `${head}——失败:${t.error ?? '未知原因'}(请重新发起规划)`;
1020
- return `${head}——已交付,摘要:${(t.deliverableExcerpt ?? '').slice(0, 120).replace(/\n/g, ' ')}`;
1035
+ return `${head}——${t.deliverablePath ? `已交付:${t.deliverablePath}` : '交付文件缺失'},摘要:${(t.deliverableExcerpt ?? '').slice(0, 120).replace(/\n/g, ' ')}`;
1021
1036
  });
1022
1037
  return {
1023
1038
  ok: true, count: tickets.length, tickets,
@@ -1660,7 +1675,7 @@ export function apply(ctx, config, seams = {}) {
1660
1675
  description: 'Productized login bootstrap for the account session channel (call this when gotry_session_search returns needs-login — the user never needs a terminal). ' + 'AUTO-DETECTION FIRST: it reads ticket-cookie NAMES before anything else — if the user already logged in (on the external site) it confirms instantly WITHOUT opening any page. '
1661
1676
  + 'OPENS the site login entry in the USER\'S OWN Chrome (foreground tab, left open for the user) and waits for the user to finish logging in on the external site. '
1662
1677
  + 'GoTry NEVER collects, stores, or transmits credentials: no passwords, no SMS codes, no cookie values — it only checks the boolean fact "already logged in" (reads cookie NAMES only, zero values). '
1663
- + 'Transport: the GoTry Session Bridge browser extension (one-time install, ZERO Chrome system dialogs). '
1678
+ + 'Transport: the Stai Travel Bridge browser extension (one-time install, ZERO Chrome system dialogs). '
1664
1679
  + `verdict logged-in (tickets detected) | pending (login tab opened, user not done yet — offer to re-check later) | needs-extension (one-time extension install: the verdict surfaces the Chrome Web Store installUrl as a clickable link for dsh UI to render — installation is a browser concern, not gotry's). `
1665
1680
  + 'Evidence [会话:<site>-login@ts].',
1666
1681
  // D-30 第三刀(issue #112):query blob → 平铺 typed;全字段可选 → interpretArgs 容忍层
@@ -1680,7 +1695,7 @@ export function apply(ctx, config, seams = {}) {
1680
1695
  : r.verdict === 'pending'
1681
1696
  ? `登录入口已在你的 Chrome 打开;请在弹出的标签页里正常登录携程(登录由你在官网完成,不属于 gotry)。完成后说一声"继续",我再确认。gotry 只检查"是否已登录",永不收集你的账号信息。${r.evidence}`
1682
1697
  : r.verdict === 'needs-extension'
1683
- ? `需要一次性安装 GoTry Session Bridge 浏览器扩展:这是浏览器的事,gotry 不弹面板不开剪贴板——请直接在 Chrome 应用商店一键装(add-to-chrome 自动更新)。storeUrl=${EXTENSION_STORE_URL}. 装好即生效,装完后告诉我「重试」即可。${r.evidence}`
1698
+ ? `需要一次性安装 Stai Travel Bridge 浏览器扩展:这是浏览器的事,gotry 不弹面板不开剪贴板——请直接在 Chrome 应用商店一键装(add-to-chrome 自动更新)。storeUrl=${EXTENSION_STORE_URL}. 装好即生效,装完后告诉我「重试」即可。${r.evidence}`
1684
1699
  : r.verdict === 'needs-attach'
1685
1700
  ? `cdp 车道需要一次性开启你 Chrome 的远程调试开关:在你的 Chrome 地址栏打开 chrome://inspect/#remote-debugging 并打开开关,然后说一声"重试"(默认走扩展车道,无需此步)。${r.evidence}`
1686
1701
  : `登录引导未完成:${r.error ?? '未知原因'} ${r.evidence}`;
@@ -1695,7 +1710,7 @@ export function apply(ctx, config, seams = {}) {
1695
1710
  : r.verdict === 'pending'
1696
1711
  ? '等待你在携程页面完成登录'
1697
1712
  : needsExt
1698
- ? '🧩 需装 GoTry Session Bridge 扩展(浏览器商店一键装,自动更新)'
1713
+ ? '🧩 需装 Stai Travel Bridge 扩展(浏览器商店一键装,自动更新)'
1699
1714
  : r.verdict ?? '降级';
1700
1715
  const content = [{ type: 'text', text: String(value.summary ?? '') }];
1701
1716
  // needs-extension:把商店 URL 渲成可点链接(浏览器自己当安装器,gotry 不插手)
@@ -1709,7 +1724,7 @@ export function apply(ctx, config, seams = {}) {
1709
1724
  name: 'gotry_session_search',
1710
1725
  description: routed('gotry_session_search', 'Search on the USER\'S OWN browser session (Ctrip kind="flight"(default)/"hotel"; 12306 kind="train" — public query face, no login needed). '
1711
1726
  + 'Consent gate: the FIRST call in a session asks the user via the runtime approval card; once granted it holds for the session, a refusal revokes it for the session (no repeat prompting). '
1712
- + 'Transport: GoTry Session Bridge browser extension (one-time install) — the agent side never talks to Chrome debugging, ZERO system dialogs; read-only by construction (the extension never issues requests; it only passively forwards the site\'s own search responses; agent NEVER touches credentials/captcha; on captcha it stops and returns challenged). '
1727
+ + 'Transport: Stai Travel Bridge browser extension (one-time install) — the agent side never talks to Chrome debugging, ZERO system dialogs; read-only by construction (the extension never issues requests; it only passively forwards the site\'s own search responses; agent NEVER touches credentials/captcha; on captcha it stops and returns challenged). '
1713
1728
  + 'kind="flight": from/to 中文城市名 + date YYYY-MM-DD — sniffs the site search API for structured options. Evidence [会话:ctrip-flight@ts]. '
1714
1729
  + 'kind="hotel": to=目的地中文, cityId? = the numeric city= in a hotels.ctrip.com list URL (web-search it when the destination is outside the built-in city table), checkIn?/checkOut? (YYYY-MM-DD), adults?; hotel prices are the user\'s real logged-in prices. Evidence [会话:ctrip-hotel@ts]. '
1715
1730
  + 'kind="dida": Dida supplier-portal realtime hotel rates on the employee\'s own logged-in session (hotel-be portal integration line) — no from/to needed; the portal find page\'s own requests are sniffed passively. Rates carry ratePlanId/referenceNo for the server-side booking chain. Evidence [会话:dida-portal@ts]. '
@@ -1888,7 +1903,7 @@ export function apply(ctx, config, seams = {}) {
1888
1903
  : r.verdict === 'needs-login'
1889
1904
  ? '需登录'
1890
1905
  : needsExt
1891
- ? '🧩 需装 GoTry Session Bridge 扩展(浏览器商店一键装,自动更新)'
1906
+ ? '🧩 需装 Stai Travel Bridge 扩展(浏览器商店一键装,自动更新)'
1892
1907
  : r.verdict ?? '降级';
1893
1908
  const content = [{ type: 'text', text: String(value.summary ?? '') }];
1894
1909
  if (needsExt && r.installUrl)
@@ -2172,7 +2187,7 @@ export function apply(ctx, config, seams = {}) {
2172
2187
  // 统一的状态入口来「显示状态 + 引导补装」,而不是在 not-installed 里各自摸索)----
2173
2188
  registerGuarded(defineTool({
2174
2189
  name: 'gotry_doctor',
2175
- description: 'Check optional-dependency health for ALL gotry tools (read-only by default). Coverage: GoTry Session Bridge extension / Agent Reach (.venv) / hbcli (hotel realtime) / FlyAI key + recent trial-quota exhaustion time / dsh-calendar mount state / dsh-better-sidebar / dsh-map-tools / dsh-tool-ask-user. '
2190
+ description: 'Check optional-dependency health for ALL gotry tools (read-only by default). Coverage: Stai Travel Bridge extension / Agent Reach (.venv) / hbcli (hotel realtime) / FlyAI key + recent trial-quota exhaustion time / dsh-calendar mount state / dsh-better-sidebar / dsh-map-tools / dsh-tool-ask-user. '
2176
2191
  + 'Call this when ANY gotry tool returns not-installed / needs-setup, when the user asks 「体检/依赖状态/工具为什么不可用」, or BEFORE leaning on a channel for a plan. '
2177
2192
  + 'Returns per-item status (ok/degraded/missing) with exact fix commands. '
2178
2193
  + 'Action=diagnose (default): pure read-only — never installs anything, only renders the report. '
@@ -2355,6 +2370,7 @@ export function apply(ctx, config, seams = {}) {
2355
2370
  try {
2356
2371
  r = await listArtifacts({
2357
2372
  stateRoot: config.stateRoot ?? '.',
2373
+ handoffRoot: process.env.GOTRY_TURN_HANDOFF_ROOT ?? ((config.stateRoot ?? '.') === '.' ? sessionCwd(exec) ?? '.' : config.stateRoot),
2358
2374
  cwd: sessionCwd(exec),
2359
2375
  limit: q.limit,
2360
2376
  offset: q.offset,
@@ -2448,7 +2464,7 @@ export function apply(ctx, config, seams = {}) {
2448
2464
  throw new Error('gotry_artifacts_read requires path');
2449
2465
  if (!q.path)
2450
2466
  return JSON.parse(JSON.stringify({ ok: false, error: 'path 必填(来自 gotry_artifacts_list)' }));
2451
- const r = await readArtifact({ stateRoot: config.stateRoot ?? '.', cwd: sessionCwd(exec), path: q.path, offset: q.offset, limit: q.limit });
2467
+ const r = await readArtifact({ stateRoot: config.stateRoot ?? '.', handoffRoot: process.env.GOTRY_TURN_HANDOFF_ROOT ?? ((config.stateRoot ?? '.') === '.' ? sessionCwd(exec) ?? '.' : config.stateRoot), cwd: sessionCwd(exec), path: q.path, offset: q.offset, limit: q.limit });
2452
2468
  if (!r.ok)
2453
2469
  return JSON.parse(JSON.stringify(r));
2454
2470
  // 身份/来源展示(issue #285 第 1 条「显示身份/来源」):行号视图保留 read 卡的