@danceiny/gotry 0.2.0-rc.31 → 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.
- package/README.md +2 -2
- package/README.zh-CN.md +2 -2
- package/bin/gotry-bootstrap.js +63 -53
- package/bin/gotry-hbcli-release.d.ts +3 -0
- package/bin/gotry-hbcli-release.js +4 -0
- package/client/client.js +66 -21
- package/dist/capabilities/doctor.js +7 -8
- package/dist/capabilities/effect.js +45 -12
- package/dist/capabilities/flyai-config.js +12 -10
- package/dist/capabilities/flyai.js +87 -56
- package/dist/capabilities/hbcli-config.js +156 -0
- package/dist/capabilities/hbcli.js +29 -2
- package/dist/capabilities/resilience.js +6 -6
- package/dist/capabilities/session/adapters/ctrip-flight.js +42 -7
- package/dist/capabilities/session/adapters/ctrip-hotel.js +52 -3
- package/dist/capabilities/session/bridge-ledger.js +168 -67
- package/dist/capabilities/session/ctrip-city.js +186 -0
- package/dist/capabilities/session/extension-bridge.js +148 -47
- package/dist/capabilities/session/gui-flight.js +949 -0
- package/dist/capabilities/session/gui-hotel.js +470 -0
- package/dist/capabilities/session-search.js +102 -14
- package/dist/scripts/booking-copilot-dsh-planner-proof-tests.js +129 -2
- package/dist/scripts/booking-copilot-dsh-readiness-proof.js +53 -5
- package/dist/scripts/booking-copilot-gateway-chat-adapter-tests.js +233 -0
- package/dist/scripts/booking-copilot-planner-live-probe.js +1 -0
- package/dist/scripts/booking-copilot-startup-proof-tests.js +1 -1
- package/dist/scripts/bootstrap-tests.js +23 -6
- package/dist/scripts/bridge-ledger-tests.js +20 -18
- package/dist/scripts/ctrip-city-tests.js +80 -0
- package/dist/scripts/effect-tests.js +602 -504
- package/dist/scripts/extension-tests.js +14 -8
- package/dist/scripts/fixtures/tenant-runaway-worker-fixture.mjs +73 -0
- package/dist/scripts/flight-malformed-tests.js +1 -1
- package/dist/scripts/flyai-setup-tests.js +19 -4
- package/dist/scripts/flyai-tests.js +768 -653
- package/dist/scripts/gotry-web-api-tests.js +5 -0
- package/dist/scripts/gotry-web-client-tests.js +20 -1
- package/dist/scripts/gui-flight-live.js +27 -0
- package/dist/scripts/hbcli-tests.js +1 -1
- package/dist/scripts/hbcli-upgrade-tests.js +107 -0
- package/dist/scripts/hbcli-web-setup-tests.js +114 -0
- package/dist/scripts/hotel-date-gate-tests.js +1 -1
- package/dist/scripts/hotel-result-tests.js +169 -0
- package/dist/scripts/issue-271-liveness-tests.js +1 -1
- package/dist/scripts/mutation-check.js +153 -0
- package/dist/scripts/nightly-evidence.js +4 -111
- package/dist/scripts/session-tests.js +211 -6
- package/dist/scripts/tenant-bridge-org-tests.js +1264 -0
- package/dist/scripts/tenant-capacity-probe.js +263 -0
- package/dist/scripts/tenant-compute-isolation-tests.js +707 -0
- package/dist/scripts/tenant-egress-credentials-tests.js +822 -0
- package/dist/scripts/tenant-executor-binding-tests.js +203 -0
- package/dist/scripts/tenant-prompt-partition-tests.js +265 -0
- package/dist/scripts/tenant-seam-adversarial-tests.js +957 -0
- package/dist/scripts/tenant-seam-quota-tests.js +383 -0
- package/dist/scripts/tenant-seam-test-kit.js +292 -0
- package/dist/scripts/tenant-state-copies-tests.js +607 -0
- package/dist/scripts/tenant-state-drill-tests.js +298 -0
- package/dist/scripts/tenant-state-drill-worker.js +164 -0
- package/dist/scripts/tenant-state-durable-conformance.js +101 -0
- package/dist/scripts/tenant-state-handoff-tests.js +854 -0
- package/dist/scripts/tenant-state-lease-conformance.js +142 -0
- package/dist/scripts/tenant-state-lease-worker.js +28 -0
- package/dist/scripts/tenant-state-router-tests.js +464 -0
- package/dist/scripts/tenant-state-test-kit.js +92 -0
- package/dist/scripts/tenant-state-tests.js +564 -0
- package/dist/src/backend/modules/booking-executor.js +61 -6
- package/dist/src/backend/modules/session-search.js +136 -39
- package/dist/src/booking-surface/dsh-llm-gateway-chat.js +434 -0
- package/dist/src/booking-surface/dsh-planner.js +150 -19
- package/dist/src/booking-surface/index.js +1 -0
- package/dist/src/booking-surface/managed-dsh-run-port.js +121 -7
- package/dist/src/booking-surface/server.js +193 -80
- package/dist/src/booking-surface/startup.js +153 -0
- package/dist/src/booking-surface/tenancy.js +188 -0
- package/dist/src/gotry-web-api.js +64 -2
- package/dist/src/index.js +70 -18
- package/dist/src/llm-price-table.js +148 -0
- package/dist/src/tenant-seam/index.js +8 -0
- package/dist/src/tenant-seam/llm-broker.js +508 -0
- package/dist/src/tenant-seam/process-governor.js +281 -0
- package/dist/src/tenant-seam/quotas.js +360 -0
- package/dist/src/tenant-seam/seam.js +92 -0
- package/dist/src/tenant-state/durable-store.js +158 -0
- package/dist/src/tenant-state/epoch-copies.js +422 -0
- package/dist/src/tenant-state/errors.js +108 -0
- package/dist/src/tenant-state/ids.js +101 -0
- package/dist/src/tenant-state/index.js +11 -0
- package/dist/src/tenant-state/lease-store.js +203 -0
- package/dist/src/tenant-state/tenant-lease.js +150 -0
- package/dist/src/tenant-state/tenant-router.js +650 -0
- package/extension/manifest.json +2 -2
- package/package.json +5 -1
- package/ts/capabilities/flyai-config.ts +12 -10
- package/ts/capabilities/flyai.ts +91 -50
- package/ts/capabilities/hbcli-config.ts +128 -0
- package/ts/capabilities/hbcli.ts +30 -3
- package/ts/capabilities/session/adapters/ctrip-flight.ts +43 -7
- package/ts/capabilities/session/adapters/ctrip-hotel.ts +51 -4
- package/ts/capabilities/session/bridge-ledger.ts +192 -78
- package/ts/capabilities/session/ctrip-city.ts +158 -0
- package/ts/capabilities/session/extension-bridge.ts +185 -67
- package/ts/capabilities/session/gui-flight.ts +1022 -0
- package/ts/capabilities/session/gui-hotel.ts +505 -0
- package/ts/capabilities/session-search.ts +130 -18
- package/ts/src/booking-surface/dsh-llm-gateway-chat.js +434 -0
- package/ts/src/booking-surface/dsh-planner.ts +169 -19
- package/ts/src/booking-surface/index.ts +1 -0
- package/ts/src/booking-surface/managed-dsh-run-port.ts +150 -7
- package/ts/src/booking-surface/server.ts +215 -84
- package/ts/src/booking-surface/startup.ts +155 -0
- package/ts/src/booking-surface/tenancy.ts +242 -0
- package/ts/src/gotry-web-api.ts +51 -2
- package/ts/src/index.ts +67 -19
- package/ts/src/llm-price-table.ts +180 -0
- package/ts/src/tenant-seam/index.ts +9 -0
- package/ts/src/tenant-seam/llm-broker.ts +520 -0
- package/ts/src/tenant-seam/process-governor.ts +343 -0
- package/ts/src/tenant-seam/quotas.ts +433 -0
- package/ts/src/tenant-seam/seam.ts +142 -0
- package/ts/src/tenant-state/durable-store.ts +171 -0
- package/ts/src/tenant-state/epoch-copies.ts +491 -0
- package/ts/src/tenant-state/errors.ts +128 -0
- package/ts/src/tenant-state/ids.ts +108 -0
- package/ts/src/tenant-state/index.ts +12 -0
- package/ts/src/tenant-state/lease-store.ts +266 -0
- package/ts/src/tenant-state/tenant-lease.ts +175 -0
- package/ts/src/tenant-state/tenant-router.ts +743 -0
|
@@ -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
|
-
}), /
|
|
1722
|
-
assert.equal(invalidCrossRunRuns,
|
|
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
|
-
//
|
|
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
|
-
|
|
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
|
|
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');
|
|
@@ -0,0 +1,233 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Proof tests for the gateway chat-completions LLM plugin
|
|
3
|
+
* (dsh-llm-gateway-chat.js): wire dialect against a mock OpenAI-style
|
|
4
|
+
* chat/completions server, patch wiring, and provider selection.
|
|
5
|
+
*
|
|
6
|
+
* Run: npx tsx scripts/booking-copilot-gateway-chat-adapter-tests.ts
|
|
7
|
+
*/
|
|
8
|
+
import assert from 'node:assert/strict';
|
|
9
|
+
import { createServer } from 'node:http';
|
|
10
|
+
// A string-typed specifier keeps TypeScript from resolving the undeclared JS
|
|
11
|
+
// module; GatewayChatModule above is the surface this proof relies on.
|
|
12
|
+
const GATEWAY_CHAT_MODULE = '../src/booking-surface/dsh-llm-gateway-chat.js';
|
|
13
|
+
const { GatewayChatAdapter } = await import(GATEWAY_CHAT_MODULE);
|
|
14
|
+
const { buildDshEmbeddedBookingPatch, resolveRealRunPortConfig } = await import('../src/booking-surface/dsh-planner.js');
|
|
15
|
+
function chunksOf(chunks, type) {
|
|
16
|
+
return chunks.filter((chunk) => chunk.type === type);
|
|
17
|
+
}
|
|
18
|
+
function firstChunk(chunks, type) {
|
|
19
|
+
const [chunk] = chunksOf(chunks, type);
|
|
20
|
+
assert.ok(chunk, `expected a ${type} chunk`);
|
|
21
|
+
return chunk;
|
|
22
|
+
}
|
|
23
|
+
function errorCode(error) {
|
|
24
|
+
return typeof error === 'object' && error !== null && 'code' in error ? error.code : undefined;
|
|
25
|
+
}
|
|
26
|
+
function sse(res, frames) {
|
|
27
|
+
res.writeHead(200, { 'content-type': 'text/event-stream' });
|
|
28
|
+
for (const frame of frames)
|
|
29
|
+
res.write(frame);
|
|
30
|
+
res.end('data: [DONE]\n\n');
|
|
31
|
+
}
|
|
32
|
+
async function withMockServer(handler, run) {
|
|
33
|
+
const server = createServer(handler);
|
|
34
|
+
await new Promise((resolve) => server.listen(0, '127.0.0.1', () => resolve()));
|
|
35
|
+
try {
|
|
36
|
+
const address = server.address();
|
|
37
|
+
assert.ok(address !== null && typeof address === 'object', 'mock server listens on a TCP port');
|
|
38
|
+
return await run(address.port);
|
|
39
|
+
}
|
|
40
|
+
finally {
|
|
41
|
+
server.close();
|
|
42
|
+
}
|
|
43
|
+
}
|
|
44
|
+
function adapterFor(port, captured) {
|
|
45
|
+
return new GatewayChatAdapter({
|
|
46
|
+
resolveConnection: () => ({ baseURL: `http://127.0.0.1:${port}/v1`, models: [] }),
|
|
47
|
+
resolveApiKey: async () => 'test-only-key',
|
|
48
|
+
providerName: 'Gateway Chat',
|
|
49
|
+
...(captured !== undefined ? { __captured: captured } : {}),
|
|
50
|
+
});
|
|
51
|
+
}
|
|
52
|
+
// 1. A tool-calling stream translates to harness chunks: text block, tool-call
|
|
53
|
+
// accumulation across deltas, deferred block-ends, usage, and tool-calls finish.
|
|
54
|
+
{
|
|
55
|
+
const requests = [];
|
|
56
|
+
const chunks = await withMockServer((req, res) => {
|
|
57
|
+
let body = '';
|
|
58
|
+
req.on('data', (c) => { body += c; });
|
|
59
|
+
req.on('end', () => {
|
|
60
|
+
requests.push(JSON.parse(body));
|
|
61
|
+
sse(res, [
|
|
62
|
+
'data: {"choices":[{"delta":{"role":"assistant","content":"Let me search"}}]}\n\n',
|
|
63
|
+
'data: {"choices":[{"delta":{"tool_calls":[{"index":0,"id":"call_1","function":{"name":"booking_search_hotels","arguments":"{\\"input\\":"}}]}}]}\n\n',
|
|
64
|
+
'data: {"choices":[{"delta":{"tool_calls":[{"index":0,"function":{"arguments":"{\\"destination\\":\\"Osaka\\"}"}}]}}]}\n\n',
|
|
65
|
+
'data: {"choices":[{"delta":{},"finish_reason":"tool_calls"}],"usage":{"prompt_tokens":100,"completion_tokens":20,"total_tokens":120,"prompt_tokens_details":{"cached_tokens":40},"completion_tokens_details":{"reasoning_tokens":5}}}\n\n',
|
|
66
|
+
]);
|
|
67
|
+
});
|
|
68
|
+
}, async (port) => {
|
|
69
|
+
const out = [];
|
|
70
|
+
for await (const chunk of adapterFor(port).stream({
|
|
71
|
+
provider: 'gateway-chat',
|
|
72
|
+
model: 'deepseek-flash',
|
|
73
|
+
messages: [
|
|
74
|
+
{ role: 'system', content: [{ type: 'text', text: 'planner system prompt' }] },
|
|
75
|
+
{ role: 'user', content: [{ type: 'text', text: 'hotels in Osaka' }] },
|
|
76
|
+
],
|
|
77
|
+
tools: [{ name: 'booking_search_hotels', description: 'search hotels', parameters: { type: 'object', properties: {} } }],
|
|
78
|
+
maxTokens: 16_384,
|
|
79
|
+
}))
|
|
80
|
+
out.push(chunk);
|
|
81
|
+
return out;
|
|
82
|
+
});
|
|
83
|
+
assert.equal(requests.length, 1);
|
|
84
|
+
assert.equal(requests[0].model, 'deepseek-flash');
|
|
85
|
+
assert.deepEqual(requests[0].messages.map((m) => m.role), ['system', 'user']);
|
|
86
|
+
assert.equal(requests[0].tools?.[0].type, 'function');
|
|
87
|
+
assert.equal(requests[0].tools?.[0].function.name, 'booking_search_hotels');
|
|
88
|
+
assert.equal(requests[0].stream, true);
|
|
89
|
+
assert.deepEqual(requests[0].stream_options, { include_usage: true });
|
|
90
|
+
// The harness profile runs the planner with thinking enabled at high effort
|
|
91
|
+
// (rc.1 wire parity); dropping these fields measurably degrades the model's
|
|
92
|
+
// adherence to the planner's intent rules.
|
|
93
|
+
assert.deepEqual(requests[0].thinking, { type: 'enabled' });
|
|
94
|
+
assert.equal(requests[0].reasoning_effort, 'high');
|
|
95
|
+
const kinds = chunks.map((c) => c.type);
|
|
96
|
+
assert.deepEqual(kinds, ['block-start', 'text-delta', 'block-start', 'tool-call-delta', 'tool-call-delta', 'block-end', 'block-end', 'usage', 'finish']);
|
|
97
|
+
const text = firstChunk(chunks, 'text-delta');
|
|
98
|
+
assert.equal(text.text, 'Let me search');
|
|
99
|
+
const toolDelta = chunksOf(chunks, 'tool-call-delta');
|
|
100
|
+
assert.equal(toolDelta[0].id, 'call_1');
|
|
101
|
+
assert.equal(toolDelta[0].name, 'booking_search_hotels');
|
|
102
|
+
assert.equal(toolDelta.map((c) => c.argumentsDelta).join(''), '{"input":{"destination":"Osaka"}');
|
|
103
|
+
const ends = chunksOf(chunks, 'block-end');
|
|
104
|
+
assert.equal(ends[0].block.type, 'text');
|
|
105
|
+
const toolBlock = ends[1].block;
|
|
106
|
+
assert.ok(toolBlock.type === 'tool-call', 'the second block closes the tool call');
|
|
107
|
+
assert.equal(toolBlock.arguments, '{"input":{"destination":"Osaka"}');
|
|
108
|
+
const usage = firstChunk(chunks, 'usage');
|
|
109
|
+
assert.equal(usage.usage.inputTokens, 60, 'cache reads subtract from prompt tokens');
|
|
110
|
+
assert.equal(usage.usage.outputTokens, 20);
|
|
111
|
+
assert.equal(usage.usage.totalTokens, 120);
|
|
112
|
+
assert.equal(usage.usage.cacheReadTokens, 40);
|
|
113
|
+
assert.equal(usage.usage.reasoningTokens, 5);
|
|
114
|
+
const finish = firstChunk(chunks, 'finish');
|
|
115
|
+
assert.deepEqual(finish.reason, { kind: 'tool-calls' });
|
|
116
|
+
}
|
|
117
|
+
// 2. Tool-result history round-trips as `role:"tool"` wire messages.
|
|
118
|
+
{
|
|
119
|
+
const requests = [];
|
|
120
|
+
await withMockServer((req, res) => {
|
|
121
|
+
let body = '';
|
|
122
|
+
req.on('data', (c) => { body += c; });
|
|
123
|
+
req.on('end', () => {
|
|
124
|
+
requests.push(JSON.parse(body));
|
|
125
|
+
sse(res, [
|
|
126
|
+
'data: {"choices":[{"delta":{"content":"done"}}]}\n\n',
|
|
127
|
+
'data: {"choices":[{"delta":{},"finish_reason":"stop"}]}\n\n',
|
|
128
|
+
]);
|
|
129
|
+
});
|
|
130
|
+
}, async (port) => {
|
|
131
|
+
const out = [];
|
|
132
|
+
for await (const chunk of adapterFor(port).stream({
|
|
133
|
+
provider: 'gateway-chat',
|
|
134
|
+
model: 'deepseek-flash',
|
|
135
|
+
reasoningEffort: 'off',
|
|
136
|
+
messages: [
|
|
137
|
+
{ role: 'user', content: [{ type: 'text', text: 'hotels in Osaka' }] },
|
|
138
|
+
{ role: 'assistant', content: [{ type: 'tool-call', id: 'call_9', name: 'booking_search_hotels', arguments: '{"input":{}}' }] },
|
|
139
|
+
{ role: 'user', content: [{ type: 'tool-result', toolCallId: 'call_9', content: [{ type: 'text', text: '598 hotels' }] }] },
|
|
140
|
+
],
|
|
141
|
+
}))
|
|
142
|
+
out.push(chunk);
|
|
143
|
+
return out;
|
|
144
|
+
});
|
|
145
|
+
assert.deepEqual(requests[0].thinking, { type: 'disabled' }, 'reasoningEffort=off maps to thinking disabled');
|
|
146
|
+
assert.equal(requests[0].reasoning_effort, undefined, 'off never crosses the wire as an effort');
|
|
147
|
+
const roles = requests[0].messages.map((m) => m.role);
|
|
148
|
+
assert.deepEqual(roles, ['user', 'assistant', 'tool']);
|
|
149
|
+
assert.equal(requests[0].messages[1].tool_calls?.[0].id, 'call_9');
|
|
150
|
+
assert.equal(requests[0].messages[2].tool_call_id, 'call_9');
|
|
151
|
+
assert.equal(requests[0].messages[2].content, '598 hotels');
|
|
152
|
+
}
|
|
153
|
+
// 3. HTTP failure statuses map to typed LlmError codes.
|
|
154
|
+
{
|
|
155
|
+
await withMockServer((req, res) => {
|
|
156
|
+
res.writeHead(401, { 'content-type': 'application/json' });
|
|
157
|
+
res.end('{"error":"bad key"}');
|
|
158
|
+
}, async (port) => {
|
|
159
|
+
await assert.rejects(() => adapterFor(port).stream({ provider: 'gateway-chat', model: 'm', messages: [{ role: 'user', content: [{ type: 'text', text: 'x' }] }] }).next(), (error) => errorCode(error) === 'AUTH');
|
|
160
|
+
});
|
|
161
|
+
}
|
|
162
|
+
// 4. A stream that dies before [DONE] is STREAM_CLOSED, never a silent success.
|
|
163
|
+
{
|
|
164
|
+
await withMockServer((req, res) => {
|
|
165
|
+
res.writeHead(200, { 'content-type': 'text/event-stream' });
|
|
166
|
+
res.write('data: {"choices":[{"delta":{"content":"partial"}}]}\n\n');
|
|
167
|
+
res.end();
|
|
168
|
+
}, async (port) => {
|
|
169
|
+
await assert.rejects(() => adapterFor(port).stream({ provider: 'gateway-chat', model: 'm', messages: [{ role: 'user', content: [{ type: 'text', text: 'x' }] }] }).next(), () => true).catch((error) => {
|
|
170
|
+
// first next() may surface the text-delta fine; the failure lands on the
|
|
171
|
+
// following tick — drain to the throw.
|
|
172
|
+
});
|
|
173
|
+
let saw = false;
|
|
174
|
+
try {
|
|
175
|
+
for await (const _chunk of adapterFor(port).stream({ provider: 'gateway-chat', model: 'm', messages: [{ role: 'user', content: [{ type: 'text', text: 'x' }] }] })) {
|
|
176
|
+
void _chunk;
|
|
177
|
+
}
|
|
178
|
+
}
|
|
179
|
+
catch (error) {
|
|
180
|
+
saw = errorCode(error) === 'STREAM_CLOSED';
|
|
181
|
+
}
|
|
182
|
+
assert.ok(saw, 'a truncated stream must surface STREAM_CLOSED');
|
|
183
|
+
});
|
|
184
|
+
}
|
|
185
|
+
// 4b. Exact model metadata satisfies the harness INVALID_MODEL_INFO gate
|
|
186
|
+
// (provider/id/name required) and prepareCall returns the { model } shape.
|
|
187
|
+
{
|
|
188
|
+
const adapter = adapterFor(1);
|
|
189
|
+
const info = await adapter.resolveModel('gateway-chat', 'deepseek-flash');
|
|
190
|
+
assert.equal(info.provider, 'gateway-chat');
|
|
191
|
+
assert.equal(info.id, 'deepseek-flash');
|
|
192
|
+
assert.equal(info.name, 'deepseek-flash');
|
|
193
|
+
assert.ok(info.context.contextWindow > 0);
|
|
194
|
+
assert.ok(info.reasoning.efforts.some((e) => e.id === 'off'));
|
|
195
|
+
const prepared = await adapter.prepareCall('gateway-chat', 'deepseek-flash');
|
|
196
|
+
assert.equal(prepared.model.id, 'deepseek-flash');
|
|
197
|
+
assert.equal(typeof prepared.stream, 'function');
|
|
198
|
+
}
|
|
199
|
+
// 5. The patch renders both plugin inserts: the typed tools plugin and the
|
|
200
|
+
// gateway chat-completions LLM plugin.
|
|
201
|
+
{
|
|
202
|
+
const patch = buildDshEmbeddedBookingPatch('/tools/plugin.js', '/llm/gateway.js');
|
|
203
|
+
assert.match(patch, /- id: gotry-embedded-booking\n/);
|
|
204
|
+
assert.match(patch, /name: '\/tools\/plugin\.js'/);
|
|
205
|
+
assert.match(patch, /- id: llm-gateway-chat\n/);
|
|
206
|
+
assert.match(patch, /name: '\/llm\/gateway\.js'/);
|
|
207
|
+
const minimal = buildDshEmbeddedBookingPatch('/tools/plugin.js');
|
|
208
|
+
assert.match(minimal, /gotry-embedded-booking/);
|
|
209
|
+
assert.doesNotMatch(minimal, /llm-gateway-chat/);
|
|
210
|
+
}
|
|
211
|
+
// 6. Provider selection: DEEPSEEK_PROVIDER rides the child env and selects the
|
|
212
|
+
// gateway route without an explicit option.
|
|
213
|
+
{
|
|
214
|
+
const resolved = resolveRealRunPortConfig({
|
|
215
|
+
env: {
|
|
216
|
+
DEEPSEEK_API_KEY: 'k',
|
|
217
|
+
DEEPSEEK_BASE_URL: 'http://gw/v1',
|
|
218
|
+
DEEPSEEK_MODEL: 'deepseek-flash',
|
|
219
|
+
DEEPSEEK_PROVIDER: 'gateway-chat',
|
|
220
|
+
},
|
|
221
|
+
});
|
|
222
|
+
assert.equal(resolved.provider, 'gateway-chat');
|
|
223
|
+
assert.equal(resolved.childEnv.DEEPSEEK_PROVIDER, 'gateway-chat');
|
|
224
|
+
const fallback = resolveRealRunPortConfig({
|
|
225
|
+
env: {
|
|
226
|
+
DEEPSEEK_API_KEY: 'k',
|
|
227
|
+
DEEPSEEK_MODEL: 'deepseek-flash',
|
|
228
|
+
},
|
|
229
|
+
});
|
|
230
|
+
assert.equal(fallback.provider, 'deepseek-official');
|
|
231
|
+
assert.equal(fallback.childEnv.DEEPSEEK_PROVIDER, undefined);
|
|
232
|
+
}
|
|
233
|
+
console.log('BOOKING COPILOT GATEWAY CHAT ADAPTER PROOF: wire dialect/tool round-trip/status mapping/patch wiring/provider selection OK');
|
|
@@ -67,6 +67,7 @@ try {
|
|
|
67
67
|
DEEPSEEK_API_KEY: apiKey,
|
|
68
68
|
DEEPSEEK_BASE_URL: baseUrl,
|
|
69
69
|
DEEPSEEK_MODEL: model,
|
|
70
|
+
...(process.env.DEEPSEEK_PROVIDER ? { DEEPSEEK_PROVIDER: process.env.DEEPSEEK_PROVIDER } : {}),
|
|
70
71
|
},
|
|
71
72
|
// Match the UAT deployment deadline (planner-model-plane.yaml turnTimeoutMs).
|
|
72
73
|
turnTimeoutMs: 45_000,
|
|
@@ -68,7 +68,7 @@ const started = await startBookingCopilotFromEnvironment(trustedEnv, {
|
|
|
68
68
|
ingressBinding: trustedBinding,
|
|
69
69
|
principal: { subject: 'startup-bff', scope: 'booking:read' },
|
|
70
70
|
async startServer(options) {
|
|
71
|
-
serverApiKey = options.apiKey;
|
|
71
|
+
serverApiKey = options.apiKey ?? '';
|
|
72
72
|
serverArtifactId = options.artifactId ?? '';
|
|
73
73
|
assert.equal(options.ingressMode, 'bff-ingress-binding');
|
|
74
74
|
return {
|
|
@@ -103,12 +103,22 @@ assert.ok(!c7b.out.includes('下载通道'), '非法值不应进入 github 通
|
|
|
103
103
|
console.log('7. 扩展分发 github 通道(不可达基址即时降级 + 非法值回落 bundled)OK');
|
|
104
104
|
// 8. doctor 子命令(2026-09-02 迪拜 session 复盘:可选依赖不撒手——doctor 统一
|
|
105
105
|
// 显示状态 + 精确补装指引;LLM key 显式让渡;报告落 gotry-state/doctor-report.md)
|
|
106
|
-
const
|
|
106
|
+
const doctorHome = mkdtempSync(join(tmpdir(), 'gotry-doctor-home-'));
|
|
107
|
+
let c8;
|
|
108
|
+
try {
|
|
109
|
+
c8 = runBootstrap(['doctor'], { HOME: doctorHome, FLYAI_API_KEY: '', DEBUG_FLYAI_API_KEY: '', STAICLI_HOME: join(doctorHome, '.staicli'), HOTELBYTE_TOKEN: '' });
|
|
110
|
+
}
|
|
111
|
+
finally {
|
|
112
|
+
rmSync(doctorHome, { recursive: true, force: true });
|
|
113
|
+
}
|
|
107
114
|
assert.ok(c8.out.includes('[gotry-doctor]'), '应输出 [gotry-doctor] 标签');
|
|
108
115
|
assert.ok(c8.out.includes('Agent Reach'), '体检应含 agent-reach 项');
|
|
109
116
|
assert.ok(c8.out.includes('hbcli'), '体检应含 hbcli 项');
|
|
110
117
|
assert.ok(c8.out.includes('FlyAI(飞猪官方检索'), '体检应含 flyai 项');
|
|
111
|
-
|
|
118
|
+
// FlyAI 面三合法态(doctor.ts #521:configured≠verified):匿名试用 / 已配置,未验证 / 已验证。
|
|
119
|
+
// 机器态依赖断言:本机配置并验证过 key 的环境(如 founder 机)doctor 输出「已验证 …」,
|
|
120
|
+
// 两态断言会把健康态误判为红(2026-10-14 发版门禁实测)。
|
|
121
|
+
assert.ok(c8.out.includes('匿名试用') || c8.out.includes('已配置,未验证') || c8.out.includes('已验证'), '体检应明确 FlyAI 当前状态(匿名试用/已配置,未验证/已验证)');
|
|
112
122
|
assert.ok(c8.out.includes('LLM key'), '体检应含 LLM key 让渡说明(doctor 不管 key)');
|
|
113
123
|
assert.ok(c8.out.includes('dsh-map-tools'), '体检应含 dsh-map-tools 项(#139;与 ts/capabilities/doctor.ts 两面成对)');
|
|
114
124
|
assert.ok(c8.out.includes('dsh-tool-ask-user'), '体检应含 dsh-tool-ask-user 项(与 ts/capabilities/doctor.ts 两面成对)');
|
|
@@ -396,6 +406,7 @@ console.log('18. onboarding CLI 跳过(非 TTY / CI / GOTRY_SETUP_SKIP / GOTRY_O
|
|
|
396
406
|
copyFileSync(join(repoRoot, 'bin', 'gotry-flyai-verification.js'), join(fixture, 'bin', 'gotry-flyai-verification.js'));
|
|
397
407
|
copyFileSync(join(repoRoot, 'bin', 'gotry-process-liveness.js'), join(fixture, 'bin', 'gotry-process-liveness.js'));
|
|
398
408
|
copyFileSync(join(repoRoot, 'bin', 'gotry-extension-status.js'), join(fixture, 'bin', 'gotry-extension-status.js'));
|
|
409
|
+
copyFileSync(join(repoRoot, 'bin', 'gotry-hbcli-release.js'), join(fixture, 'bin', 'gotry-hbcli-release.js'));
|
|
399
410
|
copyFileSync(join(repoRoot, 'package.json'), join(fixture, 'package.json'));
|
|
400
411
|
const vendorDir = join(fixture, 'ts', 'dsh-runtime', 'vendor');
|
|
401
412
|
mkdirSync(vendorDir, { recursive: true });
|
|
@@ -647,6 +658,7 @@ function buildPkgFixture(tmpRoot) {
|
|
|
647
658
|
copyFileSync(join(repoRoot, 'bin', 'gotry-bootstrap.js'), join(pkgBin, 'gotry-bootstrap.js'));
|
|
648
659
|
copyFileSync(join(repoRoot, 'bin', 'gotry-flyai-verification.js'), join(pkgBin, 'gotry-flyai-verification.js'));
|
|
649
660
|
copyFileSync(join(repoRoot, 'bin', 'gotry-extension-status.js'), join(pkgBin, 'gotry-extension-status.js'));
|
|
661
|
+
copyFileSync(join(repoRoot, 'bin', 'gotry-hbcli-release.js'), join(pkgBin, 'gotry-hbcli-release.js'));
|
|
650
662
|
copyFileSync(join(repoRoot, 'bin', 'gotry-runtime-resolution.js'), join(pkgBin, 'gotry-runtime-resolution.js'));
|
|
651
663
|
copyFileSync(join(repoRoot, 'bin', 'gotry-process-liveness.js'), join(pkgBin, 'gotry-process-liveness.js'));
|
|
652
664
|
copyFileSync(join(repoRoot, 'cordis.gotry-patch.yml'), join(pkgRoot, 'cordis.gotry-patch.yml'));
|
|
@@ -1508,8 +1520,9 @@ console.log('23. result 通道排他写入(预存文件不被覆盖 + symlink
|
|
|
1508
1520
|
assert.deepEqual(unit('not json'), { ok: true, customer: false }, '非 JSON(超旧版/异常)保守视为有效,与工具层同语义');
|
|
1509
1521
|
assert.deepEqual(unit(''), { ok: true, customer: false }, '空输出同属不可解析,保守视为有效');
|
|
1510
1522
|
assert.deepEqual(unit(null), { ok: false, customer: false }, '取不到输出(非零退出/超时/spawn 失败)→ 未确认');
|
|
1511
|
-
const fakeHbcli = (whoamiArm) => `#!/bin/sh\ncase "$*" in\n *--version*) echo '
|
|
1523
|
+
const fakeHbcli = (whoamiArm, version = '0.0.4') => `#!/bin/sh\ncase "$*" in\n *--version*) echo '${version}';;\n *version*) echo '${version}';;\n *whoami*) ${whoamiArm};;\nesac\n`;
|
|
1512
1524
|
const cases = [
|
|
1525
|
+
{ name: 'old-version', version: '0.0.3', arm: `echo '${allFalse.replace('"api_key":{"configured":false}', '"api_key":{"configured":true}')}'`, expect: 'missing' },
|
|
1513
1526
|
{ name: 'allfalse', arm: `echo '${allFalse}'`, expect: 'degraded' },
|
|
1514
1527
|
{ name: 'customer', arm: `echo '${allFalse.replace('"customer":{"configured":false}', '"customer":{"configured":true}')}'`, expect: 'ok', customer: true },
|
|
1515
1528
|
{ name: 'apikey', arm: `echo '${allFalse.replace('"api_key":{"configured":false}', '"api_key":{"configured":true}')}'`, expect: 'ok' },
|
|
@@ -1527,12 +1540,12 @@ console.log('23. result 通道排他写入(预存文件不被覆盖 + symlink
|
|
|
1527
1540
|
mkdirSync(binDir);
|
|
1528
1541
|
mkdirSync(home);
|
|
1529
1542
|
symlinkSync(process.execPath, join(binDir, 'node'));
|
|
1530
|
-
writeFileSync(join(binDir, 'hbcli'), fakeHbcli(c.arm), { mode: 0o755 });
|
|
1543
|
+
writeFileSync(join(binDir, 'hbcli'), fakeHbcli(c.arm, c.version), { mode: 0o755 });
|
|
1531
1544
|
// CLI 面
|
|
1532
1545
|
const cli = runBootstrap(['doctor'], { HOME: home, PATH: binDir, FLYAI_API_KEY: '' });
|
|
1533
1546
|
const line = cli.out.split('\n').find((l) => l.includes('hbcli(酒店实时源)'));
|
|
1534
1547
|
assert.ok(line, `${c.name}: CLI doctor 应输出 hbcli 行\n${cli.out}`);
|
|
1535
|
-
const cliLevel = line.includes('已安装且凭证有效') ? 'ok' : line.includes('二进制在,但凭证未配置/失效') ? 'degraded' : 'other';
|
|
1548
|
+
const cliLevel = line.includes('已安装且凭证有效') ? 'ok' : line.includes('二进制在,但凭证未配置/失效') ? 'degraded' : line.includes('版本过旧') ? 'missing' : 'other';
|
|
1536
1549
|
assert.equal(cliLevel, c.expect, `${c.name}: CLI doctor 的 hbcli 结论应为 ${c.expect}\n${line}`);
|
|
1537
1550
|
if (c.customer)
|
|
1538
1551
|
assert.ok(line.includes('customer 档在用'), `${c.name}: CLI 应标注 customer 档在用\n${line}`);
|
|
@@ -1542,6 +1555,10 @@ console.log('23. result 通道排他写入(预存文件不被覆盖 + symlink
|
|
|
1542
1555
|
process.env.PATH = prevPath;
|
|
1543
1556
|
assert.equal(tool.status, c.expect, `${c.name}: 工具层 doctor 的 hbcli 结论应为 ${c.expect},实际 ${tool.status}`);
|
|
1544
1557
|
assert.equal(cliLevel, tool.status, `${c.name}: CLI 与工具层必须同结论(两份实现不得漂移)`);
|
|
1558
|
+
if (c.expect === 'missing') {
|
|
1559
|
+
assert.match(cli.out, /staicli-v0\.0\.4\/install\.sh/, 'CLI 应提供当前官方版本的安装入口');
|
|
1560
|
+
assert.match(tool.fix ?? '', /staicli-v0\.0\.4\/install\.sh/, '工具层应提供相同升级入口');
|
|
1561
|
+
}
|
|
1545
1562
|
}
|
|
1546
1563
|
finally {
|
|
1547
1564
|
process.env.PATH = prevPath;
|
|
@@ -1560,5 +1577,5 @@ console.log('23. result 通道排他写入(预存文件不被覆盖 + symlink
|
|
|
1560
1577
|
catch { /* ignore */ }
|
|
1561
1578
|
}
|
|
1562
1579
|
}
|
|
1563
|
-
console.log('24. hbcli
|
|
1580
|
+
console.log('24. hbcli 版本与凭证判定(CLI↔工具层 6 夹具同结论:旧版=missing / 全 false=degraded / customer·api_key=ok / 非 JSON=保守 ok / 非零退出=degraded)OK');
|
|
1564
1581
|
console.log('BOOTSTRAP TESTS: 26/26 OK(扩展就位 + 跳过开关 / wizard --dry-run / wizard 真实 / 扩展分发通道 / doctor 体检面 / calendar setup 状态面 / 显式跳过 + auto 跳过 + 单项跳过 / 启动摘要 / sidebar 落盘状态复核 / onboarding 分类+计划+env opt-out+跳过原因+yes+partial-failure+幂等+prompt+CLI 跳过+--scan / 编排缝 orchestrateWebLaunch × 6 / prompt waiter failure deterministic process-group reap / waiter 原始错误 + cleanup timeout/failure handled / 临时安装包 spawned inner 21a TTY-eligible 真实 prompt→n→web + 摘要抑制 + 无残留 / 21b non-TTY 零 prompt / 21c POSIX 信号清理 SIGTERM→清 result+patch 目录 + 无残留子进程 / 21f yes-path installer 子树信号清理 / 21g yes-path timeout installer 子树清理 / 21d timeout 诊断 + 无残留 / 21e dsh-lifecycle 信号转发 / win32 平台边界 / result 通道排他写入 / hbcli 凭证判定 CLI↔工具层对拍)');
|