@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.
Files changed (128) hide show
  1. package/README.md +2 -2
  2. package/README.zh-CN.md +2 -2
  3. package/bin/gotry-bootstrap.js +63 -53
  4. package/bin/gotry-hbcli-release.d.ts +3 -0
  5. package/bin/gotry-hbcli-release.js +4 -0
  6. package/client/client.js +66 -21
  7. package/dist/capabilities/doctor.js +7 -8
  8. package/dist/capabilities/effect.js +45 -12
  9. package/dist/capabilities/flyai-config.js +12 -10
  10. package/dist/capabilities/flyai.js +87 -56
  11. package/dist/capabilities/hbcli-config.js +156 -0
  12. package/dist/capabilities/hbcli.js +29 -2
  13. package/dist/capabilities/resilience.js +6 -6
  14. package/dist/capabilities/session/adapters/ctrip-flight.js +42 -7
  15. package/dist/capabilities/session/adapters/ctrip-hotel.js +52 -3
  16. package/dist/capabilities/session/bridge-ledger.js +168 -67
  17. package/dist/capabilities/session/ctrip-city.js +186 -0
  18. package/dist/capabilities/session/extension-bridge.js +148 -47
  19. package/dist/capabilities/session/gui-flight.js +949 -0
  20. package/dist/capabilities/session/gui-hotel.js +470 -0
  21. package/dist/capabilities/session-search.js +102 -14
  22. package/dist/scripts/booking-copilot-dsh-planner-proof-tests.js +129 -2
  23. package/dist/scripts/booking-copilot-dsh-readiness-proof.js +53 -5
  24. package/dist/scripts/booking-copilot-gateway-chat-adapter-tests.js +233 -0
  25. package/dist/scripts/booking-copilot-planner-live-probe.js +1 -0
  26. package/dist/scripts/booking-copilot-startup-proof-tests.js +1 -1
  27. package/dist/scripts/bootstrap-tests.js +23 -6
  28. package/dist/scripts/bridge-ledger-tests.js +20 -18
  29. package/dist/scripts/ctrip-city-tests.js +80 -0
  30. package/dist/scripts/effect-tests.js +602 -504
  31. package/dist/scripts/extension-tests.js +14 -8
  32. package/dist/scripts/fixtures/tenant-runaway-worker-fixture.mjs +73 -0
  33. package/dist/scripts/flight-malformed-tests.js +1 -1
  34. package/dist/scripts/flyai-setup-tests.js +19 -4
  35. package/dist/scripts/flyai-tests.js +768 -653
  36. package/dist/scripts/gotry-web-api-tests.js +5 -0
  37. package/dist/scripts/gotry-web-client-tests.js +20 -1
  38. package/dist/scripts/gui-flight-live.js +27 -0
  39. package/dist/scripts/hbcli-tests.js +1 -1
  40. package/dist/scripts/hbcli-upgrade-tests.js +107 -0
  41. package/dist/scripts/hbcli-web-setup-tests.js +114 -0
  42. package/dist/scripts/hotel-date-gate-tests.js +1 -1
  43. package/dist/scripts/hotel-result-tests.js +169 -0
  44. package/dist/scripts/issue-271-liveness-tests.js +1 -1
  45. package/dist/scripts/mutation-check.js +153 -0
  46. package/dist/scripts/nightly-evidence.js +4 -111
  47. package/dist/scripts/session-tests.js +211 -6
  48. package/dist/scripts/tenant-bridge-org-tests.js +1264 -0
  49. package/dist/scripts/tenant-capacity-probe.js +263 -0
  50. package/dist/scripts/tenant-compute-isolation-tests.js +707 -0
  51. package/dist/scripts/tenant-egress-credentials-tests.js +822 -0
  52. package/dist/scripts/tenant-executor-binding-tests.js +203 -0
  53. package/dist/scripts/tenant-prompt-partition-tests.js +265 -0
  54. package/dist/scripts/tenant-seam-adversarial-tests.js +957 -0
  55. package/dist/scripts/tenant-seam-quota-tests.js +383 -0
  56. package/dist/scripts/tenant-seam-test-kit.js +292 -0
  57. package/dist/scripts/tenant-state-copies-tests.js +607 -0
  58. package/dist/scripts/tenant-state-drill-tests.js +298 -0
  59. package/dist/scripts/tenant-state-drill-worker.js +164 -0
  60. package/dist/scripts/tenant-state-durable-conformance.js +101 -0
  61. package/dist/scripts/tenant-state-handoff-tests.js +854 -0
  62. package/dist/scripts/tenant-state-lease-conformance.js +142 -0
  63. package/dist/scripts/tenant-state-lease-worker.js +28 -0
  64. package/dist/scripts/tenant-state-router-tests.js +464 -0
  65. package/dist/scripts/tenant-state-test-kit.js +92 -0
  66. package/dist/scripts/tenant-state-tests.js +564 -0
  67. package/dist/src/backend/modules/booking-executor.js +61 -6
  68. package/dist/src/backend/modules/session-search.js +136 -39
  69. package/dist/src/booking-surface/dsh-llm-gateway-chat.js +434 -0
  70. package/dist/src/booking-surface/dsh-planner.js +150 -19
  71. package/dist/src/booking-surface/index.js +1 -0
  72. package/dist/src/booking-surface/managed-dsh-run-port.js +121 -7
  73. package/dist/src/booking-surface/server.js +193 -80
  74. package/dist/src/booking-surface/startup.js +153 -0
  75. package/dist/src/booking-surface/tenancy.js +188 -0
  76. package/dist/src/gotry-web-api.js +64 -2
  77. package/dist/src/index.js +70 -18
  78. package/dist/src/llm-price-table.js +148 -0
  79. package/dist/src/tenant-seam/index.js +8 -0
  80. package/dist/src/tenant-seam/llm-broker.js +508 -0
  81. package/dist/src/tenant-seam/process-governor.js +281 -0
  82. package/dist/src/tenant-seam/quotas.js +360 -0
  83. package/dist/src/tenant-seam/seam.js +92 -0
  84. package/dist/src/tenant-state/durable-store.js +158 -0
  85. package/dist/src/tenant-state/epoch-copies.js +422 -0
  86. package/dist/src/tenant-state/errors.js +108 -0
  87. package/dist/src/tenant-state/ids.js +101 -0
  88. package/dist/src/tenant-state/index.js +11 -0
  89. package/dist/src/tenant-state/lease-store.js +203 -0
  90. package/dist/src/tenant-state/tenant-lease.js +150 -0
  91. package/dist/src/tenant-state/tenant-router.js +650 -0
  92. package/extension/manifest.json +2 -2
  93. package/package.json +5 -1
  94. package/ts/capabilities/flyai-config.ts +12 -10
  95. package/ts/capabilities/flyai.ts +91 -50
  96. package/ts/capabilities/hbcli-config.ts +128 -0
  97. package/ts/capabilities/hbcli.ts +30 -3
  98. package/ts/capabilities/session/adapters/ctrip-flight.ts +43 -7
  99. package/ts/capabilities/session/adapters/ctrip-hotel.ts +51 -4
  100. package/ts/capabilities/session/bridge-ledger.ts +192 -78
  101. package/ts/capabilities/session/ctrip-city.ts +158 -0
  102. package/ts/capabilities/session/extension-bridge.ts +185 -67
  103. package/ts/capabilities/session/gui-flight.ts +1022 -0
  104. package/ts/capabilities/session/gui-hotel.ts +505 -0
  105. package/ts/capabilities/session-search.ts +130 -18
  106. package/ts/src/booking-surface/dsh-llm-gateway-chat.js +434 -0
  107. package/ts/src/booking-surface/dsh-planner.ts +169 -19
  108. package/ts/src/booking-surface/index.ts +1 -0
  109. package/ts/src/booking-surface/managed-dsh-run-port.ts +150 -7
  110. package/ts/src/booking-surface/server.ts +215 -84
  111. package/ts/src/booking-surface/startup.ts +155 -0
  112. package/ts/src/booking-surface/tenancy.ts +242 -0
  113. package/ts/src/gotry-web-api.ts +51 -2
  114. package/ts/src/index.ts +67 -19
  115. package/ts/src/llm-price-table.ts +180 -0
  116. package/ts/src/tenant-seam/index.ts +9 -0
  117. package/ts/src/tenant-seam/llm-broker.ts +520 -0
  118. package/ts/src/tenant-seam/process-governor.ts +343 -0
  119. package/ts/src/tenant-seam/quotas.ts +433 -0
  120. package/ts/src/tenant-seam/seam.ts +142 -0
  121. package/ts/src/tenant-state/durable-store.ts +171 -0
  122. package/ts/src/tenant-state/epoch-copies.ts +491 -0
  123. package/ts/src/tenant-state/errors.ts +128 -0
  124. package/ts/src/tenant-state/ids.ts +108 -0
  125. package/ts/src/tenant-state/index.ts +12 -0
  126. package/ts/src/tenant-state/lease-store.ts +266 -0
  127. package/ts/src/tenant-state/tenant-lease.ts +175 -0
  128. package/ts/src/tenant-state/tenant-router.ts +743 -0
@@ -22,518 +22,616 @@ import { join } from 'node:path';
22
22
  import { backoffDelayMs, CircuitBreaker, withRetry } from '../capabilities/resilience.js';
23
23
  import { declinedObservation, makeMockInterpreter, makeProductionInterpreter, __resetEffectBreakersForTest, } from '../capabilities/effect.js';
24
24
  const sleep0 = async () => { }; // 回退即时放行(确定性,不真睡)
