@danceiny/gotry 0.2.0-rc.32 → 0.2.0-rc.33

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 (89) hide show
  1. package/README.md +2 -2
  2. package/README.zh-CN.md +2 -2
  3. package/dist/capabilities/session/bridge-ledger.js +168 -67
  4. package/dist/capabilities/session/extension-bridge.js +140 -46
  5. package/dist/capabilities/session/gui-flight.js +281 -69
  6. package/dist/capabilities/session/gui-hotel.js +74 -17
  7. package/dist/capabilities/session-search.js +6 -5
  8. package/dist/scripts/booking-copilot-dsh-planner-proof-tests.js +129 -2
  9. package/dist/scripts/booking-copilot-dsh-readiness-proof.js +53 -5
  10. package/dist/scripts/booking-copilot-gateway-chat-adapter-tests.js +233 -0
  11. package/dist/scripts/booking-copilot-planner-live-probe.js +1 -0
  12. package/dist/scripts/booking-copilot-startup-proof-tests.js +1 -1
  13. package/dist/scripts/bridge-ledger-tests.js +20 -18
  14. package/dist/scripts/fixtures/tenant-runaway-worker-fixture.mjs +73 -0
  15. package/dist/scripts/mutation-check.js +153 -0
  16. package/dist/scripts/nightly-evidence.js +4 -111
  17. package/dist/scripts/session-tests.js +42 -8
  18. package/dist/scripts/tenant-bridge-org-tests.js +1264 -0
  19. package/dist/scripts/tenant-capacity-probe.js +263 -0
  20. package/dist/scripts/tenant-compute-isolation-tests.js +707 -0
  21. package/dist/scripts/tenant-egress-credentials-tests.js +822 -0
  22. package/dist/scripts/tenant-executor-binding-tests.js +203 -0
  23. package/dist/scripts/tenant-prompt-partition-tests.js +265 -0
  24. package/dist/scripts/tenant-seam-adversarial-tests.js +957 -0
  25. package/dist/scripts/tenant-seam-quota-tests.js +383 -0
  26. package/dist/scripts/tenant-seam-test-kit.js +292 -0
  27. package/dist/scripts/tenant-state-copies-tests.js +607 -0
  28. package/dist/scripts/tenant-state-drill-tests.js +298 -0
  29. package/dist/scripts/tenant-state-drill-worker.js +164 -0
  30. package/dist/scripts/tenant-state-durable-conformance.js +101 -0
  31. package/dist/scripts/tenant-state-handoff-tests.js +854 -0
  32. package/dist/scripts/tenant-state-lease-conformance.js +142 -0
  33. package/dist/scripts/tenant-state-lease-worker.js +28 -0
  34. package/dist/scripts/tenant-state-router-tests.js +464 -0
  35. package/dist/scripts/tenant-state-test-kit.js +92 -0
  36. package/dist/scripts/tenant-state-tests.js +564 -0
  37. package/dist/src/backend/modules/booking-executor.js +61 -6
  38. package/dist/src/backend/modules/session-search.js +136 -39
  39. package/dist/src/booking-surface/dsh-llm-gateway-chat.js +434 -0
  40. package/dist/src/booking-surface/dsh-planner.js +150 -19
  41. package/dist/src/booking-surface/index.js +1 -0
  42. package/dist/src/booking-surface/managed-dsh-run-port.js +121 -7
  43. package/dist/src/booking-surface/server.js +193 -80
  44. package/dist/src/booking-surface/startup.js +153 -0
  45. package/dist/src/booking-surface/tenancy.js +188 -0
  46. package/dist/src/index.js +31 -1
  47. package/dist/src/llm-price-table.js +148 -0
  48. package/dist/src/tenant-seam/index.js +8 -0
  49. package/dist/src/tenant-seam/llm-broker.js +508 -0
  50. package/dist/src/tenant-seam/process-governor.js +281 -0
  51. package/dist/src/tenant-seam/quotas.js +360 -0
  52. package/dist/src/tenant-seam/seam.js +92 -0
  53. package/dist/src/tenant-state/durable-store.js +158 -0
  54. package/dist/src/tenant-state/epoch-copies.js +422 -0
  55. package/dist/src/tenant-state/errors.js +108 -0
  56. package/dist/src/tenant-state/ids.js +101 -0
  57. package/dist/src/tenant-state/index.js +11 -0
  58. package/dist/src/tenant-state/lease-store.js +203 -0
  59. package/dist/src/tenant-state/tenant-lease.js +150 -0
  60. package/dist/src/tenant-state/tenant-router.js +650 -0
  61. package/extension/manifest.json +2 -2
  62. package/package.json +4 -1
  63. package/ts/capabilities/session/bridge-ledger.ts +192 -78
  64. package/ts/capabilities/session/extension-bridge.ts +176 -65
  65. package/ts/capabilities/session/gui-flight.ts +318 -88
  66. package/ts/capabilities/session/gui-hotel.ts +72 -18
  67. package/ts/capabilities/session-search.ts +17 -5
  68. package/ts/src/booking-surface/dsh-llm-gateway-chat.js +434 -0
  69. package/ts/src/booking-surface/dsh-planner.ts +169 -19
  70. package/ts/src/booking-surface/index.ts +1 -0
  71. package/ts/src/booking-surface/managed-dsh-run-port.ts +150 -7
  72. package/ts/src/booking-surface/server.ts +215 -84
  73. package/ts/src/booking-surface/startup.ts +155 -0
  74. package/ts/src/booking-surface/tenancy.ts +242 -0
  75. package/ts/src/index.ts +28 -2
  76. package/ts/src/llm-price-table.ts +180 -0
  77. package/ts/src/tenant-seam/index.ts +9 -0
  78. package/ts/src/tenant-seam/llm-broker.ts +520 -0
  79. package/ts/src/tenant-seam/process-governor.ts +343 -0
  80. package/ts/src/tenant-seam/quotas.ts +433 -0
  81. package/ts/src/tenant-seam/seam.ts +142 -0
  82. package/ts/src/tenant-state/durable-store.ts +171 -0
  83. package/ts/src/tenant-state/epoch-copies.ts +491 -0
  84. package/ts/src/tenant-state/errors.ts +128 -0
  85. package/ts/src/tenant-state/ids.ts +108 -0
  86. package/ts/src/tenant-state/index.ts +12 -0
  87. package/ts/src/tenant-state/lease-store.ts +266 -0
  88. package/ts/src/tenant-state/tenant-lease.ts +175 -0
  89. package/ts/src/tenant-state/tenant-router.ts +743 -0
@@ -6,7 +6,7 @@
6
6
  * 与机票 GUI 车道(gui-flight.ts)同构:动作闭集 + 提交件黑名单 + readback 硬闸 +
7
7
  * 可注入 driver;复用 ctrip-hotel 适配器的三态分类器(classifyCtripHotelList:
8
8
  * parsed/recognized-empty/unrecognized——只有显式空列表才算 miss,绝不猜)。
9
- * 证据链 [会话:ctrip-hotel-gui@ts],恒标 anonymous。
9
+ * 证据链 [会话:ctrip-hotel-gui@ts];anonymous 标注(登录态配对后为 false)。
10
10
  */
11
11
  import { mkdtempSync, rmSync } from 'node:fs';
12
12
  import { tmpdir } from 'node:os';
@@ -16,6 +16,8 @@ import { HOTEL_NETWORK_HINTS, looksLikeHotelListBody, classifyCtripHotelList } f
16
16
  export const HOTEL_GUI_HOME = 'https://hotels.ctrip.com/';
17
17
  const YMD = /^\d{4}-\d{2}-\d{2}$/;
18
18
  const GUI_CHALLENGE_RE = /验证|校验|拖动滑块|请输入验证码/;
19
+ /** 登录票据 cookie 名单(与 bridge 车道 LOGIN_COOKIE_NAMES 同源;只读名字) */
20
+ const TICKET_COOKIE_NAMES = ['cticket', 'uid', 'uname', 'passport'];
19
21
  /** 纯函数:查询 → 动作序列(离线测试锚点) */