25
- let clock = 1_000_000;
26
- const now = () => clock;
27
- // ---------------------------------------------------------------------------
28
- // 1. 注册表封闭性:未登记效应 → 结构化拒绝(不抛错)
29
- // ---------------------------------------------------------------------------
30
- const interp = makeProductionInterpreter({ sleep: sleep0, now });
31
- const badItp = await interp({ effect: 'CTRIP_DIRECT_QUERY', params: {} });
32
- assert.equal(badItp.result, null, '未登记效应 result=null');
33
- assert.equal(badItp.trace.declined, 'unknown-effect');
34
- assert.equal(badItp.trace.attempts, 0, '未登记效应零执行');
35
- const badObs = declinedObservation('CTRIP_DIRECT_QUERY', badItp.trace);
36
- assert.equal(badObs.ok, false);
37
- assert.match(badObs.summary, /未登记效应/, '拒绝面人话 summary');
38
- assert.match(badObs.evidence, /\[效应:CTRIP_DIRECT_QUERY@/, '拒绝面带证据链');
39
- console.log('1. 注册表封闭性:未登记效应 → 结构化拒绝面(不抛错)OK');
40
- // Host cancellation is an explicit outcome, not an unregistered-effect diagnosis.
41
- const preCancelled = new AbortController();
42
- preCancelled.abort();
43
- let cancelledDispatches = 0;
44
- const cancelledInterpreter = makeProductionInterpreter({
45
- breakers: new Map(),
46
- handlers: { ANYTHING_SEARCH: async () => { cancelledDispatches++; return { ok: true }; } },
47
- });
48
- const cancelled = await cancelledInterpreter({ effect: 'ANYTHING_SEARCH', params: { signal: preCancelled.signal } });
49
- assert.equal(cancelled.result, null);
50
- assert.equal(cancelled.trace.declined, 'aborted');
51
- assert.equal(cancelled.trace.attempts, 0);
52
- assert.equal(cancelledDispatches, 0);
53
- const cancelledObservation = declinedObservation('ANYTHING_SEARCH', cancelled.trace);
54
- assert.equal(cancelledObservation.ok, false);
55
- assert.match(cancelledObservation.summary, /已取消/);
56
- assert.doesNotMatch(cancelledObservation.summary, /未登记|断路器/);
57
- assert.match(cancelledObservation.evidence, /@abort@/);
58
- console.log('1b. 宿主取消明确呈现已取消,零派发、不误报未登记效应 OK');
59
- // ---------------------------------------------------------------------------
60
- // 2. 指数退避:500→1000→2000 封顶;累计记账;非瞬时失败不重试
61
- // ---------------------------------------------------------------------------
62
- assert.equal(backoffDelayMs({ baseDelayMs: 500, maxDelayMs: 8000 }, 1), 500);
63
- assert.equal(backoffDelayMs({ baseDelayMs: 500, maxDelayMs: 8000 }, 2), 1000);
64
- assert.equal(backoffDelayMs({ baseDelayMs: 500, maxDelayMs: 8000 }, 4), 4000);
65
- assert.equal(backoffDelayMs({ baseDelayMs: 500, maxDelayMs: 2000 }, 6), 2000, '封顶');
66
- const retried = [];
67
- const rt = await withRetry(async () => ({ ok: false }), { maxAttempts: 4, baseDelayMs: 500, maxDelayMs: 2000, isRetryable: () => true, sleep: sleep0 }, (_failN, delayMs) => retried.push(delayMs));
68
- assert.deepEqual(retried, [500, 1000, 2000], '退避序列 500/1000/2000');
69
- assert.equal(rt.attempts, 4, '重试到上限');
70
- assert.equal(rt.backoffMs, 3500, '累计回退记账');
71
- const noRetry = await withRetry(async () => ({ ok: false, verdict: 'error', error: '{"message":"SentinelBlockException"}' }), { maxAttempts: 3, baseDelayMs: 500, maxDelayMs: 2000, isRetryable: () => false, sleep: sleep0 });
72
- assert.equal(noRetry.attempts, 1, '瞬时判定说不重试就 1 次');
73
- // 2a. 退避等待生命周期:正常 timer、取消与注入 sleep 都移除 abort listener。
74
- const normalBackoffController = new AbortController();
75
- let normalBackoffCalls = 0;
76
- const normalBackoff = await withRetry(async () => ({ ok: ++normalBackoffCalls > 1 }), { maxAttempts: 2, baseDelayMs: 5, maxDelayMs: 5, signal: normalBackoffController.signal });
77
- assert.equal(normalBackoff.attempts, 2, '真实 timer 等待后继续下一次尝试');
78
- assert.equal(normalBackoff.aborted, false, '正常等待不是取消');
79
- assert.equal(getEventListeners(normalBackoffController.signal, 'abort').length, 0, '正常 timer 完成后移除 abort listener');
80
- const cancelledBackoffController = new AbortController();
81
- let cancelledBackoffCalls = 0;
82
- let cancelTimer;
83
- let cancelledBackoff;
25
+ // Every FlyAI test resolves only synthetic configuration, never the user's key.
26
+ const flyaiTestHome = await mkdtemp(join(tmpdir(), 'effect-flyai-home-'));
27
+ const originalFlyaiEnv = Object.fromEntries(['HOME', 'FLYAI_API_KEY', 'DEBUG_FLYAI_API_KEY', 'DEBUG_FLYAI_MCP_URL'].map(name => [name, process.env[name]]));
28
+ process.env.HOME = flyaiTestHome;
29
+ process.env.FLYAI_API_KEY = 'effect-fixture-key';
30
+ delete process.env.DEBUG_FLYAI_API_KEY;
31
+ process.env.DEBUG_FLYAI_MCP_URL = 'https://flyai-fixture.invalid/mcp';
84
32
  try {
85
- cancelledBackoff = await withRetry(async () => { cancelledBackoffCalls += 1; return { ok: false }; }, { maxAttempts: 2, baseDelayMs: 100, maxDelayMs: 100, signal: cancelledBackoffController.signal }, () => { cancelTimer = setTimeout(() => cancelledBackoffController.abort(), 1); });
86
- }
87
- finally {
88
- if (cancelTimer !== undefined)
89
- clearTimeout(cancelTimer);
90
- }
91
- assert.equal(cancelledBackoff.attempts, 1, 'backoff 取消不派发下一次尝试');
92
- assert.equal(cancelledBackoff.aborted, true, 'backoff 取消返回 aborted');
93
- assert.equal(cancelledBackoffCalls, 1, '取消不是第二次失败');
94
- assert.equal(getEventListeners(cancelledBackoffController.signal, 'abort').length, 0, '取消后移除 abort listener');
95
- const injectedSleepController = new AbortController();
96
- let injectedSleepCalls = 0;
97
- const injectedSleep = await withRetry(async () => ({ ok: false }), {
98
- maxAttempts: 2,
99
- baseDelayMs: 1,
100
- maxDelayMs: 1,
101
- signal: injectedSleepController.signal,
102
- sleep: async () => { injectedSleepCalls += 1; injectedSleepController.abort(); },
103
- });
104
- assert.equal(injectedSleepCalls, 1, '退避使用注入 sleep');
105
- assert.equal(injectedSleep.aborted, true, '注入 sleep 中取消仍终止重试');
106
- assert.equal(injectedSleep.attempts, 1, '注入 sleep 取消不派发下一次尝试');
107
- assert.equal(getEventListeners(injectedSleepController.signal, 'abort').length, 0, '注入 sleep 取消后移除 abort listener');
108
- const rejectedSleepController = new AbortController();
109
- const sleepFailure = new Error('synthetic sleep failure');
110
- await assert.rejects(withRetry(async () => ({ ok: false }), {
111
- maxAttempts: 2,
112
- baseDelayMs: 1,
113
- maxDelayMs: 1,
114
- signal: rejectedSleepController.signal,
115
- sleep: async () => { throw sleepFailure; },
116
- }), (error) => error === sleepFailure, '注入 sleep rejection 保持原异常约定');
117
- assert.equal(getEventListeners(rejectedSleepController.signal, 'abort').length, 0, '注入 sleep rejection 后移除 abort listener');
118
- console.log('2. 指数退避(封顶链/累计记账/非瞬时止步)OK');
119
- // ---------------------------------------------------------------------------
120
- // 3. 断路器三态(时钟注入确定性)
121
- // ---------------------------------------------------------------------------
122
- const br = new CircuitBreaker({ failureThreshold: 2, openMs: 60_000, now });
123
- assert.equal(br.canAttempt().state, 'closed');
124
- br.onFailure();
125
- br.onFailure();
126
- assert.equal(br.state(), 'open', '连续 2 失败 → open');
127
- assert.equal(br.canAttempt().allowed, false, 'open 中拒绝');
128
- clock += 61_000;
129
- assert.equal(br.state(), 'half-open', '冷却满 → half-open');
130
- const gate = br.canAttempt();
131
- assert.equal(gate.allowed, true, 'half-open 放行单探测');
132
- assert.equal(br.canAttempt().allowed, false, '探测在途拒并发');
133
- br.onSuccess();
134
- assert.equal(br.state(), 'closed', '探测成功 → closed');
135
- br.onFailure();
136
- br.onFailure();
137
- clock += 61_000;
138
- assert.equal(br.state(), 'half-open');
139
- assert.equal(br.canAttempt().allowed, true);
140
- br.onFailure();
141
- assert.equal(br.state(), 'open', '探测失败 → 重新 open(冷却重启)');
142
- console.log('3. 断路器三态(closed→open→half-open 单探测→双向收敛)OK');
143
- // 3a. 半开探测所有权:旧 closed 调用的取消/完成不能释放或修改新一代探测。
144
- const raceClock = { value: 100 };
145
- const raceNow = () => raceClock.value;
146
- let resolveOldClosed;
147
- let resolveOldClosedCompletion;
148
- let resolveHalfOpenProbe;
149
- let oldClosedStarted;
150
- let oldClosedCompletionStarted;
151
- let halfOpenProbeStarted;
152
- const oldClosedReady = new Promise((resolve) => { oldClosedStarted = resolve; });
153
- const oldClosedCompletionReady = new Promise((resolve) => { oldClosedCompletionStarted = resolve; });
154
- const halfOpenProbeReady = new Promise((resolve) => { halfOpenProbeStarted = resolve; });
155
- let secondProbeCalled = false;
156
- const raceInterpreter = makeProductionInterpreter({
157
- sleep: sleep0,
158
- now: raceNow,
159
- breakers: new Map(),
160
- handlers: {
161
- HBCLI_HOTEL_SEARCH: async (params) => {
162
- if (params.testCase === 'old-closed') {
163
- oldClosedStarted();
164
- return new Promise((resolve) => { resolveOldClosed = resolve; });
165
- }
166
- if (params.testCase === 'old-closed-completion') {
167
- oldClosedCompletionStarted();
168
- return new Promise((resolve) => { resolveOldClosedCompletion = resolve; });
169
- }
170
- if (params.testCase === 'half-open-probe') {
171
- halfOpenProbeStarted();
172
- return new Promise((resolve) => { resolveHalfOpenProbe = resolve; });
173
- }
174
- if (params.testCase === 'second-probe') {
175
- secondProbeCalled = true;
176
- return { via: 'hbcli-realtime' };
177
- }
178
- return { via: 'hbcli-error', error: 'synthetic permanent failure' };
33
+ let clock = 1_000_000;
34
+ const now = () => clock;
35
+ // ---------------------------------------------------------------------------
36
+ // 1. 注册表封闭性:未登记效应 → 结构化拒绝(不抛错)
37
+ // ---------------------------------------------------------------------------
38
+ const interp = makeProductionInterpreter({ sleep: sleep0, now });
39
+ const badItp = await interp({ effect: 'CTRIP_DIRECT_QUERY', params: {} });
40
+ assert.equal(badItp.result, null, '未登记效应 result=null');
41
+ assert.equal(badItp.trace.declined, 'unknown-effect');
42
+ assert.equal(badItp.trace.attempts, 0, '未登记效应零执行');
43
+ const badObs = declinedObservation('CTRIP_DIRECT_QUERY', badItp.trace);
44
+ assert.equal(badObs.ok, false);
45
+ assert.match(badObs.summary, /未登记效应/, '拒绝面人话 summary');
46
+ assert.match(badObs.evidence, /\[效应:CTRIP_DIRECT_QUERY@/, '拒绝面带证据链');
47
+ console.log('1. 注册表封闭性:未登记效应 → 结构化拒绝面(不抛错)OK');
48
+ // Host cancellation is an explicit outcome, not an unregistered-effect diagnosis.
49
+ const preCancelled = new AbortController();
50
+ preCancelled.abort();
51
+ let cancelledDispatches = 0;
52
+ const cancelledInterpreter = makeProductionInterpreter({
53
+ breakers: new Map(),
54
+ handlers: { ANYTHING_SEARCH: async () => { cancelledDispatches++; return { ok: true }; } },
55
+ });
56
+ const cancelled = await cancelledInterpreter({ effect: 'ANYTHING_SEARCH', params: { signal: preCancelled.signal } });
57
+ assert.equal(cancelled.result, null);
58
+ assert.equal(cancelled.trace.declined, 'aborted');
59
+ assert.equal(cancelled.trace.attempts, 0);
60
+ assert.equal(cancelledDispatches, 0);
61
+ const cancelledObservation = declinedObservation('ANYTHING_SEARCH', cancelled.trace);
62
+ assert.equal(cancelledObservation.ok, false);
63
+ assert.match(cancelledObservation.summary, /已取消/);
64
+ assert.doesNotMatch(cancelledObservation.summary, /未登记|断路器/);
65
+ assert.match(cancelledObservation.evidence, /@abort@/);
66
+ console.log('1b. 宿主取消明确呈现已取消,零派发、不误报未登记效应 OK');
67
+ // ---------------------------------------------------------------------------
68
+ // 2. 指数退避:500→1000→2000 封顶;累计记账;非瞬时失败不重试
69
+ // ---------------------------------------------------------------------------
70
+ assert.equal(backoffDelayMs({ baseDelayMs: 500, maxDelayMs: 8000 }, 1), 500);
71
+ assert.equal(backoffDelayMs({ baseDelayMs: 500, maxDelayMs: 8000 }, 2), 1000);
72
+ assert.equal(backoffDelayMs({ baseDelayMs: 500, maxDelayMs: 8000 }, 4), 4000);
73
+ assert.equal(backoffDelayMs({ baseDelayMs: 500, maxDelayMs: 2000 }, 6), 2000, '封顶');
74
+ const retried = [];
75
+ const rt = await withRetry(async () => ({ ok: false }), { maxAttempts: 4, baseDelayMs: 500, maxDelayMs: 2000, isRetryable: () => true, sleep: sleep0 }, (_failN, delayMs) => retried.push(delayMs));
76
+ assert.deepEqual(retried, [500, 1000, 2000], '退避序列 500/1000/2000');
77
+ assert.equal(rt.attempts, 4, '重试到上限');
78
+ assert.equal(rt.backoffMs, 3500, '累计回退记账');
79
+ const noRetry = await withRetry(async () => ({ ok: false, verdict: 'error', error: '{"message":"SentinelBlockException"}' }), { maxAttempts: 3, baseDelayMs: 500, maxDelayMs: 2000, isRetryable: () => false, sleep: sleep0 });
80
+ assert.equal(noRetry.attempts, 1, '瞬时判定说不重试就 1 次');
81
+ // 2a. 退避等待生命周期:正常 timer、取消与注入 sleep 都移除 abort listener。
82
+ const normalBackoffController = new AbortController();
83
+ let normalBackoffCalls = 0;
84
+ const normalBackoff = await withRetry(async () => ({ ok: ++normalBackoffCalls > 1 }), { maxAttempts: 2, baseDelayMs: 5, maxDelayMs: 5, signal: normalBackoffController.signal });
85
+ assert.equal(normalBackoff.attempts, 2, '真实 timer 等待后继续下一次尝试');
86
+ assert.equal(normalBackoff.aborted, false, '正常等待不是取消');
87
+ assert.equal(getEventListeners(normalBackoffController.signal, 'abort').length, 0, '正常 timer 完成后移除 abort listener');
88
+ const cancelledBackoffController = new AbortController();
89
+ let cancelledBackoffCalls = 0;
90
+ let cancelTimer;
91
+ let cancelledBackoff;
92
+ try {
93
+ cancelledBackoff = await withRetry(async () => { cancelledBackoffCalls += 1; return { ok: false }; }, { maxAttempts: 2, baseDelayMs: 100, maxDelayMs: 100, signal: cancelledBackoffController.signal }, () => { cancelTimer = setTimeout(() => cancelledBackoffController.abort(), 1); });
94
+ }
95
+ finally {
96
+ if (cancelTimer !== undefined)
97
+ clearTimeout(cancelTimer);
98
+ }
99
+ assert.equal(cancelledBackoff.attempts, 1, 'backoff 取消不派发下一次尝试');
100
+ assert.equal(cancelledBackoff.aborted, true, 'backoff 取消返回 aborted');
101
+ assert.equal(cancelledBackoffCalls, 1, '取消不是第二次失败');
102
+ assert.equal(getEventListeners(cancelledBackoffController.signal, 'abort').length, 0, '取消后移除 abort listener');
103
+ const injectedSleepController = new AbortController();
104
+ let injectedSleepCalls = 0;
105
+ const injectedSleep = await withRetry(async () => ({ ok: false }), {
106
+ maxAttempts: 2,
107
+ baseDelayMs: 1,
108
+ maxDelayMs: 1,
109
+ signal: injectedSleepController.signal,
110
+ sleep: async () => { injectedSleepCalls += 1; injectedSleepController.abort(); },
111
+ });
112
+ assert.equal(injectedSleepCalls, 1, '退避使用注入 sleep');
113
+ assert.equal(injectedSleep.aborted, true, '注入 sleep 中取消仍终止重试');
114
+ assert.equal(injectedSleep.attempts, 1, '注入 sleep 取消不派发下一次尝试');
115
+ assert.equal(getEventListeners(injectedSleepController.signal, 'abort').length, 0, '注入 sleep 取消后移除 abort listener');
116
+ const rejectedSleepController = new AbortController();
117
+ const sleepFailure = new Error('synthetic sleep failure');
118
+ await assert.rejects(withRetry(async () => ({ ok: false }), {
119
+ maxAttempts: 2,
120
+ baseDelayMs: 1,
121
+ maxDelayMs: 1,
122
+ signal: rejectedSleepController.signal,
123
+ sleep: async () => { throw sleepFailure; },
124
+ }), (error) => error === sleepFailure, '注入 sleep rejection 保持原异常约定');
125
+ assert.equal(getEventListeners(rejectedSleepController.signal, 'abort').length, 0, '注入 sleep rejection 后移除 abort listener');
126
+ console.log('2. 指数退避(封顶链/累计记账/非瞬时止步)OK');
127
+ // ---------------------------------------------------------------------------
128
+ // 3. 断路器三态(时钟注入确定性)
129
+ // ---------------------------------------------------------------------------
130
+ const br = new CircuitBreaker({ failureThreshold: 2, openMs: 60_000, now });
131
+ assert.equal(br.canAttempt().state, 'closed');
132
+ br.onFailure();
133
+ br.onFailure();
134
+ assert.equal(br.state(), 'open', '连续 2 失败 → open');
135
+ assert.equal(br.canAttempt().allowed, false, 'open 中拒绝');
136
+ clock += 61_000;
137
+ assert.equal(br.state(), 'half-open', '冷却满 → half-open');
138
+ const gate = br.canAttempt();
139
+ assert.equal(gate.allowed, true, 'half-open 放行单探测');
140
+ assert.equal(br.canAttempt().allowed, false, '探测在途拒并发');
141
+ br.onSuccess();
142
+ assert.equal(br.state(), 'closed', '探测成功 → closed');
143
+ br.onFailure();
144
+ br.onFailure();
145
+ clock += 61_000;
146
+ assert.equal(br.state(), 'half-open');
147
+ assert.equal(br.canAttempt().allowed, true);
148
+ br.onFailure();
149
+ assert.equal(br.state(), 'open', '探测失败 → 重新 open(冷却重启)');
150
+ console.log('3. 断路器三态(closed→open→half-open 单探测→双向收敛)OK');
151
+ // A valid injected clock can start at epoch zero; opening must not depend on
152
+ // a truthy timestamp, and remaining cooldown must stay bounded if time rolls back.
153
+ let zeroClock = 0;
154
+ const epochBreaker = new CircuitBreaker({ failureThreshold: 1, openMs: 60_000, now: () => zeroClock });
155
+ epochBreaker.onFailure();
156
+ assert.equal(epochBreaker.canAttempt().allowed, false, 'failure at epoch zero must open the breaker');
157
+ assert.equal(epochBreaker.canAttempt().retryAfterMs, 60_000);
158
+ zeroClock = -1_000;
159
+ assert.equal(epochBreaker.canAttempt().retryAfterMs, 60_000, 'clock rollback cannot exceed the configured cooldown');
160
+ zeroClock = 60_000;
161
+ assert.equal(epochBreaker.canAttempt().allowed, true, 'exact cooldown boundary admits a single probe');
162
+ assert.equal(epochBreaker.canAttempt().retryAfterMs, undefined, 'in-flight probe has no invented completion estimate');
163
+ // 3a. 半开探测所有权:旧 closed 调用的取消/完成不能释放或修改新一代探测。
164
+ const raceClock = { value: 100 };
165
+ const raceNow = () => raceClock.value;
166
+ let resolveOldClosed;
167
+ let resolveOldClosedCompletion;
168
+ let resolveHalfOpenProbe;
169
+ let oldClosedStarted;
170
+ let oldClosedCompletionStarted;
171
+ let halfOpenProbeStarted;
172
+ const oldClosedReady = new Promise((resolve) => { oldClosedStarted = resolve; });
173
+ const oldClosedCompletionReady = new Promise((resolve) => { oldClosedCompletionStarted = resolve; });
174
+ const halfOpenProbeReady = new Promise((resolve) => { halfOpenProbeStarted = resolve; });
175
+ let secondProbeCalled = false;
176
+ const raceInterpreter = makeProductionInterpreter({
177
+ sleep: sleep0,
178
+ now: raceNow,
179
+ breakers: new Map(),
180
+ handlers: {
181
+ HBCLI_HOTEL_SEARCH: async (params) => {
182
+ if (params.testCase === 'old-closed') {
183
+ oldClosedStarted();
184
+ return new Promise((resolve) => { resolveOldClosed = resolve; });
185
+ }
186
+ if (params.testCase === 'old-closed-completion') {
187
+ oldClosedCompletionStarted();
188
+ return new Promise((resolve) => { resolveOldClosedCompletion = resolve; });
189
+ }
190
+ if (params.testCase === 'half-open-probe') {
191
+ halfOpenProbeStarted();
192
+ return new Promise((resolve) => { resolveHalfOpenProbe = resolve; });
193
+ }
194
+ if (params.testCase === 'second-probe') {
195
+ secondProbeCalled = true;
196
+ return { via: 'hbcli-realtime' };
197
+ }
198
+ return { via: 'hbcli-error', error: 'synthetic permanent failure' };
199
+ },
179
200
  },
180
- },
181
- });
182
- const oldClosedController = new AbortController();
183
- const oldClosed = raceInterpreter({ effect: 'HBCLI_HOTEL_SEARCH', params: { testCase: 'old-closed', signal: oldClosedController.signal } });
184
- await oldClosedReady;
185
- const oldClosedCompletion = raceInterpreter({ effect: 'HBCLI_HOTEL_SEARCH', params: { testCase: 'old-closed-completion' } });
186
- await oldClosedCompletionReady;
187
- for (let i = 0; i < 3; i++)
188
- await raceInterpreter({ effect: 'HBCLI_HOTEL_SEARCH', params: { testCase: `failure-${i}` } });
189
- const raceBlocked = await raceInterpreter({ effect: 'HBCLI_HOTEL_SEARCH', params: { testCase: 'blocked' } });
190
- assert.equal(raceBlocked.trace.declined, 'circuit-open', '旧请求在途时仍可进入 open');
191
- raceClock.value = 100_000;
192
- const halfOpenProbe = raceInterpreter({ effect: 'HBCLI_HOTEL_SEARCH', params: { testCase: 'half-open-probe' } });
193
- await halfOpenProbeReady;
194
- oldClosedController.abort();
195
- resolveOldClosed({ via: 'hbcli-error', error: 'aborted by host signal' });
196
- const cancelledOldClosed = await oldClosed;
197
- assert.equal(cancelledOldClosed.trace.attempts, 1, '旧 closed 请求取消仍只执行一次');
198
- assert.equal(cancelledOldClosed.trace.declined, 'aborted');
199
- assert.match(declinedObservation('HBCLI_HOTEL_SEARCH', cancelledOldClosed.trace).summary, /已取消/);
200
- resolveOldClosedCompletion({ via: 'hbcli-realtime' });
201
- const completedOldClosed = await oldClosedCompletion;
202
- assert.equal(completedOldClosed.trace.breaker, 'half-open', '旧 closed 请求完成不能修改新半开探测');
203
- const secondProbe = await raceInterpreter({ effect: 'HBCLI_HOTEL_SEARCH', params: { testCase: 'second-probe' } });
204
- assert.equal(secondProbe.trace.declined, 'circuit-open', '旧 closed 请求取消不能释放新一代半开探测');
205
- assert.equal(secondProbeCalled, false, '新一代探测仍由原探测独占');
206
- resolveHalfOpenProbe({ via: 'hbcli-realtime' });
207
- const settledHalfOpenProbe = await halfOpenProbe;
208
- assert.equal(settledHalfOpenProbe.trace.breaker, 'closed', '自身半开探测成功后恢复 closed');
209
- // 3b. 探测令牌必须跨 generation 隔离:旧令牌不能释放新探测;失败仍保持 open。
210
- const ownershipClock = { value: 1_000 };
211
- const ownership = new CircuitBreaker({ failureThreshold: 1, openMs: 10, now: () => ownershipClock.value });
212
- ownership.onFailure();
213
- ownershipClock.value += 10;
214
- const firstProbe = ownership.canAttempt();
215
- assert.equal(firstProbe.allowed, true);
216
- ownership.releaseProbe(firstProbe.token);
217
- const secondGenerationProbe = ownership.canAttempt();
218
- assert.equal(secondGenerationProbe.allowed, true, '自身取消释放后允许下一代探测');
219
- ownership.releaseProbe(firstProbe.token);
220
- assert.equal(ownership.canAttempt().allowed, false, '旧 generation 令牌不能释放新探测');
221
- ownership.onFailure(secondGenerationProbe.token);
222
- assert.equal(ownership.state(), 'open', '真实半开失败重新熔断');
223
- assert.equal(ownership.canAttempt().allowed, false, '真实半开失败后仍拒绝并发探测');
224
- console.log('3a/3b. 半开探测所有权(旧 closed 取消隔离/取消可重试/跨 generation/失败再熔断)OK');
225
- // ---------------------------------------------------------------------------
226
- // 4. 生产解译器×瞬时失败:重试后成功,trace 记账(WEATHER 免费源策略 2 次上限)
227
- // ---------------------------------------------------------------------------
228
- let flaky = 0;
229
- const ip = makeProductionInterpreter({
230
- sleep: sleep0,
231
- breakers: new Map(),
232
- handlers: {
233
- WEATHER_FORECAST: async () => {
234
- flaky += 1;
235
- return flaky < 2
236
- ? { ok: false, via: 'open-meteo-error', evidence: '[实时API:open-meteo@error]', latencyMs: 1, error: 'socket hang up' }
237
- : { ok: true, via: 'open-meteo', evidence: '[实时API:open-meteo@ts]', latencyMs: 2, daily: [] };
201
+ });
202
+ const oldClosedController = new AbortController();
203
+ const oldClosed = raceInterpreter({ effect: 'HBCLI_HOTEL_SEARCH', params: { testCase: 'old-closed', signal: oldClosedController.signal } });
204
+ await oldClosedReady;
205
+ const oldClosedCompletion = raceInterpreter({ effect: 'HBCLI_HOTEL_SEARCH', params: { testCase: 'old-closed-completion' } });
206
+ await oldClosedCompletionReady;
207
+ for (let i = 0; i < 3; i++)
208
+ await raceInterpreter({ effect: 'HBCLI_HOTEL_SEARCH', params: { testCase: `failure-${i}` } });
209
+ const raceBlocked = await raceInterpreter({ effect: 'HBCLI_HOTEL_SEARCH', params: { testCase: 'blocked' } });
210
+ assert.equal(raceBlocked.trace.declined, 'circuit-open', '旧请求在途时仍可进入 open');
211
+ raceClock.value = 100_000;
212
+ const halfOpenProbe = raceInterpreter({ effect: 'HBCLI_HOTEL_SEARCH', params: { testCase: 'half-open-probe' } });
213
+ await halfOpenProbeReady;
214
+ oldClosedController.abort();
215
+ resolveOldClosed({ via: 'hbcli-error', error: 'aborted by host signal' });
216
+ const cancelledOldClosed = await oldClosed;
217
+ assert.equal(cancelledOldClosed.trace.attempts, 1, '旧 closed 请求取消仍只执行一次');
218
+ assert.equal(cancelledOldClosed.trace.declined, 'aborted');
219
+ assert.match(declinedObservation('HBCLI_HOTEL_SEARCH', cancelledOldClosed.trace).summary, /已取消/);
220
+ resolveOldClosedCompletion({ via: 'hbcli-realtime' });
221
+ const completedOldClosed = await oldClosedCompletion;
222
+ assert.equal(completedOldClosed.trace.breaker, 'half-open', '旧 closed 请求完成不能修改新半开探测');
223
+ const secondProbe = await raceInterpreter({ effect: 'HBCLI_HOTEL_SEARCH', params: { testCase: 'second-probe' } });
224
+ assert.equal(secondProbe.trace.declined, 'circuit-open', '旧 closed 请求取消不能释放新一代半开探测');
225
+ assert.equal(secondProbeCalled, false, '新一代探测仍由原探测独占');
226
+ resolveHalfOpenProbe({ via: 'hbcli-realtime' });
227
+ const settledHalfOpenProbe = await halfOpenProbe;
228
+ assert.equal(settledHalfOpenProbe.trace.breaker, 'closed', '自身半开探测成功后恢复 closed');
229
+ // 3b. 探测令牌必须跨 generation 隔离:旧令牌不能释放新探测;失败仍保持 open。
230
+ const ownershipClock = { value: 1_000 };
231
+ const ownership = new CircuitBreaker({ failureThreshold: 1, openMs: 10, now: () => ownershipClock.value });
232
+ ownership.onFailure();
233
+ ownershipClock.value += 10;
234
+ const firstProbe = ownership.canAttempt();
235
+ assert.equal(firstProbe.allowed, true);
236
+ ownership.releaseProbe(firstProbe.token);
237
+ const secondGenerationProbe = ownership.canAttempt();
238
+ assert.equal(secondGenerationProbe.allowed, true, '自身取消释放后允许下一代探测');
239
+ ownership.releaseProbe(firstProbe.token);
240
+ assert.equal(ownership.canAttempt().allowed, false, '旧 generation 令牌不能释放新探测');
241
+ ownership.onFailure(secondGenerationProbe.token);
242
+ assert.equal(ownership.state(), 'open', '真实半开失败重新熔断');
243
+ assert.equal(ownership.canAttempt().allowed, false, '真实半开失败后仍拒绝并发探测');
244
+ console.log('3a/3b. 半开探测所有权(旧 closed 取消隔离/取消可重试/跨 generation/失败再熔断)OK');
245
+ // ---------------------------------------------------------------------------
246
+ // 4. 生产解译器×瞬时失败:重试后成功,trace 记账(WEATHER 免费源策略 2 次上限)
247
+ // ---------------------------------------------------------------------------
248
+ let flaky = 0;
249
+ const ip = makeProductionInterpreter({
250
+ sleep: sleep0,
251
+ breakers: new Map(),
252
+ handlers: {
253
+ WEATHER_FORECAST: async () => {
254
+ flaky += 1;
255
+ return flaky < 2
256
+ ? { ok: false, via: 'open-meteo-error', evidence: '[实时API:open-meteo@error]', latencyMs: 1, error: 'socket hang up' }
257
+ : { ok: true, via: 'open-meteo', evidence: '[实时API:open-meteo@ts]', latencyMs: 2, daily: [] };
258
+ },
238
259
  },
239
- },
240
- });
241
- const itp4 = await ip({ effect: 'WEATHER_FORECAST', params: { latitude: 25.6, longitude: 100.2 } });
242
- assert.equal(itp4.trace.attempts, 2, '瞬时失败重试第 2 次成功(免费源上限 2)');
243
- assert.equal(itp4.trace.backoffMs, 400, '回退按 API 策略记账');
244
- assert.ok(itp4.result.ok, '成功 observation 原样透传');
245
- assert.match(itp4.trace.evidence[0] ?? '', /attempts=2 backoff=400ms breaker=closed/, 'trace 证据行');
246
- console.log('4. 生产解译器×瞬时失败:重试后成功 + trace 记账 OK');
247
- // ---------------------------------------------------------------------------
248
- // 5. FlyAI×Sentinel:不重试,但连续失败计入熔断
249
- // ---------------------------------------------------------------------------
250
- const sentinelResult = {
251
- ok: false, via: 'flyai-error', verdict: 'error', kind: 'flight', latencyMs: 5,
252
- evidence: '[实时API:flyai@error@ts] parse failed(SentinelBlockException)',
253
- error: 'failed to parse flyai output as JSON (incomplete FlyAI JSON object): {"message":"SentinelBlockException"}',
254
- };
255
- let sentinelCalls = 0;
256
- const flyaiBreakers = new Map();
257
- const ipS = makeProductionInterpreter({
258
- sleep: sleep0,
259
- breakers: flyaiBreakers,
260
- now,
261
- handlers: { FLYAI_SEARCH: async () => { sentinelCalls += 1; return structuredClone(sentinelResult); } },
262
- });
263
- const q = { kind: 'flight', origin: '上海', destination: '丽江', depDate: '2026-10-01' };
264
- await ipS({ effect: 'FLYAI_SEARCH', params: q });
265
- await ipS({ effect: 'FLYAI_SEARCH', params: q });
266
- assert.equal(sentinelCalls, 2, '每次调用只打一次上游(Sentinel 不重试)');
267
- const itp3 = await ipS({ effect: 'FLYAI_SEARCH', params: q });
268
- assert.equal(itp3.trace.attempts, 1);
269
- assert.equal(itp3.trace.breaker, 'open', '连续 3 次失败(FlyAI 阈值 3)→ 断路器开启');
270
- assert.equal(sentinelCalls, 3);
271
- const itp4th = await ipS({ effect: 'FLYAI_SEARCH', params: q });
272
- assert.equal(itp4th.trace.declined, 'circuit-open', '第 4 次:熔断中零执行成本拒绝');
273
- assert.equal(itp4th.result, null);
274
- assert.equal(sentinelCalls, 3, '熔断后不再打上游');
275
- console.log('5. FlyAI×Sentinel:永不重试 + 连环失败触发熔断保护配额 OK');
276
- // #517: adapter-owned local termination is explicit terminal state. The
277
- // effect policy must honor retryable=false even when a stale timeout verdict is
278
- // present, while a classified transient result still receives one retry.
279
- let localDeadlineCalls = 0;
280
- const localDeadline = makeProductionInterpreter({
281
- breakers: new Map(), sleep: sleep0,
282
- handlers: {
283
- FLYAI_SEARCH: async () => {
284
- localDeadlineCalls += 1;
285
- return { ok: false, via: 'flyai-error', verdict: 'timeout', localTermination: 'deadline', retryable: false, kind: 'flight', latencyMs: 1, evidence: 'local deadline' };
260
+ });
261
+ const itp4 = await ip({ effect: 'WEATHER_FORECAST', params: { latitude: 25.6, longitude: 100.2 } });
262
+ assert.equal(itp4.trace.attempts, 2, '瞬时失败重试第 2 次成功(免费源上限 2)');
263
+ assert.equal(itp4.trace.backoffMs, 400, '回退按 API 策略记账');
264
+ assert.ok(itp4.result.ok, '成功 observation 原样透传');
265
+ assert.match(itp4.trace.evidence[0] ?? '', /attempts=2 backoff=400ms breaker=closed/, 'trace 证据行');
266
+ console.log('4. 生产解译器×瞬时失败:重试后成功 + trace 记账 OK');
267
+ // ---------------------------------------------------------------------------
268
+ // 5. FlyAI×Sentinel:不重试,但连续失败计入熔断
269
+ // ---------------------------------------------------------------------------
270
+ // A failure observation is not evidence of an outage. Business/auth/input/
271
+ // malformed failures stay visible without opening or retrying the breaker.
272
+ for (const result of [
273
+ { verdict: 'error', error: 'upstream business status 1001', upstreamHealth: 'unknown' },
274
+ { verdict: 'auth-error', upstreamHealth: 'unknown' },
275
+ { verdict: 'forbidden', upstreamHealth: 'unknown' },
276
+ { verdict: 'needs-setup', upstreamHealth: 'unknown' },
277
+ { verdict: 'cancelled', localTermination: 'signal', upstreamHealth: 'unknown' },
278
+ { verdict: 'error', error: 'malformed itemList', upstreamHealth: 'unknown' },
279
+ { verdict: 'error', error: 'invalid argument', upstreamHealth: 'unknown' },
280
+ { verdict: 'error', error: 'unclassified legacy failure' },
281
+ ]) {
282
+ let calls = 0;
283
+ const neutral = makeProductionInterpreter({
284
+ breakers: new Map(), sleep: sleep0,
285
+ handlers: { FLYAI_SEARCH: async () => { calls += 1; return { ok: false, via: 'flyai-error', kind: 'flight', latencyMs: 1, evidence: 'fixture failure', retryable: false, ...result }; } },
286
+ });
287
+ for (let attempt = 0; attempt < 4; attempt += 1) {
288
+ const out = await neutral({ effect: 'FLYAI_SEARCH', params: { kind: 'flight', origin: '北京' } });
289
+ assert.equal(out.trace.declined, undefined, `${result.verdict}/${result.error ?? ''} must not open breaker`);
290
+ assert.equal(out.trace.attempts, 1, 'neutral failure must not retry');
291
+ assert.equal(out.result.ok, false, 'neutral failure stays a visible failure');
292
+ }
293
+ assert.equal(calls, 4, 'all neutral requests reach the adapter');
294
+ }
295
+ console.log('5a. FlyAI business/auth/setup/input/malformed/cancelled failures are breaker-neutral OK');
296
+ const streakResults = [
297
+ { ok: false, upstreamHealth: 'unhealthy', retryable: false },
298
+ { ok: false, upstreamHealth: 'unknown', retryable: false },
299
+ { ok: false, upstreamHealth: 'unhealthy', retryable: false },
300
+ { ok: false, upstreamHealth: 'unhealthy', retryable: false },
301
+ ];
302
+ const streakInterpreter = makeProductionInterpreter({
303
+ breakers: new Map(), sleep: sleep0,
304
+ handlers: { FLYAI_SEARCH: async () => streakResults.shift() },
305
+ });
306
+ for (let i = 0; i < 4; i += 1)
307
+ await streakInterpreter({ effect: 'FLYAI_SEARCH', params: { kind: 'flight' } });
308
+ const streakBlocked = await streakInterpreter({ effect: 'FLYAI_SEARCH', params: { kind: 'flight' } });
309
+ assert.equal(streakBlocked.trace.declined, 'circuit-open', 'neutral failure cannot falsely reset consecutive upstream faults');
310
+ const sentinelResult = {
311
+ ok: false, via: 'flyai-error', verdict: 'error', kind: 'flight', latencyMs: 5,
312
+ upstreamHealth: 'unhealthy', retryable: false,
313
+ evidence: '[实时API:flyai@error@ts] parse failed(SentinelBlockException)',
314
+ error: 'failed to parse flyai output as JSON (incomplete FlyAI JSON object): {"message":"SentinelBlockException"}',
315
+ };
316
+ let sentinelCalls = 0;
317
+ const flyaiBreakers = new Map();
318
+ const ipS = makeProductionInterpreter({
319
+ sleep: sleep0,
320
+ breakers: flyaiBreakers,
321
+ now,
322
+ handlers: { FLYAI_SEARCH: async () => { sentinelCalls += 1; return structuredClone(sentinelResult); } },
323
+ });
324
+ const q = { kind: 'flight', origin: '上海', destination: '丽江', depDate: '2026-10-01' };
325
+ await ipS({ effect: 'FLYAI_SEARCH', params: q });
326
+ await ipS({ effect: 'FLYAI_SEARCH', params: q });
327
+ assert.equal(sentinelCalls, 2, '每次调用只打一次上游(Sentinel 不重试)');
328
+ const itp3 = await ipS({ effect: 'FLYAI_SEARCH', params: q });
329
+ assert.equal(itp3.trace.attempts, 1);
330
+ assert.equal(itp3.trace.breaker, 'open', '连续 3 次失败(FlyAI 阈值 3)→ 断路器开启');
331
+ assert.equal(sentinelCalls, 3);
332
+ const itp4th = await ipS({ effect: 'FLYAI_SEARCH', params: q });
333
+ assert.equal(itp4th.trace.declined, 'circuit-open', '第 4 次:熔断中零执行成本拒绝');
334
+ assert.equal(itp4th.result, null);
335
+ assert.equal(sentinelCalls, 3, '熔断后不再打上游');
336
+ assert.equal(itp4th.trace.retryAfterMs, 60_000, 'open rejection exposes bounded remaining cooldown');
337
+ clock += 1_000;
338
+ const cooling = await ipS({ effect: 'FLYAI_SEARCH', params: q });
339
+ assert.equal(cooling.trace.retryAfterMs, 59_000, 'cooldown decreases with elapsed time');
340
+ const coolingObservation = declinedObservation('FLYAI_SEARCH', cooling.trace);
341
+ assert.equal(coolingObservation.retryAfterMs, 59_000, 'flat failure preserves actionable cooldown');
342
+ assert.match(coolingObservation.summary, /59/, 'user feedback includes remaining seconds');
343
+ assert.doesNotMatch(coolingObservation.summary, /不支持|unsupported|无航班/, 'outage is not route/inventory evidence');
344
+ const hotelIndependent = await ipS({ effect: 'FLYAI_SEARCH', params: { kind: 'hotel', destName: '北京' } });
345
+ assert.equal(hotelIndependent.trace.attempts, 1, 'flight breaker cannot disable hotel searches');
346
+ process.env.FLYAI_API_KEY = 'corrected-fixture-key';
347
+ const correctedKey = await ipS({ effect: 'FLYAI_SEARCH', params: q });
348
+ assert.equal(correctedKey.trace.attempts, 1, 'corrected key starts an independent breaker');
349
+ process.env.FLYAI_API_KEY = 'effect-fixture-key';
350
+ process.env.DEBUG_FLYAI_MCP_URL = 'https://flyai-fixture.invalid/mcp?token=changed-secret';
351
+ const changedEndpoint = await ipS({ effect: 'FLYAI_SEARCH', params: q });
352
+ assert.equal(changedEndpoint.trace.attempts, 1, 'endpoint query changes start an independent breaker');
353
+ const scopeText = JSON.stringify([...flyaiBreakers.keys()]) + JSON.stringify(changedEndpoint.trace);
354
+ assert.doesNotMatch(scopeText, /effect-fixture-key|corrected-fixture-key|changed-secret|https:/, 'breaker keys and traces contain no credential/endpoint plaintext');
355
+ process.env.DEBUG_FLYAI_MCP_URL = 'https://flyai-fixture.invalid/mcp';
356
+ console.log('5. FlyAI×Sentinel:永不重试 + 连环失败触发熔断保护配额 OK');
357
+ // #517: adapter-owned local termination is explicit terminal state. The
358
+ // effect policy must honor retryable=false even when a stale timeout verdict is
359
+ // present, while a classified transient result still receives one retry.
360
+ let localDeadlineCalls = 0;
361
+ const localDeadline = makeProductionInterpreter({
362
+ breakers: new Map(), sleep: sleep0,
363
+ handlers: {
364
+ FLYAI_SEARCH: async () => {
365
+ localDeadlineCalls += 1;
366
+ return { ok: false, via: 'flyai-error', verdict: 'timeout', localTermination: 'deadline', retryable: false, kind: 'flight', latencyMs: 1, evidence: 'local deadline' };
367
+ },
286
368
  },
287
- },
288
- });
289
- const localDeadlineOutcome = await localDeadline({ effect: 'FLYAI_SEARCH', params: q });
290
- assert.equal(localDeadlineOutcome.trace.attempts, 1, 'local deadline 明确 terminal 只能一次');
291
- assert.equal(localDeadlineCalls, 1);
292
- let classifiedTransientCalls = 0;
293
- const classifiedTransient = makeProductionInterpreter({
294
- breakers: new Map(), sleep: sleep0,
295
- handlers: {
296
- FLYAI_SEARCH: async () => {
297
- classifiedTransientCalls += 1;
298
- return classifiedTransientCalls === 1
299
- ? { ok: false, via: 'flyai-error', verdict: 'error', retryable: true, kind: 'flight', latencyMs: 1, evidence: 'HTTP 500' }
300
- : { ok: true, via: 'flyai', verdict: 'miss', kind: 'flight', latencyMs: 1, evidence: 'empty itemList', options: [] };
369
+ });
370
+ const localDeadlineOutcome = await localDeadline({ effect: 'FLYAI_SEARCH', params: q });
371
+ assert.equal(localDeadlineOutcome.trace.attempts, 1, 'local deadline 明确 terminal 只能一次');
372
+ assert.equal(localDeadlineCalls, 1);
373
+ let classifiedTransientCalls = 0;
374
+ const classifiedTransient = makeProductionInterpreter({
375
+ breakers: new Map(), sleep: sleep0,
376
+ handlers: {
377
+ FLYAI_SEARCH: async () => {
378
+ classifiedTransientCalls += 1;
379
+ return classifiedTransientCalls === 1
380
+ ? { ok: false, via: 'flyai-error', verdict: 'error', retryable: true, upstreamHealth: 'unhealthy', kind: 'flight', latencyMs: 1, evidence: 'HTTP 500' }
381
+ : { ok: true, via: 'flyai', verdict: 'miss', upstreamHealth: 'healthy', kind: 'flight', latencyMs: 1, evidence: 'empty itemList', options: [] };
382
+ },
301
383
  },
302
- },
303
- });
304
- const classifiedTransientOutcome = await classifiedTransient({ effect: 'FLYAI_SEARCH', params: q });
305
- assert.equal(classifiedTransientOutcome.trace.attempts, 2, 'HTTP500 retryable=true 应重试一次');
306
- assert.equal(classifiedTransientCalls, 2);
307
- console.log('5b. FlyAI retry policy 只接受显式 retryable=true,本地终止/timeout=false 单次 OK');
308
- // ---------------------------------------------------------------------------
309
- // 6. 断路拒绝面 + 冷却后 half-open 单探测成功恢复 closed
310
- // ---------------------------------------------------------------------------
311
- const declined = declinedObservation('FLYAI_SEARCH', itp4th.trace);
312
- assert.equal(declined.ok, false);
313
- assert.match(declined.summary, /断路器开启/, '拒绝面指引「不要立即重试」,不伪装成 miss');
314
- clock += 120_000; // 冷却满(FlyAI 60s)→ half-open
315
- const ipOk = makeProductionInterpreter({
316
- sleep: sleep0, breakers: flyaiBreakers, now, // 复用同一断路器 Map——状态在 Map 里存活
317
- handlers: { FLYAI_SEARCH: async () => ({ ok: true, via: 'flyai', verdict: 'hit', kind: 'flight', latencyMs: 1, evidence: 'e', options: [] }) },
318
- });
319
- const itp6 = await ipOk({ effect: 'FLYAI_SEARCH', params: { kind: 'flight' } });
320
- assert.equal(itp6.trace.breaker, 'closed', 'half-open 单探测成功 → closed 恢复');
321
- assert.ok(itp6.result != null && !itp6.trace.declined);
322
- console.log('6. 断路拒绝面(结构化/零执行)+ 冷却后单探测恢复 closed OK');
323
- // ---------------------------------------------------------------------------
324
- // 7. mock 解译器:夹具回放确定性(CI 无网)
325
- // ---------------------------------------------------------------------------
326
- const mock = makeMockInterpreter({
327
- FLYAI_SEARCH: { ok: true, via: 'flyai', verdict: 'hit', kind: 'flight', latencyMs: 0, evidence: '[实时API:flyai@2026-08-29T00:00:00Z] 2/2 flight options', options: [{ no: '9C6617', name: '吉祥航空', depDateTime: '2026-10-01 07:55', arrDateTime: '2026-10-01 11:20', depStation: '浦东T2', arrStation: '丽江三义', durationMin: 205, price: 580 }] },
328
- });
329
- const mockFx = { effect: 'FLYAI_SEARCH', params: { kind: 'flight', origin: '上海', destination: '丽江', depDate: '2026-10-01' } };
330
- const mockA = await mock(mockFx);
331
- const mockB = await mock(mockFx);
332
- assert.deepEqual(mockA.result, mockB.result, '同一夹具两次回放同值');
333
- assert.equal(mockA.result.verdict, 'hit');
334
- assert.match(mockA.trace.evidence[0] ?? '', /\[效应:mock@.*夹具回放/, 'mock trace 标注');
335
- const mockMiss = await mock({ effect: 'WEATHER_FORECAST', params: {} });
336
- assert.equal(mockMiss.trace.declined, 'unknown-effect', 'mock 未登记夹具 = 拒绝面');
337
- console.log('7. mock 解译器(夹具回放/未登记拒绝/确定性)OK');
338
- // ---------------------------------------------------------------------------
339
- // 8. SESSION 通道策略:永不重试、不熔断(风控红线),一次调用一次透传
340
- // ---------------------------------------------------------------------------
341
- let sessionCalls = 0;
342
- const ipSess = makeProductionInterpreter({
343
- sleep: sleep0,
344
- breakers: new Map(),
345
- handlers: {
346
- SESSION_FLIGHT_SEARCH: async () => {
347
- sessionCalls += 1;
348
- return { ok: false, via: 'session-ctrip-flight-error', verdict: 'challenged', evidence: '[会话:ctrip-flight@error@ts] 风控命中', latencyMs: 9, error: 'challenge' };
384
+ });
385
+ const classifiedTransientOutcome = await classifiedTransient({ effect: 'FLYAI_SEARCH', params: q });
386
+ assert.equal(classifiedTransientOutcome.trace.attempts, 2, 'HTTP500 retryable=true 应重试一次');
387
+ assert.equal(classifiedTransientCalls, 2);
388
+ console.log('5b. FlyAI retry policy 只接受显式 retryable=true,本地终止/timeout=false 单次 OK');
389
+ // ---------------------------------------------------------------------------
390
+ // 6. 断路拒绝面 + 冷却后 half-open 单探测成功恢复 closed
391
+ // ---------------------------------------------------------------------------
392
+ const declined = declinedObservation('FLYAI_SEARCH', itp4th.trace);
393
+ assert.equal(declined.ok, false);
394
+ assert.match(declined.summary, /断路器开启/, '拒绝面指引「不要立即重试」,不伪装成 miss');
395
+ clock += 120_000; // 冷却满(FlyAI 60s)→ half-open
396
+ const neutralProbe = makeProductionInterpreter({
397
+ sleep: sleep0, breakers: flyaiBreakers, now,
398
+ handlers: { FLYAI_SEARCH: async () => ({ ok: false, verdict: 'error', upstreamHealth: 'unknown', retryable: false, evidence: 'unknown business failure' }) },
399
+ });
400
+ const neutralProbeOutcome = await neutralProbe({ effect: 'FLYAI_SEARCH', params: q });
401
+ assert.equal(neutralProbeOutcome.trace.breaker, 'half-open', 'neutral probe cannot claim upstream recovery');
402
+ const ipOk = makeProductionInterpreter({
403
+ sleep: sleep0, breakers: flyaiBreakers, now, // 复用同一断路器 Map——状态在 Map 里存活
404
+ handlers: { FLYAI_SEARCH: async () => ({ ok: true, via: 'flyai', verdict: 'miss', upstreamHealth: 'healthy', kind: 'flight', latencyMs: 1, evidence: 'e', options: [] }) },
405
+ });
406
+ const itp6 = await ipOk({ effect: 'FLYAI_SEARCH', params: { kind: 'flight' } });
407
+ assert.equal(itp6.trace.breaker, 'closed', 'half-open 单探测成功 → closed 恢复');
408
+ assert.ok(itp6.result != null && !itp6.trace.declined);
409
+ assert.equal(itp6.result.verdict, 'miss', 'valid empty response recovers the breaker without inventing inventory');
410
+ console.log('6. 断路拒绝面(结构化/零执行)+ 冷却后单探测恢复 closed OK');
411
+ // ---------------------------------------------------------------------------
412
+ // 7. mock 解译器:夹具回放确定性(CI 无网)
413
+ // ---------------------------------------------------------------------------
414
+ const mock = makeMockInterpreter({
415
+ FLYAI_SEARCH: { ok: true, via: 'flyai', verdict: 'hit', kind: 'flight', latencyMs: 0, evidence: '[实时API:flyai@2026-08-29T00:00:00Z] 2/2 flight options', options: [{ no: '9C6617', name: '吉祥航空', depDateTime: '2026-10-01 07:55', arrDateTime: '2026-10-01 11:20', depStation: '浦东T2', arrStation: '丽江三义', durationMin: 205, price: 580 }] },
416
+ });
417
+ const mockFx = { effect: 'FLYAI_SEARCH', params: { kind: 'flight', origin: '上海', destination: '丽江', depDate: '2026-10-01' } };
418
+ const mockA = await mock(mockFx);
419
+ const mockB = await mock(mockFx);
420
+ assert.deepEqual(mockA.result, mockB.result, '同一夹具两次回放同值');
421
+ assert.equal(mockA.result.verdict, 'hit');
422
+ assert.match(mockA.trace.evidence[0] ?? '', /\[效应:mock@.*夹具回放/, 'mock trace 标注');
423
+ const mockMiss = await mock({ effect: 'WEATHER_FORECAST', params: {} });
424
+ assert.equal(mockMiss.trace.declined, 'unknown-effect', 'mock 未登记夹具 = 拒绝面');
425
+ console.log('7. mock 解译器(夹具回放/未登记拒绝/确定性)OK');
426
+ // ---------------------------------------------------------------------------
427
+ // 8. SESSION 通道策略:永不重试、不熔断(风控红线),一次调用一次透传
428
+ // ---------------------------------------------------------------------------
429
+ let sessionCalls = 0;
430
+ const ipSess = makeProductionInterpreter({
431
+ sleep: sleep0,
432
+ breakers: new Map(),
433
+ handlers: {
434
+ SESSION_FLIGHT_SEARCH: async () => {
435
+ sessionCalls += 1;
436
+ return { ok: false, via: 'session-ctrip-flight-error', verdict: 'challenged', evidence: '[会话:ctrip-flight@error@ts] 风控命中', latencyMs: 9, error: 'challenge' };
437
+ },
349
438
  },
350
- },
351
- });
352
- const sessQ = { from: '上海', to: '丽江', date: '2026-10-01' };
353
- const itp8 = await ipSess({ effect: 'SESSION_FLIGHT_SEARCH', params: sessQ });
354
- assert.equal(itp8.result.verdict, 'challenged', '渠道 verdict 原样透传');
355
- assert.equal(itp8.trace.attempts, 1, 'SESSION 永不重试(挑战=红线)');
356
- assert.equal(itp8.trace.breaker, 'off', 'SESSION 不参与断路器');
357
- for (let i = 0; i < 5; i++)
358
- await ipSess({ effect: 'SESSION_FLIGHT_SEARCH', params: sessQ });
359
- const itp8z = await ipSess({ effect: 'SESSION_FLIGHT_SEARCH', params: sessQ });
360
- assert.ok(itp8z.trace.breaker === 'off' && !itp8z.trace.declined, 'SESSION 无断路拒绝(治理在节律闸与授权闸)');
361
- assert.equal(sessionCalls, 7);
362
- console.log('8. SESSION 通道策略(永不重试/不熔断/verdict 透传)OK');
363
- // ---------------------------------------------------------------------------
364
- // 9. 真实 handler 离线冒烟:hbcli 显式不可达 → 静态包降级(永不抛错,零网络)
365
- // ---------------------------------------------------------------------------
366
- __resetEffectBreakersForTest();
367
- const tmp = await mkdtemp(join(tmpdir(), 'effect-test-'));
368
- const itp9 = await interp({
369
- effect: 'HBCLI_HOTEL_SEARCH',
370
- // 显式不存在的自定义 bin:hbcliBinCandidates 对自定义名不回退已知安装位 → ENOENT → 静态包降级
371
- params: { destination: '大理', hbcliBin: join(tmp, 'no-such-hbcli'), fallbackPath: join(import.meta.dirname, '..', '..', 'data', 'hotels_2026.json') },
372
- });
373
- const hb9 = itp9.result;
374
- assert.ok(hb9 != null, '真实 handler 降级仍返回 observation');
375
- assert.ok(typeof hb9.summary === 'string' && hb9.summary.length > 0, '降级带人话 summary');
376
- assert.ok(itp9.trace.evidence[0]?.startsWith('[效应:HBCLI_HOTEL_SEARCH@'), '解译层证据行');
377
- await rm(tmp, { recursive: true, force: true });
378
- console.log(`9. 真实 handler 离线冒烟(HBCLI 静态包降级,summary=${(hb9.summary ?? '').slice(0, 40)}…)OK`);
379
- // ---------------------------------------------------------------------------
380
- // 10. M0 预订链读效应:HBCLI_HOTEL_RATES / HBCLI_CHECK_AVAIL
381
- // (注入 handler 验策略面;真实 handler 离线冒烟验价格面 fail-closed;mock 夹具)
382
- // ---------------------------------------------------------------------------
383
- // 10a. 注册进生产解译器:注入 handler 派发 / 永不重试 / 失败计熔断 / 三连错开闸
384
- let ratesDispatches = 0;
385
- const ipM0 = makeProductionInterpreter({
386
- sleep: sleep0,
387
- breakers: new Map(),
388
- handlers: {
389
- HBCLI_HOTEL_RATES: async () => {
390
- ratesDispatches += 1;
391
- return { via: 'hbcli-realtime', exitCode: 0, result: { rooms: [] }, evidence: '[实时API:hbcli@ts]', latencyMs: 1, rates: { rooms: [] }, summary: 'ok' };
439
+ });
440
+ const sessQ = { from: '上海', to: '丽江', date: '2026-10-01' };
441
+ const itp8 = await ipSess({ effect: 'SESSION_FLIGHT_SEARCH', params: sessQ });
442
+ assert.equal(itp8.result.verdict, 'challenged', '渠道 verdict 原样透传');
443
+ assert.equal(itp8.trace.attempts, 1, 'SESSION 永不重试(挑战=红线)');
444
+ assert.equal(itp8.trace.breaker, 'off', 'SESSION 不参与断路器');
445
+ for (let i = 0; i < 5; i++)
446
+ await ipSess({ effect: 'SESSION_FLIGHT_SEARCH', params: sessQ });
447
+ const itp8z = await ipSess({ effect: 'SESSION_FLIGHT_SEARCH', params: sessQ });
448
+ assert.ok(itp8z.trace.breaker === 'off' && !itp8z.trace.declined, 'SESSION 无断路拒绝(治理在节律闸与授权闸)');
449
+ assert.equal(sessionCalls, 7);
450
+ console.log('8. SESSION 通道策略(永不重试/不熔断/verdict 透传)OK');
451
+ // ---------------------------------------------------------------------------
452
+ // 9. 真实 handler 离线冒烟:hbcli 显式不可达 → 静态包降级(永不抛错,零网络)
453
+ // ---------------------------------------------------------------------------
454
+ __resetEffectBreakersForTest();
455
+ const tmp = await mkdtemp(join(tmpdir(), 'effect-test-'));
456
+ const itp9 = await interp({
457
+ effect: 'HBCLI_HOTEL_SEARCH',
458
+ // 显式不存在的自定义 bin:hbcliBinCandidates 对自定义名不回退已知安装位 → ENOENT → 静态包降级
459
+ params: { destination: '大理', hbcliBin: join(tmp, 'no-such-hbcli'), fallbackPath: join(import.meta.dirname, '..', '..', 'data', 'hotels_2026.json') },
460
+ });
461
+ const hb9 = itp9.result;
462
+ assert.ok(hb9 != null, '真实 handler 降级仍返回 observation');
463
+ assert.ok(typeof hb9.summary === 'string' && hb9.summary.length > 0, '降级带人话 summary');
464
+ assert.ok(itp9.trace.evidence[0]?.startsWith('[效应:HBCLI_HOTEL_SEARCH@'), '解译层证据行');
465
+ await rm(tmp, { recursive: true, force: true });
466
+ console.log(`9. 真实 handler 离线冒烟(HBCLI 静态包降级,summary=${(hb9.summary ?? '').slice(0, 40)}…)OK`);
467
+ // ---------------------------------------------------------------------------
468
+ // 10. M0 预订链读效应:HBCLI_HOTEL_RATES / HBCLI_CHECK_AVAIL
469
+ // (注入 handler 验策略面;真实 handler 离线冒烟验价格面 fail-closed;mock 夹具)
470
+ // ---------------------------------------------------------------------------
471
+ // 10a. 注册进生产解译器:注入 handler 派发 / 永不重试 / 失败计熔断 / 三连错开闸
472
+ let ratesDispatches = 0;
473
+ const ipM0 = makeProductionInterpreter({
474
+ sleep: sleep0,
475
+ breakers: new Map(),
476
+ handlers: {
477
+ HBCLI_HOTEL_RATES: async () => {
478
+ ratesDispatches += 1;
479
+ return { via: 'hbcli-realtime', exitCode: 0, result: { rooms: [] }, evidence: '[实时API:hbcli@ts]', latencyMs: 1, rates: { rooms: [] }, summary: 'ok' };
480
+ },
481
+ HBCLI_CHECK_AVAIL: async () => ({ via: 'hbcli-error', exitCode: 1, result: null, evidence: '[实时API:hbcli@error@ts]', latencyMs: 1, error: 'boom', avail: null, summary: 'err' }),
392
482
  },
393
- HBCLI_CHECK_AVAIL: async () => ({ via: 'hbcli-error', exitCode: 1, result: null, evidence: '[实时API:hbcli@error@ts]', latencyMs: 1, error: 'boom', avail: null, summary: 'err' }),
394
- },
395
- });
396
- const itp10 = await ipM0({ effect: 'HBCLI_HOTEL_RATES', params: { hotelId: '900000001', checkIn: '2026-09-05', checkOut: '2026-09-07', adults: 2 } });
397
- assert.equal(itp10.trace.attempts, 1, 'RATES 永不重试(候选路径切换不是重试)');
398
- assert.ok(itp10.result != null && !itp10.trace.declined, 'RATES 命中注入 handler,observation 透传');
399
- assert.ok(itp10.trace.evidence[0]?.startsWith('[效应:HBCLI_HOTEL_RATES@'), '解译层证据行');
400
- const itp10b = await ipM0({ effect: 'HBCLI_CHECK_AVAIL', params: { ratePkgId: 'rp-1' } });
401
- assert.equal(itp10b.result?.via, 'hbcli-error', '渠道失败观察原样透传(平铺失败面,isFailure 计熔断)');
402
- await ipM0({ effect: 'HBCLI_CHECK_AVAIL', params: { ratePkgId: 'rp-2' } });
403
- await ipM0({ effect: 'HBCLI_CHECK_AVAIL', params: { ratePkgId: 'rp-3' } });
404
- const itp10c = await ipM0({ effect: 'HBCLI_CHECK_AVAIL', params: { ratePkgId: 'rp-4' } });
405
- assert.equal(itp10c.trace.declined, 'circuit-open', '3 连错熔断保护(同 HBCLI 族策略)');
406
- assert.equal(ratesDispatches, 1, '成功通道不受失败通道熔断影响(per-effect 隔离)');
407
- console.log('10a. 预订链读效应注册(派发/永不重试/熔断族/per-effect 隔离)OK');
408
- // 10b. 真实 handler 离线冒烟:不可达 bin → 诚实失败,价格面无静态降级(fail-closed)
409
- __resetEffectBreakersForTest();
410
- const tmp10 = await mkdtemp(join(tmpdir(), 'effect-m0-'));
411
- const itp10d = await interp({
412
- effect: 'HBCLI_HOTEL_RATES',
413
- // 自定义不存在的 bin:候选路径不回退已知安装位 → ENOENT → 诚实失败(hotel-list 才有静态包降级)
414
- params: { hotelId: '900000001', hbcliBin: join(tmp10, 'no-such-hbcli') },
415
- });
416
- const hr10 = itp10d.result;
417
- assert.ok(hr10 != null, '不可达仍返回 observation(永不抛错)');
418
- assert.equal(hr10.via, 'hbcli-error', '未伪装成功');
419
- assert.match(hr10.summary ?? '', /不可用/, 'summary 明示不可用');
420
- assert.ok(!/静态包/.test(hr10.summary ?? '') || /无静态降级/.test(hr10.summary ?? ''), '价格面不降级到静态包');
421
- const itp10e = await interp({ effect: 'HBCLI_CHECK_AVAIL', params: { ratePkgId: 'rp-x', hbcliBin: join(tmp10, 'no-such-hbcli') } });
422
- assert.equal(itp10e.result?.via, 'hbcli-error', 'check-avail 同口径诚实失败');
423
- await rm(tmp10, { recursive: true, force: true });
424
- console.log('10b. 真实 handler 离线冒烟(价格面 fail-closed,无静态降级)OK');
425
- // 10c. mock 解译器夹具回放(预订链 CI 形态;录制形态 = 渠道 observation 原样)
426
- const mock10 = makeMockInterpreter({
427
- HBCLI_HOTEL_RATES: { via: 'hbcli-realtime', exitCode: 0, result: { rooms: [{ ratePkgId: 'rp-fx-1', roomTypeName: 'Deluxe King' }] }, evidence: '[实时API:hbcli@2026-08-30T00:00:00Z]', latencyMs: 1800, rates: { rooms: [{ ratePkgId: 'rp-fx-1', roomTypeName: 'Deluxe King' }] }, summary: 'hotel 900000001:hbcli 实时报价(2026-09-05 → 2026-09-07)' },
428
- HBCLI_CHECK_AVAIL: { via: 'hbcli-realtime', exitCode: 0, result: { ratePkgId: 'rp-fx-1', avail: true }, evidence: '[实时API:hbcli@2026-08-30T00:00:01Z]', latencyMs: 900, avail: { ratePkgId: 'rp-fx-1', avail: true }, summary: 'ratePkg rp-fx-1:hbcli 实时验价(库存与价格以后端为准)' },
429
- });
430
- const m10a = await mock10({ effect: 'HBCLI_HOTEL_RATES', params: { hotelId: '900000001' } });
431
- const m10b = await mock10({ effect: 'HBCLI_CHECK_AVAIL', params: { ratePkgId: 'rp-fx-1' } });
432
- assert.equal(m10a.result.rates?.rooms?.length, 1, 'RATES 夹具回放');
433
- assert.equal(m10b.result.avail?.avail, true, 'CHECK_AVAIL 夹具回放');
434
- assert.match(m10a.trace.evidence[0] ?? '', /\[效应:mock@.*夹具回放/, 'mock trace 标注');
435
- console.log('10c. mock 解译器夹具回放(预订链 CI 形态)OK');
436
- // ---------------------------------------------------------------------------
437
- // 11. HBCLI×timeout 冷启动重试(2026-09-02 迪拜 session 复盘:hotel-list 首调
438
- // 30s 超时,LLM 手动立即重试即成功——上游建后端 session 可超时;策略改为
439
- // 「仅 timeout 类重试 1 次」,ENOENT/退码类维持「切换不是重试」)
440
- // ---------------------------------------------------------------------------
441
- __resetEffectBreakersForTest();
442
- let flakyCalls = 0;
443
- const ipTimeout = makeProductionInterpreter({
444
- sleep: sleep0,
445
- breakers: new Map(),
446
- handlers: {
447
- HBCLI_HOTEL_SEARCH: async () => {
448
- flakyCalls += 1;
449
- if (flakyCalls === 1)
450
- return { via: 'hbcli-error', exitCode: -1, result: null, evidence: '[实时API:hbcli@timeout@ts]', latencyMs: 1, error: 'timeout after 30000ms' };
451
- return { via: 'hbcli-realtime', exitCode: 0, result: { hotels: [] }, evidence: '[实时API:hbcli@ts]', latencyMs: 1 };
483
+ });
484
+ const itp10 = await ipM0({ effect: 'HBCLI_HOTEL_RATES', params: { hotelId: '900000001', checkIn: '2026-09-05', checkOut: '2026-09-07', adults: 2 } });
485
+ assert.equal(itp10.trace.attempts, 1, 'RATES 永不重试(候选路径切换不是重试)');
486
+ assert.ok(itp10.result != null && !itp10.trace.declined, 'RATES 命中注入 handler,observation 透传');
487
+ assert.ok(itp10.trace.evidence[0]?.startsWith('[效应:HBCLI_HOTEL_RATES@'), '解译层证据行');
488
+ const itp10b = await ipM0({ effect: 'HBCLI_CHECK_AVAIL', params: { ratePkgId: 'rp-1' } });
489
+ assert.equal(itp10b.result?.via, 'hbcli-error', '渠道失败观察原样透传(平铺失败面,isFailure 计熔断)');
490
+ await ipM0({ effect: 'HBCLI_CHECK_AVAIL', params: { ratePkgId: 'rp-2' } });
491
+ await ipM0({ effect: 'HBCLI_CHECK_AVAIL', params: { ratePkgId: 'rp-3' } });
492
+ const itp10c = await ipM0({ effect: 'HBCLI_CHECK_AVAIL', params: { ratePkgId: 'rp-4' } });
493
+ assert.equal(itp10c.trace.declined, 'circuit-open', '3 连错熔断保护(同 HBCLI 族策略)');
494
+ assert.equal(ratesDispatches, 1, '成功通道不受失败通道熔断影响(per-effect 隔离)');
495
+ console.log('10a. 预订链读效应注册(派发/永不重试/熔断族/per-effect 隔离)OK');
496
+ // 10b. 真实 handler 离线冒烟:不可达 bin → 诚实失败,价格面无静态降级(fail-closed)
497
+ __resetEffectBreakersForTest();
498
+ const tmp10 = await mkdtemp(join(tmpdir(), 'effect-m0-'));
499
+ const itp10d = await interp({
500
+ effect: 'HBCLI_HOTEL_RATES',
501
+ // 自定义不存在的 bin:候选路径不回退已知安装位 → ENOENT → 诚实失败(hotel-list 才有静态包降级)
502
+ params: { hotelId: '900000001', hbcliBin: join(tmp10, 'no-such-hbcli') },
503
+ });
504
+ const hr10 = itp10d.result;
505
+ assert.ok(hr10 != null, '不可达仍返回 observation(永不抛错)');
506
+ assert.equal(hr10.via, 'hbcli-error', '未伪装成功');
507
+ assert.match(hr10.summary ?? '', /不可用/, 'summary 明示不可用');
508
+ assert.ok(!/静态包/.test(hr10.summary ?? '') || /无静态降级/.test(hr10.summary ?? ''), '价格面不降级到静态包');
509
+ const itp10e = await interp({ effect: 'HBCLI_CHECK_AVAIL', params: { ratePkgId: 'rp-x', hbcliBin: join(tmp10, 'no-such-hbcli') } });
510
+ assert.equal(itp10e.result?.via, 'hbcli-error', 'check-avail 同口径诚实失败');
511
+ await rm(tmp10, { recursive: true, force: true });
512
+ console.log('10b. 真实 handler 离线冒烟(价格面 fail-closed,无静态降级)OK');
513
+ // 10c. mock 解译器夹具回放(预订链 CI 形态;录制形态 = 渠道 observation 原样)
514
+ const mock10 = makeMockInterpreter({
515
+ HBCLI_HOTEL_RATES: { via: 'hbcli-realtime', exitCode: 0, result: { rooms: [{ ratePkgId: 'rp-fx-1', roomTypeName: 'Deluxe King' }] }, evidence: '[实时API:hbcli@2026-08-30T00:00:00Z]', latencyMs: 1800, rates: { rooms: [{ ratePkgId: 'rp-fx-1', roomTypeName: 'Deluxe King' }] }, summary: 'hotel 900000001:hbcli 实时报价(2026-09-05 → 2026-09-07)' },
516
+ HBCLI_CHECK_AVAIL: { via: 'hbcli-realtime', exitCode: 0, result: { ratePkgId: 'rp-fx-1', avail: true }, evidence: '[实时API:hbcli@2026-08-30T00:00:01Z]', latencyMs: 900, avail: { ratePkgId: 'rp-fx-1', avail: true }, summary: 'ratePkg rp-fx-1:hbcli 实时验价(库存与价格以后端为准)' },
517
+ });
518
+ const m10a = await mock10({ effect: 'HBCLI_HOTEL_RATES', params: { hotelId: '900000001' } });
519
+ const m10b = await mock10({ effect: 'HBCLI_CHECK_AVAIL', params: { ratePkgId: 'rp-fx-1' } });
520
+ assert.equal(m10a.result.rates?.rooms?.length, 1, 'RATES 夹具回放');
521
+ assert.equal(m10b.result.avail?.avail, true, 'CHECK_AVAIL 夹具回放');
522
+ assert.match(m10a.trace.evidence[0] ?? '', /\[效应:mock@.*夹具回放/, 'mock trace 标注');
523
+ console.log('10c. mock 解译器夹具回放(预订链 CI 形态)OK');
524
+ // ---------------------------------------------------------------------------
525
+ // 11. HBCLI×timeout 冷启动重试(2026-09-02 迪拜 session 复盘:hotel-list 首调
526
+ // 30s 超时,LLM 手动立即重试即成功——上游建后端 session 可超时;策略改为
527
+ // 「仅 timeout 类重试 1 次」,ENOENT/退码类维持「切换不是重试」)
528
+ // ---------------------------------------------------------------------------
529
+ __resetEffectBreakersForTest();
530
+ let flakyCalls = 0;
531
+ const ipTimeout = makeProductionInterpreter({
532
+ sleep: sleep0,
533
+ breakers: new Map(),
534
+ handlers: {
535
+ HBCLI_HOTEL_SEARCH: async () => {
536
+ flakyCalls += 1;
537
+ if (flakyCalls === 1)
538
+ return { via: 'hbcli-error', exitCode: -1, result: null, evidence: '[实时API:hbcli@timeout@ts]', latencyMs: 1, error: 'timeout after 30000ms' };
539
+ return { via: 'hbcli-realtime', exitCode: 0, result: { hotels: [] }, evidence: '[实时API:hbcli@ts]', latencyMs: 1 };
540
+ },
452
541
  },
453
- },
454
- });
455
- const itp11 = await ipTimeout({ effect: 'HBCLI_HOTEL_SEARCH', params: { destination: '迪拜' } });
456
- assert.equal(itp11.trace.attempts, 2, `timeout 重试一次(attempts=2),实际 ${itp11.trace.attempts}`);
457
- assert.equal(itp11.result?.via, 'hbcli-realtime', '重试后恢复实时');
458
- assert.equal(itp11.trace.breaker, 'closed', '恢复即成功,断路不计数');
459
- let hardCalls = 0;
460
- const ipEnoent = makeProductionInterpreter({
461
- sleep: sleep0,
462
- breakers: new Map(),
463
- handlers: {
464
- HBCLI_HOTEL_SEARCH: async () => {
465
- hardCalls += 1;
466
- return { via: 'hbcli-error', exitCode: -1, result: null, evidence: '[实时API:hbcli@spawn_error@ts]', latencyMs: 1, error: 'spawn hbcli ENOENT' };
542
+ });
543
+ const itp11 = await ipTimeout({ effect: 'HBCLI_HOTEL_SEARCH', params: { destination: '迪拜' } });
544
+ assert.equal(itp11.trace.attempts, 2, `timeout 重试一次(attempts=2),实际 ${itp11.trace.attempts}`);
545
+ assert.equal(itp11.result?.via, 'hbcli-realtime', '重试后恢复实时');
546
+ assert.equal(itp11.trace.breaker, 'closed', '恢复即成功,断路不计数');
547
+ let hardCalls = 0;
548
+ const ipEnoent = makeProductionInterpreter({
549
+ sleep: sleep0,
550
+ breakers: new Map(),
551
+ handlers: {
552
+ HBCLI_HOTEL_SEARCH: async () => {
553
+ hardCalls += 1;
554
+ return { via: 'hbcli-error', exitCode: -1, result: null, evidence: '[实时API:hbcli@spawn_error@ts]', latencyMs: 1, error: 'spawn hbcli ENOENT' };
555
+ },
467
556
  },
468
- },
469
- });
470
- const itp11b = await ipEnoent({ effect: 'HBCLI_HOTEL_SEARCH', params: { destination: '迪拜' } });
471
- assert.equal(itp11b.trace.attempts, 1, 'ENOENT 类永不重试(切换不是重试契约不变)');
472
- console.log('11. HBCLI×timeout 冷启动重试(timeout→attempts=2 恢复实时;ENOENT→attempts=1)OK');
473
- // ---------------------------------------------------------------------------
474
- // 12. D-23 收尾(issue #115):六渠道入注册表——策略表行存在 + 横切语义正确
475
- // (anything/web/github/video/agent_reach/session_login;没有策略表行就没有效应)
476
- // ---------------------------------------------------------------------------
477
- __resetEffectBreakersForTest();
478
- // 12a. 六效应注册在案:handler 注入 → 原样透传,trace.channel 与策略表一致
479
- const sixChannels = [
480
- ['ANYTHING_SEARCH', 'cli', { ok: true, via: 'hbcli-anything', verdict: 'hit', evidence: 'e', latencyMs: 1 }],
481
- ['WEB_READ', 'api', { ok: true, via: 'r.jina.ai', evidence: 'e', latencyMs: 1 }],
482
- ['GITHUB_SEARCH', 'cli', { ok: true, verdict: 'found', evidence: 'e', latencyMs: 1 }],
483
- ['VIDEO_SUBTITLE', 'cli', { ok: true, verdict: 'found', evidence: 'e', latencyMs: 1 }],
484
- ['AGENT_REACH', 'cli', { ok: true, verdict: 'found', channel: 'v2ex', method: 'read', evidence: 'e', latencyMs: 1 }],
485
- ['SESSION_LOGIN', 'browser', { ok: true, via: 'session-login', verdict: 'pending', evidence: 'e', latencyMs: 1 }],
486
- ];
487
- for (const [fx, channel, obs] of sixChannels) {
488
- const ip = makeProductionInterpreter({ sleep: sleep0, breakers: new Map(), handlers: { [fx]: async () => obs } });
489
- const out = await ip({ effect: fx, params: {} });
490
- assert.equal(out.result, obs, `${fx} 透传原样`);
491
- assert.equal(out.trace.channel, channel, `${fx} trace.channel=${channel}(策略表行在案)`);
492
- assert.equal(out.trace.attempts, 1, `${fx} 成功单次执行`);
557
+ });
558
+ const itp11b = await ipEnoent({ effect: 'HBCLI_HOTEL_SEARCH', params: { destination: '迪拜' } });
559
+ assert.equal(itp11b.trace.attempts, 1, 'ENOENT 类永不重试(切换不是重试契约不变)');
560
+ console.log('11. HBCLI×timeout 冷启动重试(timeout→attempts=2 恢复实时;ENOENT→attempts=1)OK');
561
+ // ---------------------------------------------------------------------------
562
+ // 12. D-23 收尾(issue #115):六渠道入注册表——策略表行存在 + 横切语义正确
563
+ // (anything/web/github/video/agent_reach/session_login;没有策略表行就没有效应)
564
+ // ---------------------------------------------------------------------------
565
+ __resetEffectBreakersForTest();
566
+ // 12a. 六效应注册在案:handler 注入 → 原样透传,trace.channel 与策略表一致
567
+ const sixChannels = [
568
+ ['ANYTHING_SEARCH', 'cli', { ok: true, via: 'hbcli-anything', verdict: 'hit', evidence: 'e', latencyMs: 1 }],
569
+ ['WEB_READ', 'api', { ok: true, via: 'r.jina.ai', evidence: 'e', latencyMs: 1 }],
570
+ ['GITHUB_SEARCH', 'cli', { ok: true, verdict: 'found', evidence: 'e', latencyMs: 1 }],
571
+ ['VIDEO_SUBTITLE', 'cli', { ok: true, verdict: 'found', evidence: 'e', latencyMs: 1 }],
572
+ ['AGENT_REACH', 'cli', { ok: true, verdict: 'found', channel: 'v2ex', method: 'read', evidence: 'e', latencyMs: 1 }],
573
+ ['SESSION_LOGIN', 'browser', { ok: true, via: 'session-login', verdict: 'pending', evidence: 'e', latencyMs: 1 }],
574
+ ];
575
+ for (const [fx, channel, obs] of sixChannels) {
576
+ const ip = makeProductionInterpreter({ sleep: sleep0, breakers: new Map(), handlers: { [fx]: async () => obs } });
577
+ const out = await ip({ effect: fx, params: {} });
578
+ assert.equal(out.result, obs, `${fx} 透传原样`);
579
+ assert.equal(out.trace.channel, channel, `${fx} trace.channel=${channel}(策略表行在案)`);
580
+ assert.equal(out.trace.attempts, 1, `${fx} 成功单次执行`);
581
+ }
582
+ console.log('12a. 六渠道入表(透传 + trace.channel 与策略表一致)OK');
583
+ // 12b. 永不重试族:AGENT_REACH / SESSION_LOGIN handler 恒抛错 → attempts=1(透传面不放大上游消耗)
584
+ for (const fx of ['AGENT_REACH', 'SESSION_LOGIN']) {
585
+ const ip = makeProductionInterpreter({ sleep: sleep0, breakers: new Map(), handlers: { [fx]: async () => { throw new Error('upstream says no'); } } });
586
+ const out = await ip({ effect: fx, params: {} });
587
+ assert.equal(out.result, null, `${fx} 抛错 → result=null`);
588
+ assert.equal(out.trace.attempts, 1, `${fx} 永不重试(attempts=1)`);
589
+ }
590
+ console.log('12b. AGENT_REACH/SESSION_LOGIN 永不重试(反射桥透传/浏览器风控红线)OK');
591
+ // 12c. WEB_READ 瞬时抖动重试 1 次(throw → 成功,attempts=2);GITHUB timeout 重试 / not-installed 不重试
592
+ __resetEffectBreakersForTest();
593
+ let webCalls = 0;
594
+ const ipWeb = makeProductionInterpreter({
595
+ sleep: sleep0, breakers: new Map(),
596
+ handlers: { WEB_READ: async () => { webCalls += 1; if (webCalls === 1)
597
+ throw new Error('socket hang up'); return { ok: true, via: 'r.jina.ai', evidence: 'e', latencyMs: 1 }; } },
598
+ });
599
+ const outWeb = await ipWeb({ effect: 'WEB_READ', params: { url: 'https://example.com' } });
600
+ assert.equal(outWeb.trace.attempts, 2, `WEB_READ 瞬时抖动重试 1 次,实际 ${outWeb.trace.attempts}`);
601
+ let ghTimeoutCalls = 0;
602
+ const ipGhTimeout = makeProductionInterpreter({
603
+ sleep: sleep0, breakers: new Map(),
604
+ handlers: { GITHUB_SEARCH: async () => { ghTimeoutCalls += 1; if (ghTimeoutCalls === 1)
605
+ return { ok: false, verdict: 'timeout', evidence: 'e', latencyMs: 1, stdout: '', stderr: '' }; return { ok: true, verdict: 'found', evidence: 'e', latencyMs: 1 }; } },
606
+ });
607
+ const outGhT = await ipGhTimeout({ effect: 'GITHUB_SEARCH', params: { query: 'x' } });
608
+ assert.equal(outGhT.trace.attempts, 2, 'GITHUB timeout 类重试 1 次(冷启动同 HBCLI 实况)');
609
+ let ghNiCalls = 0;
610
+ const ipGhNi = makeProductionInterpreter({
611
+ sleep: sleep0, breakers: new Map(),
612
+ handlers: { GITHUB_SEARCH: async () => { ghNiCalls += 1; return { ok: false, verdict: 'not-installed', evidence: 'e', latencyMs: 0 }; } },
613
+ });
614
+ const outGhNi = await ipGhNi({ effect: 'GITHUB_SEARCH', params: { query: 'x' } });
615
+ assert.equal(outGhNi.trace.attempts, 1, 'not-installed 是配置态(needs-setup 族)不重试');
616
+ console.log('12c. WEB_READ/GITHUB/VIDEO 策略行(timeout 重试/配置态不重试)OK');
617
+ // 12d. mock 面:新效应夹具回放 + 未登记夹具结构化拒绝(与既有效应同语义)
618
+ __resetEffectBreakersForTest();
619
+ const mockIp = makeMockInterpreter({ ANYTHING_SEARCH: { ok: true, via: 'hbcli-anything', verdict: 'hit', evidence: '[实时API:hbcli-anything@ts]', latencyMs: 1 } });
620
+ const outMock = await mockIp({ effect: 'ANYTHING_SEARCH', params: { keyword: '大理' } });
621
+ assert.equal(outMock.result.verdict, 'hit', 'mock 夹具回放原样');
622
+ assert.equal(outMock.trace.channel, 'mock', 'mock 通道标注');
623
+ const outMockBad = await mockIp({ effect: 'SESSION_LOGIN', params: {} });
624
+ assert.equal(outMockBad.result, null, '未登记夹具 → result=null');
625
+ assert.equal(outMockBad.trace.declined, 'unknown-effect', '未登记夹具 → 结构化拒绝');
626
+ console.log('12d. mock 夹具回放/未登记拒绝(新效应同语义)OK');
627
+ console.log('EFFECT INTERPRETER TESTS: 12/12 OK(effect_interpreter.v1:注册表封闭/退避链/断路三态/Sentinel 不重试/mock 夹具/SESSION 红线/真实降级/M0 预订链读效应/HBCLI timeout 重试/D-23 六渠道入表,纯离线)');
493
628
  }
494
- console.log('12a. 六渠道入表(透传 + trace.channel 与策略表一致)OK');
495
- // 12b. 永不重试族:AGENT_REACH / SESSION_LOGIN handler 恒抛错 → attempts=1(透传面不放大上游消耗)
496
- for (const fx of ['AGENT_REACH', 'SESSION_LOGIN']) {
497
- const ip = makeProductionInterpreter({ sleep: sleep0, breakers: new Map(), handlers: { [fx]: async () => { throw new Error('upstream says no'); } } });
498
- const out = await ip({ effect: fx, params: {} });
499
- assert.equal(out.result, null, `${fx} 抛错 → result=null`);
500
- assert.equal(out.trace.attempts, 1, `${fx} 永不重试(attempts=1)`);
629
+ finally {
630
+ for (const [name, value] of Object.entries(originalFlyaiEnv)) {
631
+ if (value === undefined)
632
+ delete process.env[name];
633
+ else
634
+ process.env[name] = value;
635
+ }
636
+ await rm(flyaiTestHome, { recursive: true, force: true });
501
637
  }
502
- console.log('12b. AGENT_REACH/SESSION_LOGIN 永不重试(反射桥透传/浏览器风控红线)OK');
503
- // 12c. WEB_READ 瞬时抖动重试 1 次(throw → 成功,attempts=2);GITHUB timeout 重试 / not-installed 不重试
504
- __resetEffectBreakersForTest();
505
- let webCalls = 0;
506
- const ipWeb = makeProductionInterpreter({
507
- sleep: sleep0, breakers: new Map(),
508
- handlers: { WEB_READ: async () => { webCalls += 1; if (webCalls === 1)
509
- throw new Error('socket hang up'); return { ok: true, via: 'r.jina.ai', evidence: 'e', latencyMs: 1 }; } },
510
- });
511
- const outWeb = await ipWeb({ effect: 'WEB_READ', params: { url: 'https://example.com' } });
512
- assert.equal(outWeb.trace.attempts, 2, `WEB_READ 瞬时抖动重试 1 次,实际 ${outWeb.trace.attempts}`);
513
- let ghTimeoutCalls = 0;
514
- const ipGhTimeout = makeProductionInterpreter({
515
- sleep: sleep0, breakers: new Map(),
516
- handlers: { GITHUB_SEARCH: async () => { ghTimeoutCalls += 1; if (ghTimeoutCalls === 1)
517
- return { ok: false, verdict: 'timeout', evidence: 'e', latencyMs: 1, stdout: '', stderr: '' }; return { ok: true, verdict: 'found', evidence: 'e', latencyMs: 1 }; } },
518
- });
519
- const outGhT = await ipGhTimeout({ effect: 'GITHUB_SEARCH', params: { query: 'x' } });
520
- assert.equal(outGhT.trace.attempts, 2, 'GITHUB timeout 类重试 1 次(冷启动同 HBCLI 实况)');
521
- let ghNiCalls = 0;
522
- const ipGhNi = makeProductionInterpreter({
523
- sleep: sleep0, breakers: new Map(),
524
- handlers: { GITHUB_SEARCH: async () => { ghNiCalls += 1; return { ok: false, verdict: 'not-installed', evidence: 'e', latencyMs: 0 }; } },
525
- });
526
- const outGhNi = await ipGhNi({ effect: 'GITHUB_SEARCH', params: { query: 'x' } });
527
- assert.equal(outGhNi.trace.attempts, 1, 'not-installed 是配置态(needs-setup 族)不重试');
528
- console.log('12c. WEB_READ/GITHUB/VIDEO 策略行(timeout 重试/配置态不重试)OK');
529
- // 12d. mock 面:新效应夹具回放 + 未登记夹具结构化拒绝(与既有效应同语义)
530
- __resetEffectBreakersForTest();
531
- const mockIp = makeMockInterpreter({ ANYTHING_SEARCH: { ok: true, via: 'hbcli-anything', verdict: 'hit', evidence: '[实时API:hbcli-anything@ts]', latencyMs: 1 } });
532
- const outMock = await mockIp({ effect: 'ANYTHING_SEARCH', params: { keyword: '大理' } });
533
- assert.equal(outMock.result.verdict, 'hit', 'mock 夹具回放原样');
534
- assert.equal(outMock.trace.channel, 'mock', 'mock 通道标注');
535
- const outMockBad = await mockIp({ effect: 'SESSION_LOGIN', params: {} });
536
- assert.equal(outMockBad.result, null, '未登记夹具 → result=null');
537
- assert.equal(outMockBad.trace.declined, 'unknown-effect', '未登记夹具 → 结构化拒绝');
538
- console.log('12d. mock 夹具回放/未登记拒绝(新效应同语义)OK');
539
- console.log('EFFECT INTERPRETER TESTS: 12/12 OK(effect_interpreter.v1:注册表封闭/退避链/断路三态/Sentinel 不重试/mock 夹具/SESSION 红线/真实降级/M0 预订链读效应/HBCLI timeout 重试/D-23 六渠道入表,纯离线)');