20
22
  export function planHotelGuiActions(q) {
21
23
  return [
@@ -77,10 +79,12 @@ export function __setHotelDriverForTest(factory) {
77
79
  export async function runHotelGuiSearch(q) {
78
80
  const started = Date.now();
79
81
  const ts = new Date().toISOString();
82
+ let humanLoggedIn = false;
80
83
  const fail = (error, verdict = 'error') => ({
81
84
  ok: false, via: 'session-ctrip-hotel-gui-error', latencyMs: Date.now() - started, verdict, error,
82
- evidence: `[会话:ctrip-hotel-gui@error@${ts}] ${error.slice(0, 120)}`, anonymous: true,
85
+ evidence: `[会话:ctrip-hotel-gui@error@${ts}] ${error.slice(0, 120)}`, anonymous: !humanLoggedIn,
83
86
  });
87
+ const needsLoginMessage = () => '酒店列表需登录态:匿名窗口被重定向到携程登录页(红线:不自动登录)——interactiveLogin=true 可在窗口内本人登录后自动重查,或 gotry_session_login 后走 bridge 车道';
84
88
  if (!(q.checkIn < q.checkOut))
85
89
  return fail(`退房日期(${q.checkOut})须晚于入住(${q.checkIn})`);
86
90
  const plan = planHotelGuiActions(q);
@@ -99,11 +103,17 @@ export async function runHotelGuiSearch(q) {
99
103
  }
100
104
  let api = null;
101
105
  let listUrl = '';
102
- try {
106
+ class HotelFlowAbort {
107
+ result;
108
+ constructor(result) {
109
+ this.result = result;
110
+ }
111
+ }
112
+ const driveOnce = async () => {
103
113
  for (const a of plan) {
104
114
  const remain = budget - (Date.now() - started);
105
115
  if (remain <= 0)
106
- return fail(`GUI 预算耗尽(${budget}ms)`);
116
+ throw new HotelFlowAbort(fail(`GUI 预算耗尽(${budget}ms)`));
107
117
  const step = (p, what) => withDeadline(p, Math.min(remain, 20_000), `GUI 步骤失败(${what})`);
108
118
  switch (a.t) {
109
119
  case 'goto':
@@ -128,7 +138,7 @@ export async function runHotelGuiSearch(q) {
128
138
  {
129
139
  const after = await page.currentUrl().catch(() => '');
130
140
  if (/passport\.ctrip\.com\/.*login/i.test(after)) {
131
- return fail('酒店列表需登录态:匿名窗口被重定向到携程登录页(红线:不自动登录)——gotry_session_login 为用户打开官方登录入口后走 bridge 车道(ui 默认),或登录后重试', 'needs-login');
141
+ throw new HotelFlowAbort(fail('__NEEDS_LOGIN__', 'needs-login'));
132
142
  }
133
143
  }
134
144
  break;
@@ -136,7 +146,7 @@ export async function runHotelGuiSearch(q) {
136
146
  const dest = await step(page.destFieldValue(), '读回目的地框');
137
147
  const form = await step(page.visibleFormText(), '读回表单');
138
148
  if (!dest.includes(a.expect.to)) {
139
- return fail(`readback 硬闸:目的地框未确认「${a.expect.to}」(实际「${dest.slice(0, 30)}」)——已终止,未发起搜索`);
149
+ throw new HotelFlowAbort(fail(`readback 硬闸:目的地框未确认「${a.expect.to}」(实际「${dest.slice(0, 30)}」)——已终止,未发起搜索`));
140
150
  }
141
151
  // live 校准:酒店表单日期显示为本地化格式(如 11月13日(周五))而非 YMD——双格式核对
142
152
  const dateShown = (ymd) => {
@@ -145,7 +155,7 @@ export async function runHotelGuiSearch(q) {
145
155
  };
146
156
  for (const [label, value] of [['入住日期', a.expect.checkIn], ['退房日期', a.expect.checkOut]]) {
147
157
  if (!dateShown(value)) {
148
- return fail(`readback 硬闸:${label}「${value}」未在表单确认(表单摘要:${form.slice(0, 200)})——已终止,未发起搜索`);
158
+ throw new HotelFlowAbort(fail(`readback 硬闸:${label}「${value}」未在表单确认(表单摘要:${form.slice(0, 200)})——已终止,未发起搜索`));
149
159
  }
150
160
  }
151
161
  break;
@@ -153,34 +163,70 @@ export async function runHotelGuiSearch(q) {
153
163
  case 'awaitApi': {
154
164
  const text = await step(page.pageText(), '检查页面');
155
165
  if (GUI_CHALLENGE_RE.test(text)) {
156
- return fail('风控/验证码命中;按红线不重试不绕过,交还用户', 'challenged');
166
+ throw new HotelFlowAbort(fail('风控/验证码命中;按红线不重试不绕过,交还用户', 'challenged'));
157
167
  }
158
168
  api = await step(page.waitForApiBody(Math.min(remain - 500, 18_000)), '等待酒店列表回包');
159
169
  if (!api) {
160
170
  const recent = page.recentApiUrls ? (await page.recentApiUrls().catch(() => [])) : [];
161
171
  const nowUrl = await page.currentUrl().catch(() => '?');
162
172
  if (/passport\.ctrip\.com\/.*login/i.test(nowUrl)) {
163
- return fail('酒店列表需登录态:匿名窗口被重定向到携程登录页(红线:不自动登录)——gotry_session_login 为用户打开官方登录入口后走 bridge 车道,或登录后重试', 'needs-login');
173
+ throw new HotelFlowAbort(fail('__NEEDS_LOGIN__', 'needs-login'));
164
174
  }
165
- return fail(`酒店列表回包嗅探超时(href=${nowUrl.slice(0, 120)})${recent.length ? ` ‖recent-apis: ${recent.slice(-8).join(' ; ').slice(0, 500)}` : ''}`);
175
+ throw new HotelFlowAbort(fail(`酒店列表回包嗅探超时(href=${nowUrl.slice(0, 120)})${recent.length ? ` ‖recent-apis: ${recent.slice(-8).join(' ; ').slice(0, 500)}` : ''}`));
166
176
  }
167
177
  break;
168
178
  }
169
179
  }
170
180
  }
171
- listUrl = await page.currentUrl().catch(() => '');
181
+ return;
182
+ };
183
+ try {
184
+ await driveOnce();
172
185
  }
173
186
  catch (e) {
174
- return fail(e.message.slice(0, 400));
187
+ // 登录态配对(#664 余尾,2026-10-15):needs-login 且调用方授权等待时,窗口停在官方
188
+ // 登录页等用户本人完成登录(cookie 只读名字),随后原窗重驾一次拿登录态真实价。
189
+ if (e instanceof HotelFlowAbort && e.result.verdict === 'needs-login' && q.interactiveLoginMs && q.interactiveLoginMs > 0) {
190
+ const loggedIn = await withDeadline(page.waitForHumanLogin(q.interactiveLoginMs), q.interactiveLoginMs + 5_000, '等待人工登录').catch(() => false);
191
+ if (!loggedIn) {
192
+ return fail(`登录等待超时(${q.interactiveLoginMs}ms):用户未在 GUI 窗口完成登录——可稍后带 interactiveLogin 重试,或登录后走 bridge 车道`, 'needs-login');
193
+ }
194
+ humanLoggedIn = true;
195
+ api = null;
196
+ try {
197
+ await driveOnce();
198
+ }
199
+ catch (e2) {
200
+ if (e2 instanceof HotelFlowAbort) {
201
+ return e2.result.error === '__NEEDS_LOGIN__'
202
+ ? fail(needsLoginMessage(), 'needs-login')
203
+ : e2.result;
204
+ }
205
+ return fail(e2.message.slice(0, 400));
206
+ }
207
+ }
208
+ else if (e instanceof HotelFlowAbort) {
209
+ return e.result.error === '__NEEDS_LOGIN__'
210
+ ? fail(needsLoginMessage(), 'needs-login')
211
+ : e.result;
212
+ }
213
+ else {
214
+ return fail(e.message.slice(0, 400));
215
+ }
175
216
  }
176
217
  finally {
177
218
  await driver.close().catch(() => { });
178
219
  }
179
- if (!api)
220
+ try {
221
+ listUrl = await page.currentUrl().catch(() => '');
222
+ }
223
+ catch { /* 已关窗口 */ }
224
+ const captured = api;
225
+ if (captured === null)
180
226
  return fail('内部不变量失败:api 未结算(awaitApi 已过)');
181
- const classified = classifyCtripHotelList(api.body);
227
+ const classified = classifyCtripHotelList(captured.body);
182
228
  if (classified.shape === 'unrecognized') {
183
- return fail(`酒店列表回包形状未识别(body头:${api.body.replace(/\s+/g, ' ').slice(0, 240)})`);
229
+ return fail(`酒店列表回包形状未识别(body头:${captured.body.replace(/\s+/g, ' ').slice(0, 240)})`);
184
230
  }
185
231
  if (classified.shape === 'malformed') {
186
232
  return fail('酒店列表回包非 JSON(接口改版?)');
@@ -191,8 +237,8 @@ export async function runHotelGuiSearch(q) {
191
237
  ok: verdict === 'hit', via: 'session-ctrip-hotel-gui', latencyMs: Date.now() - started, verdict,
192
238
  ...(hotels.length > 0 ? { hotels: hotels.slice(0, 20) } : {}),
193
239
  ...(listUrl ? { listUrl } : {}),
194
- evidence: `[会话:ctrip-hotel-gui@${ts}] 匿名 GUI 面检索:${hotels.length} 家(shape=${classified.shape};匿名公开价,非登录态价)`,
195
- anonymous: true,
240
+ evidence: `[会话:ctrip-hotel-gui@${ts}] GUI 面检索:${hotels.length} 家(shape=${classified.shape};${humanLoggedIn ? '用户本人已在本窗口官方页登录,登录态真实价' : '匿名公开价,非登录态价'})`,
241
+ anonymous: !humanLoggedIn,
196
242
  };
197
243
  }
198
244
  async function withDeadline(p, ms, what) {
@@ -393,6 +439,17 @@ async function createHotelPlaywrightDriver() {
393
439
  return title + text;
394
440
  },
395
441
  async currentUrl() { return page.url(); },
442
+ async waitForHumanLogin(timeoutMs) {
443
+ // 红线:人自己完成登录;只轮询票据 cookie 名字(值即弃)。窗口此刻通常已在 passport 页。
444
+ const deadline = Date.now() + timeoutMs;
445
+ while (Date.now() < deadline) {
446
+ const names = await ctx.cookies().then(cs => cs.map(c => c.name)).catch(() => []);
447
+ if (names.some(n => TICKET_COOKIE_NAMES.includes(n)))
448
+ return true;
449
+ await page.waitForTimeout(5_000);
450
+ }
451
+ return false;
452
+ },
396
453
  async recentApiUrls() { return [...recentUrls]; },
397
454
  async waitForApiBody(timeoutMs) {
398
455
  return new Promise(resolve => {
@@ -115,7 +115,7 @@ export async function sessionHotelGuiSearch(q) {
115
115
  if (!q.checkIn || !q.checkOut) {
116
116
  return err('error', 'GUI 车道需要 checkIn/checkOut(YYYY-MM-DD,成对)');
117
117
  }
118
- const r = await runHotelGuiSearch({ to: q.to, checkIn: q.checkIn, checkOut: q.checkOut, ...(q.timeoutMs ? { timeoutMs: q.timeoutMs } : {}) });
118
+ const r = await runHotelGuiSearch({ to: q.to, checkIn: q.checkIn, checkOut: q.checkOut, ...(q.interactiveLoginMs ? { interactiveLoginMs: q.interactiveLoginMs } : {}), ...(q.timeoutMs ? { timeoutMs: Math.max(q.timeoutMs, q.interactiveLoginMs ? q.interactiveLoginMs + 120_000 : 0) } : {}) });
119
119
  return {
120
120
  ok: r.ok, via: r.via, evidence: r.evidence, latencyMs: r.latencyMs, verdict: r.verdict,
121
121
  ...(r.hotels ? { hotels: r.hotels } : {}),
@@ -307,12 +307,13 @@ export async function sessionDidaSearch(q) {
307
307
  const err = (verdict, error) => ({
308
308
  ok: false, via: 'session-dida-portal-error', evidence: `[会话:${site}@error@${ts}] ${error}`, latencyMs: Date.now() - started, verdict, error,
309
309
  });
310
- // 节律闸:超间隔即拒,不发起导航
311
- const last = lastCallAt.get(site) ?? 0;
310
+ // 节律闸:超间隔即拒,不发起导航。键缺省是站点;多组织形态按组织分键(见 SessionDidaQuery.pacingKey)
311
+ const paceKey = q.pacingKey ?? site;
312
+ const last = lastCallAt.get(paceKey) ?? 0;
312
313
  if (Date.now() - last < MIN_INTERVAL_MS) {
313
314
  return err('cooldown', `rate limit: last call ${Date.now() - last}ms ago, min ${MIN_INTERVAL_MS}ms`);
314
315
  }
315
- lastCallAt.set(site, Date.now());
316
+ lastCallAt.set(paceKey, Date.now());
316
317
  const entry = buildDidaEntryUrl({ entryUrl: q.entryUrl });
317
318
  if (!entry.ok || !entry.url) {
318
319
  return err('error', 'entry URL 必须落在 https://portal.dida.com/ 域内');
@@ -761,7 +762,7 @@ export async function sessionFlightGuiSearch(q) {
761
762
  if (q.backDate && !(q.date < q.backDate)) {
762
763
  return err('error', `回程日期(${q.backDate})须晚于去程(${q.date})`);
763
764
  }
764
- const r = await runFlightGuiSearch({ from: q.from, to: q.to, date: q.date, ...(q.backDate ? { backDate: q.backDate } : {}), ...(q.timeoutMs ? { timeoutMs: q.timeoutMs } : {}) });
765
+ const r = await runFlightGuiSearch({ from: q.from, to: q.to, date: q.date, ...(q.backDate ? { backDate: q.backDate } : {}), ...(q.legs ? { legs: q.legs } : {}), ...(q.interactiveLoginMs ? { interactiveLoginMs: q.interactiveLoginMs } : {}), ...(q.timeoutMs ? { timeoutMs: Math.max(q.timeoutMs, q.interactiveLoginMs ? q.interactiveLoginMs + 120_000 : 0) } : {}) });
765
766
  return {
766
767
  ok: r.ok, via: r.via, evidence: r.evidence, latencyMs: r.latencyMs,
767
768
  verdict: r.verdict,
@@ -782,6 +782,54 @@ const invalidThenValidDecision = await invalidThenValid.plannerFactory(task).nex
782
782
  });
783
783
  assertRuntimeMaterializedOperation(invalidThenValidDecision, 'search.run', {}, task, 'a schema-rejected call may be superseded by the later unique typed success in the same run');
784
784
  await invalidThenValid.close();
785
+ let schemaRepairRuns = 0;
786
+ const schemaRejectedThenRepaired = await createDshEmbeddedBookingPlanner({
787
+ runPort: {
788
+ async run() {
789
+ schemaRepairRuns += 1;
790
+ if (schemaRepairRuns === 1) {
791
+ return {
792
+ finalResponse: '',
793
+ events: [
794
+ toolCall('booking_search_hotels', invalidCallArguments, 'call-schema-rejected'),
795
+ toolResult('call-schema-rejected', true, 'INVALID_ARGS'),
796
+ ],
797
+ };
798
+ }
799
+ return { finalResponse: '', events: successfulToolEvents('booking_search_hotels', JSON.stringify({ decision: { kind: 'operation', action: searchRun, intent: { target: 'search.results' } } }), 'call-schema-repaired') };
800
+ },
801
+ async close() { },
802
+ },
803
+ });
804
+ const schemaRepairedDecision = await schemaRejectedThenRepaired.plannerFactory(task).next({
805
+ task,
806
+ turn: { schemaVersion: 'booking.surface', kind: 'user.turn', taskId: task.taskId, turnId: 'dsh-schema-repair', workspace, request: { text: 'Find hotels' } },
807
+ });
808
+ assertRuntimeMaterializedOperation(schemaRepairedDecision, 'search.run', {}, task, 'one schema-shaped rejection is retried once and the repaired call is accepted');
809
+ assert.equal(schemaRepairRuns, 2, 'schema rejection consumes a bounded retry attempt');
810
+ await schemaRejectedThenRepaired.close();
811
+ let repeatedSchemaRejectionRuns = 0;
812
+ const repeatedSchemaRejections = await createDshEmbeddedBookingPlanner({
813
+ runPort: {
814
+ async run() {
815
+ repeatedSchemaRejectionRuns += 1;
816
+ return {
817
+ finalResponse: '',
818
+ events: [
819
+ toolCall('booking_search_hotels', invalidCallArguments, 'call-repeated-schema-rejection'),
820
+ toolResult('call-repeated-schema-rejection', true, 'INVALID_ARGS'),
821
+ ],
822
+ };
823
+ },
824
+ async close() { },
825
+ },
826
+ });
827
+ await assert.rejects(repeatedSchemaRejections.plannerFactory(task).next({
828
+ task,
829
+ turn: { schemaVersion: 'booking.surface', kind: 'user.turn', taskId: task.taskId, turnId: 'dsh-repeated-schema-rejection', workspace, request: { text: 'Find hotels' } },
830
+ }), /planner_invalid_typed_decision/, 'a schema-invalid repair must fail closed');
831
+ assert.equal(repeatedSchemaRejectionRuns, 2, 'a turn gets only one schema repair retry even if the repair is rejected again');
832
+ await repeatedSchemaRejections.close();
785
833
  const invalidWithoutSchemaRejection = await createDshEmbeddedBookingPlanner({
786
834
  runPort: {
787
835
  async run() {
@@ -1718,8 +1766,8 @@ const invalidCrossRun = await createDshEmbeddedBookingPlanner({
1718
1766
  await assert.rejects(invalidCrossRun.plannerFactory(task).next({
1719
1767
  task,
1720
1768
  turn: { schemaVersion: 'booking.surface', kind: 'user.turn', taskId: task.taskId, turnId: 'dsh-cross-run-invalid', workspace, request: { text: 'Find hotels' } },
1721
- }), /planner_invalid_tool_arguments/, 'a schema-invalid call cannot be washed into a valid action by a later provider run');
1722
- assert.equal(invalidCrossRunRuns, 1, 'event-authority schema errors fail closed without a correction run');
1769
+ }), /planner_booking_intent_required/, 'a schema-invalid call gets one repair run, but a still-invalid repaired payload cannot become a valid action');
1770
+ assert.equal(invalidCrossRunRuns, 2, 'one bounded schema-repair retry, not an unbounded correction loop');
1723
1771
  await invalidCrossRun.close();
1724
1772
  const confirmedTask = {
1725
1773
  ...task,
@@ -2179,6 +2227,85 @@ assert.notDeepEqual(bootRejectResult, { testDeadline: true }, 'an expired handsh
2179
2227
  assert.equal(bootRejectResult[0]?.error?.code, 'PLANNER_BOOT_TIMEOUT', 'an expired local handshake is typed as a boot timeout');
2180
2228
  assert.equal(bootRejectPortClosed, 1, 'an expired handshake closes its owned run port');
2181
2229
  await bootRejectPlanner.close();
2230
+ // A cold first boot that exceeds the local handshake budget must not fail the
2231
+ // turn while deadline headroom remains: the failed boot warmed the OS page
2232
+ // cache, so one fresh-port retry converges (Danceiny/gotry#660). The retry
2233
+ // must not consume a provider attempt, and the failed port must be closed.
2234
+ let bootRetryFirstClosed = 0;
2235
+ let bootRetrySecondRuns = 0;
2236
+ let bootRetrySecondClosed = 0;
2237
+ let bootRetryPortIndex = 0;
2238
+ const bootRetryPlanner = await createDshEmbeddedBookingPlanner({
2239
+ runPortFactory: () => {
2240
+ bootRetryPortIndex += 1;
2241
+ if (bootRetryPortIndex === 1) {
2242
+ return {
2243
+ run: () => Promise.reject(new Error('HARNESS_BOOT_TIMEOUT')),
2244
+ async close() { bootRetryFirstClosed += 1; },
2245
+ bootObservation: () => undefined,
2246
+ };
2247
+ }
2248
+ return {
2249
+ async run() {
2250
+ bootRetrySecondRuns += 1;
2251
+ return {
2252
+ finalResponse: '',
2253
+ events: successfulToolEvents('booking_search_hotels', JSON.stringify({ kind: 'search.run', input: {}, intent: { target: 'search.results' } }), 'call-boot-retry'),
2254
+ };
2255
+ },
2256
+ async close() { bootRetrySecondClosed += 1; },
2257
+ };
2258
+ },
2259
+ turnTimeoutMs: 30_000,
2260
+ });
2261
+ const bootRetryResult = await resolveWithin(bootRetryPlanner.plannerFactory(bootTimeoutTask).next({ task: bootTimeoutTask, turn: bootTimeoutTurn }), 250);
2262
+ assert.notDeepEqual(bootRetryResult, { testDeadline: true }, 'a retried boot stays inside the turn deadline');
2263
+ assert.equal(bootRetryResult[0]?.kind, 'operation', 'a fresh-port retry after a cold boot timeout produces the operation decision');
2264
+ assert.equal(bootRetryResult[0]?.action?.kind, 'search.run', 'the retried decision carries the capability action');
2265
+ assert.equal(bootRetryFirstClosed, 1, 'the failed boot port is closed by the retry');
2266
+ assert.ok(bootRetrySecondRuns >= 1, 'the fresh port actually ran the provider request');
2267
+ await bootRetryPlanner.close();
2268
+ // A fresh-port retry must never run on the port it just retired. A factory
2269
+ // that returns one shared instance hands the closed port back, and running it
2270
+ // rejects untyped out of next() (the #669 readiness-proof crash). Such a retry
2271
+ // cannot be funded, so each retry branch settles exactly as it does when the
2272
+ // deadline cannot fund one. These fakes reject like the real port once closed.
2273
+ let sharedBootFactoryCalls = 0;
2274
+ let sharedBootCloses = 0;
2275
+ const sharedBootPort = {
2276
+ run: () => Promise.reject(new Error(sharedBootCloses > 0 ? 'booking_planner_run_port_closed' : 'HARNESS_BOOT_TIMEOUT')),
2277
+ async close() { sharedBootCloses += 1; },
2278
+ bootObservation: () => undefined,
2279
+ };
2280
+ const sharedBootPlanner = await createDshEmbeddedBookingPlanner({
2281
+ runPortFactory: () => { sharedBootFactoryCalls += 1; return sharedBootPort; },
2282
+ turnTimeoutMs: 30_000,
2283
+ });
2284
+ const sharedBootResult = await resolveWithin(sharedBootPlanner.plannerFactory(bootTimeoutTask).next({ task: bootTimeoutTask, turn: bootTimeoutTurn }), 250);
2285
+ assert.notDeepEqual(sharedBootResult, { testDeadline: true }, 'a refused boot retry settles inside the turn deadline');
2286
+ assert.equal(sharedBootResult[0]?.error?.code, 'PLANNER_BOOT_TIMEOUT', 'a boot retry handed its own retired port settles as the typed boot timeout');
2287
+ assert.equal(sharedBootFactoryCalls, 2, 'the boot retry asked the factory for a fresh port once');
2288
+ assert.equal(sharedBootCloses, 1, 'the retired port is closed exactly once and never run again');
2289
+ await sharedBootPlanner.close();
2290
+ let sharedStallFactoryCalls = 0;
2291
+ let sharedStallCloses = 0;
2292
+ const sharedStallPort = {
2293
+ run: () => sharedStallCloses > 0
2294
+ ? Promise.reject(new Error('booking_planner_run_port_closed'))
2295
+ : new Promise((_resolve) => undefined),
2296
+ async close() { sharedStallCloses += 1; },
2297
+ };
2298
+ const sharedStallPlanner = await createDshEmbeddedBookingPlanner({
2299
+ runPortFactory: () => { sharedStallFactoryCalls += 1; return sharedStallPort; },
2300
+ turnTimeoutMs: 30_000,
2301
+ stallSoftBudgetMs: 20,
2302
+ });
2303
+ const sharedStallResult = await resolveWithin(sharedStallPlanner.plannerFactory(bootTimeoutTask).next({ task: bootTimeoutTask, turn: bootTimeoutTurn }), 500);
2304
+ assert.notDeepEqual(sharedStallResult, { testDeadline: true }, 'a refused stall retry settles inside the turn deadline');
2305
+ assert.equal(sharedStallResult[0]?.error?.code, 'PLANNER_PROVIDER_TIMEOUT', 'a stall retry handed its own retired port settles as the typed provider timeout');
2306
+ assert.equal(sharedStallFactoryCalls, 2, 'the stall retry asked the factory for a fresh port once');
2307
+ assert.equal(sharedStallCloses, 1, 'the stalled port is closed exactly once and never run again');
2308
+ await sharedStallPlanner.close();
2182
2309
  let taskAClosed = 0;
2183
2310
  let taskARuns = 0;
2184
2311
  let taskBRuns = 0;
@@ -157,11 +157,15 @@ try {
157
157
  // The local boot budget must govern its own phase. With a model-stall budget
158
158
  // wider than the boot budget, only the explicit initialize deadline can
159
159
  // settle this turn inside the asserted window; the SDK's 10s fallback or the
160
- // 13.3s stall budget would both land later and fail the bound below.
160
+ // 16s stall budget would both land later and fail the bound below.
161
+ // The 24s deadline funds the fresh-port boot retry (#669) with margin, but
162
+ // this factory hands back its one shared port, which the boot timeout has
163
+ // just retired: the planner must refuse to run on it and settle typed.
161
164
  phase = 'boot_budget_bound';
162
165
  const budgetGate = gate('boot-budget');
163
166
  port = await createRealRunPort({ stateRoot: root, env, dshBin: budgetGate.dshBin, maxTokens: 512 });
164
- planner = await createDshEmbeddedBookingPlanner({ runPortFactory: () => port, turnTimeoutMs: 20_000 });
167
+ let budgetPortRequests = 0;
168
+ planner = await createDshEmbeddedBookingPlanner({ runPortFactory: () => { budgetPortRequests += 1; return port; }, turnTimeoutMs: 24_000 });
165
169
  const budgetAt = Date.now();
166
170
  const budgetResultPromise = planner.plannerFactory(task).next({ turn, task });
167
171
  await until(() => existsSync(budgetGate.marker), 3_000, 'boot-budget runtime never entered startup gate');
@@ -169,14 +173,15 @@ try {
169
173
  const budgetResult = await bounded(budgetResultPromise, 15_000, 'boot budget did not settle the turn');
170
174
  const budgetElapsedMs = Date.now() - budgetAt;
171
175
  assert.equal(budgetResult[0]?.kind === 'error' && budgetResult[0].error.code, 'PLANNER_BOOT_TIMEOUT');
176
+ assert.equal(budgetPortRequests, 2, 'the funded boot retry asked for a fresh port and was handed back the retired one');
172
177
  assert.ok(budgetElapsedMs >= PLANNER_BOOT_BUDGET_MS, `the boot budget must be consumed before it fires (${budgetElapsedMs}ms)`);
173
- assert.ok(budgetElapsedMs < PLANNER_BOOT_BUDGET_MS + 6_000, `the local boot budget, not the 13.3s stall budget, must settle this turn (${budgetElapsedMs}ms)`);
178
+ assert.ok(budgetElapsedMs < PLANNER_BOOT_BUDGET_MS + 6_000, `the local boot budget, not the 16s stall budget, must settle this turn (${budgetElapsedMs}ms)`);
174
179
  assert.equal(requests.length, 1, 'a blocked handshake makes no provider request');
175
180
  await bounded(planner.close(), 3_000, 'boot-budget tree cleanup exceeded budget');
176
181
  planner = undefined;
177
182
  port = undefined;
178
183
  assert.ok(!alive(budgetPids.pid) && !alive(budgetPids.workerPid), 'boot-budget runtime and worker both reaped');
179
- console.log('DSH READINESS BOOT BUDGET:', JSON.stringify({ budgetMs: PLANNER_BOOT_BUDGET_MS, elapsedMs: budgetElapsedMs, code: 'PLANNER_BOOT_TIMEOUT', providerRequests: 0, treeReaped: true }));
184
+ console.log('DSH READINESS BOOT BUDGET:', JSON.stringify({ budgetMs: PLANNER_BOOT_BUDGET_MS, elapsedMs: budgetElapsedMs, code: 'PLANNER_BOOT_TIMEOUT', providerRequests: 0, retryRefused: true, treeReaped: true }));
180
185
  phase = 'initialize_failure';
181
186
  const failedMarker = join(root, 'failed-start.json');
182
187
  const failedBin = join(root, 'failed-dsh.mjs');
@@ -191,6 +196,49 @@ try {
191
196
  port = undefined;
192
197
  assert.ok(!alive(failed.pid) && !alive(failed.workerPid), 'failed runtime and worker both reaped');
193
198
  console.log('DSH READINESS FAILURE CLASSIFICATION:', JSON.stringify({ exitCode: failed.exitCode, signal: failed.signal, error: 'HARNESS_START_FAILED', rawStderrExposed: false, providerRequests: 0, treeReaped: true }));
199
+ // A cold first boot that exceeds the local budget is retried on a fresh
200
+ // runtime while the deadline still funds a viable run (#669). The first
201
+ // runtime stays blocked before initialize; the second is released up front,
202
+ // so it completes initialize and reaches the provider, which never answers.
203
+ // The 24s deadline funds the retry with margin on a slow host, and the 9s
204
+ // stall budget ends the stalled second run before a third boot fits.
205
+ phase = 'boot_retry_fresh_port';
206
+ const retryCold = gate('retry-cold');
207
+ const retryWarm = gate('retry-warm');
208
+ writeFileSync(retryWarm.release, 'release');
209
+ const retryPorts = [];
210
+ planner = await createDshEmbeddedBookingPlanner({
211
+ runPortFactory: async () => {
212
+ const dshBin = (retryPorts.length === 0 ? retryCold : retryWarm).dshBin;
213
+ const created = await createRealRunPort({ stateRoot: root, env, dshBin, maxTokens: 512 });
214
+ retryPorts.push(created);
215
+ return created;
216
+ },
217
+ turnTimeoutMs: 24_000,
218
+ stallSoftBudgetMs: 9_000,
219
+ });
220
+ const requestsBeforeRetry = requests.length;
221
+ const retryAt = Date.now();
222
+ const retryResultPromise = planner.plannerFactory(task).next({ turn, task });
223
+ await until(() => existsSync(retryCold.marker), 3_000, 'cold retry runtime never entered startup gate');
224
+ const coldRetryPids = retryCold.pids();
225
+ evidence = { ...coldRetryPids, providerRequests: requests.length };
226
+ await until(() => existsSync(retryWarm.marker), 15_000, 'the boot timeout never retried on a fresh runtime');
227
+ const retriedAfterMs = Date.now() - retryAt;
228
+ const warmRetryPids = retryWarm.pids();
229
+ assert.ok(retriedAfterMs >= PLANNER_BOOT_BUDGET_MS, `the retry waits for the local boot budget (${retriedAfterMs}ms)`);
230
+ assert.ok(retriedAfterMs < PLANNER_BOOT_BUDGET_MS + 6_000, `the local boot budget, not the SDK fallback, triggers the retry (${retriedAfterMs}ms)`);
231
+ assert.ok(!alive(coldRetryPids.pid) && !alive(coldRetryPids.workerPid), 'the timed-out runtime is reaped before the fresh one starts');
232
+ await until(() => requests.length === requestsBeforeRetry + 1, 10_000, 'the fresh runtime never reached the provider');
233
+ const retryResult = await bounded(retryResultPromise, 20_000, 'the retried turn did not settle');
234
+ const retryElapsedMs = Date.now() - retryAt;
235
+ assert.equal(retryResult[0]?.kind === 'error' && retryResult[0].error.code, 'PLANNER_PROVIDER_TIMEOUT', 'a retried boot that reached a stalled provider is a provider timeout, not a boot timeout');
236
+ assert.equal(retryPorts.length, 2, 'exactly one fresh-port retry');
237
+ assert.equal(requests.length, requestsBeforeRetry + 1, 'only the fresh runtime made a provider request');
238
+ await bounded(planner.close(), 3_000, 'boot-retry tree cleanup exceeded budget');
239
+ planner = undefined;
240
+ assert.ok(!alive(warmRetryPids.pid) && !alive(warmRetryPids.workerPid), 'the fresh runtime and worker are reaped on close');
241
+ console.log('DSH READINESS BOOT RETRY:', JSON.stringify({ budgetMs: PLANNER_BOOT_BUDGET_MS, retriedAfterMs, elapsedMs: retryElapsedMs, code: 'PLANNER_PROVIDER_TIMEOUT', ports: retryPorts.length, providerRequests: 1, treeReaped: true }));
194
242
  // Opt-in distribution run: boot fresh runtimes back to back so the local
195
243
  // initialize budget can be set from measured percentiles instead of a guess.
196
244
  // Each sample is a new worker plus a new dsh home, so the page cache is the
@@ -235,4 +283,4 @@ finally {
235
283
  if (failedCleanup?.status === 'rejected')
236
284
  throw failedCleanup.reason;
237
285
  }
238
- console.log('BOOKING COPILOT DSH READINESS PROOF: real initialize, provider stall, cold timeout and process cleanup OK');
286
+ console.log('BOOKING COPILOT DSH READINESS PROOF: real initialize, provider stall, cold timeout, boot retry and process cleanup OK');