@sema-agent/server 7.107.0 → 7.108.0
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/MIGRATION.md +29 -0
- package/USAGE.md +36 -0
- package/dist/approval-ask-audit-store.d.ts +62 -4
- package/dist/approval-ask-audit-store.js +67 -11
- package/dist/approval.js +16 -13
- package/dist/boot/coordinators.d.ts +4 -0
- package/dist/boot/coordinators.js +3 -2
- package/dist/boot/shutdown.d.ts +8 -0
- package/dist/boot/shutdown.js +31 -8
- package/dist/boot/stage-06-runners.d.ts +1 -0
- package/dist/boot/stage-06-runners.js +6 -3
- package/dist/boot/stage-07-capability-layer.d.ts +1 -0
- package/dist/boot/stage-08-reapers.d.ts +1 -0
- package/dist/boot/stage-09-leader.d.ts +1 -0
- package/dist/boot/stage-10-http-server.d.ts +1 -0
- package/dist/boot/stage-10-http-server.js +3 -1
- package/dist/brain.js +2 -1
- package/dist/config-center/apply-effective.js +2 -0
- package/dist/config-center/types.d.ts +7 -0
- package/dist/deployment-governance.js +2 -1
- package/dist/fleet-client.d.ts +10 -2
- package/dist/fleet-client.js +14 -8
- package/dist/http/memory-compliance-failure-reply.d.ts +41 -0
- package/dist/http/memory-compliance-failure-reply.js +73 -0
- package/dist/http/memory-store-refusal-reply.d.ts +2 -2
- package/dist/http/resume-legs.js +3 -2
- package/dist/http/routes/diagnostics.js +2 -0
- package/dist/http/routes/memory-compliance.js +2 -34
- package/dist/http/routes/memory-origin.d.ts +1 -1
- package/dist/http/routes/sessions.js +3 -11
- package/dist/http/routes/side-query.js +7 -2
- package/dist/http/routes/tasks.js +2 -1
- package/dist/memory-store-refusal.d.ts +2 -1
- package/dist/model-entry-headers.d.ts +41 -0
- package/dist/model-entry-headers.js +61 -0
- package/dist/model-entry-refusal.d.ts +6 -0
- package/dist/model-entry-refusal.js +4 -0
- package/dist/model-reach-scope.d.ts +55 -0
- package/dist/model-reach-scope.js +16 -0
- package/dist/model-unreachable-notice.d.ts +38 -0
- package/dist/model-unreachable-notice.js +72 -0
- package/dist/observability/fail-open.d.ts +4 -0
- package/dist/observability/fail-open.js +4 -0
- package/dist/observability/run-terminal.d.ts +2 -1
- package/dist/outbound-dispatcher.d.ts +69 -0
- package/dist/outbound-dispatcher.js +123 -3
- package/dist/plugins/park-commit-watch.d.ts +48 -0
- package/dist/plugins/park-commit-watch.js +87 -0
- package/dist/router/route-orchestration.js +7 -2
- package/dist/runs.js +3 -2
- package/dist/runtime-governance.js +3 -2
- package/dist/task-settings.js +4 -2
- package/dist/tool-approval.d.ts +62 -0
- package/dist/tool-approval.js +67 -3
- package/dist/tool-rule-names.d.ts +25 -0
- package/dist/tool-rule-names.js +5 -0
- package/dist/trace/core-keyset-guard.d.ts +13 -1
- package/dist/trace/engine-notice-wire.d.ts +3 -1
- package/dist/trace/engine-notice-wire.js +2 -0
- package/dist/trace/project.d.ts +3 -1
- package/dist/trace/self-minted-wire.d.ts +71 -0
- package/dist/trace/self-minted-wire.js +28 -0
- package/package.json +1 -1
|
@@ -0,0 +1,61 @@
|
|
|
1
|
+
import { redactSecrets } from "./trace/redact.js";
|
|
2
|
+
export const CATALOG_ENTRY_HEADER_LANES = {
|
|
3
|
+
"anthropic-beta": ["anthropic-messages"],
|
|
4
|
+
};
|
|
5
|
+
export const CATALOG_ENTRY_HEADER_NAMES = Object.keys(CATALOG_ENTRY_HEADER_LANES);
|
|
6
|
+
export const MAX_HEADER_VALUE_CHARS = 1024;
|
|
7
|
+
const HEADER_VALUE_SHAPE = /^[\x21-\x7e](?:[\x20-\x7e]*[\x21-\x7e])?$/;
|
|
8
|
+
const HEADER_NAME_TOKEN = /^[A-Za-z0-9!#$%&'*+.^_`|~-]{1,64}$/;
|
|
9
|
+
export function displayHeaderName(name) {
|
|
10
|
+
return HEADER_NAME_TOKEN.test(name) && redactSecrets(name) === name ? name : "(unprintable header name)";
|
|
11
|
+
}
|
|
12
|
+
function isCatalogEntryHeaderName(name) {
|
|
13
|
+
return Object.hasOwn(CATALOG_ENTRY_HEADER_LANES, name);
|
|
14
|
+
}
|
|
15
|
+
export function readCatalogEntryHeaders(raw, api) {
|
|
16
|
+
if (raw === undefined)
|
|
17
|
+
return { ok: true, headers: undefined };
|
|
18
|
+
const proto = typeof raw === "object" && raw !== null ? Object.getPrototypeOf(raw) : undefined;
|
|
19
|
+
if (typeof raw !== "object" || raw === null || Array.isArray(raw) || (proto !== Object.prototype && proto !== null)) {
|
|
20
|
+
return { ok: false, fields: ["headers"], issue: `headers: expected a plain object of { <header name>: <value> } (allowed names: ${CATALOG_ENTRY_HEADER_NAMES.join(", ")})` };
|
|
21
|
+
}
|
|
22
|
+
const fields = [];
|
|
23
|
+
const issues = [];
|
|
24
|
+
const bag = {};
|
|
25
|
+
for (const name of Object.keys(raw)) {
|
|
26
|
+
const shown = displayHeaderName(name);
|
|
27
|
+
const field = `headers.${shown}`;
|
|
28
|
+
const value = Reflect.get(raw, name);
|
|
29
|
+
if (!isCatalogEntryHeaderName(name)) {
|
|
30
|
+
const canonical = CATALOG_ENTRY_HEADER_NAMES.find((n) => n === name.toLowerCase());
|
|
31
|
+
fields.push(field);
|
|
32
|
+
issues.push(canonical !== undefined
|
|
33
|
+
? `${field}: write the header name in its canonical spelling "${canonical}" (one spelling per header)`
|
|
34
|
+
: `${field}: not in the closed allowlist of catalog request headers (${CATALOG_ENTRY_HEADER_NAMES.join(", ")}); credentials ride sealedApiKey / apiKeyEnv, never a header`);
|
|
35
|
+
continue;
|
|
36
|
+
}
|
|
37
|
+
const lanes = CATALOG_ENTRY_HEADER_LANES[name];
|
|
38
|
+
if (typeof api !== "string" || !lanes.includes(api)) {
|
|
39
|
+
fields.push(field);
|
|
40
|
+
issues.push(`${field}: only read on the ${lanes.join(" / ")} lane — this entry ${typeof api === "string" ? `is api "${api}"` : "declares no api"}, where the header would be sent and do nothing`);
|
|
41
|
+
continue;
|
|
42
|
+
}
|
|
43
|
+
if (typeof value !== "string" || value.length > MAX_HEADER_VALUE_CHARS || !HEADER_VALUE_SHAPE.test(value)) {
|
|
44
|
+
fields.push(field);
|
|
45
|
+
issues.push(`${field}: the value must be a non-empty string of visible ASCII (spaces only between words, at most ${MAX_HEADER_VALUE_CHARS} characters)`);
|
|
46
|
+
continue;
|
|
47
|
+
}
|
|
48
|
+
bag[name] = value;
|
|
49
|
+
}
|
|
50
|
+
if (fields.length > 0)
|
|
51
|
+
return { ok: false, fields: [...new Set(fields)], issue: issues.join("; ") };
|
|
52
|
+
return { ok: true, headers: Object.keys(bag).length > 0 ? bag : undefined };
|
|
53
|
+
}
|
|
54
|
+
export function buildModelHeadersKey(entryName, raw, api) {
|
|
55
|
+
const verdict = readCatalogEntryHeaders(raw, api);
|
|
56
|
+
if (!verdict.ok) {
|
|
57
|
+
throw new Error(`catalog entry "${entryName}" reached the Model mint with unjudged headers (${verdict.fields.join(", ")}) — judgeCatalogEntry must refuse it first; this is a server defect, not a config error`);
|
|
58
|
+
}
|
|
59
|
+
return verdict.headers === undefined ? {} : { headers: { ...verdict.headers } };
|
|
60
|
+
}
|
|
61
|
+
//# sourceMappingURL=model-entry-headers.js.map
|
|
@@ -43,9 +43,15 @@ export interface CatalogEntryLike {
|
|
|
43
43
|
readonly name: string;
|
|
44
44
|
readonly id: string;
|
|
45
45
|
readonly provider: string;
|
|
46
|
+
/** S-931:线路词(`headers` 的线路判据读它;原值,不信这张型)。 */
|
|
47
|
+
readonly api?: unknown;
|
|
48
|
+
/** S-931:供应商附加头的承载形(`unknown`:判据在 `model-entry-headers.ts`,不信这张型)。 */
|
|
49
|
+
readonly headers?: unknown;
|
|
46
50
|
}
|
|
47
51
|
/**
|
|
48
52
|
* **逐条判据**:`undefined` = 本部署能完整服务它。判序即语义:先形(不合形的条目谈不上走哪条腿),再腿。
|
|
53
|
+
* 形分两段、同一个码(`config.model_entry_invalid`):settings-schema 的 `ModelEntry`,再是 S-931 的 `headers`(白名单 + 线路 + 值形,
|
|
54
|
+
* 判据单属主 `model-entry-headers.ts`;settings-schema 6.3.0 的 `ModelEntry` 还没有这个键 —— 候裁加字段,届时词表不另抄)。
|
|
49
55
|
*
|
|
50
56
|
* 形判的属主是 settings-schema 的 `ModelEntry`(两条读腿用的就是它)—— 本仓不手抄任何一条字段规则;这里只是
|
|
51
57
|
* 在第二道边界(绕过读腿的输入)上**再问一次同一个属主**,并把答案变成一条带码的拒因。只取 `success` 与 issue
|
|
@@ -1,6 +1,7 @@
|
|
|
1
1
|
import { CC_MODEL_TIER_ALIASES, DEFAULT_TIER_ORDER, expandTiers } from "@sema-agent/core";
|
|
2
2
|
import { ModelEntry, isReportableRefusalName } from "@sema-agent/settings-schema";
|
|
3
3
|
import { catalogBrainLane } from "./model-provider.js";
|
|
4
|
+
import { readCatalogEntryHeaders } from "./model-entry-headers.js";
|
|
4
5
|
import { describeSchemaIssueFields, describeSchemaIssues } from "./config-center/read-warnings.js";
|
|
5
6
|
import { DEFAULT_MODEL_REF } from "./model-select.js";
|
|
6
7
|
export const ENTRY_REFUSAL_REASON_OF = {
|
|
@@ -26,6 +27,9 @@ export function judgeCatalogEntry(m, ctx) {
|
|
|
26
27
|
const fields = describeSchemaIssueFields(shape.error);
|
|
27
28
|
return entryInvalidRefusal(typeof m.id === "string" ? m.id : "", typeof m.provider === "string" ? m.provider : "", fields, describeSchemaIssues(shape.error) ?? fields.join(", "));
|
|
28
29
|
}
|
|
30
|
+
const headers = readCatalogEntryHeaders(m.headers, m.api);
|
|
31
|
+
if (!headers.ok)
|
|
32
|
+
return entryInvalidRefusal(m.id, m.provider, headers.fields, headers.issue);
|
|
29
33
|
const lane = catalogBrainLane(m.provider);
|
|
30
34
|
const mount = laneMount(lane, ctx);
|
|
31
35
|
if (!mount.mounted) {
|
|
@@ -0,0 +1,55 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* S-925(7.108.0)—— 「这次模型调用的每一次出网尝试落在哪」的**异步作用域**(叶:只引 node 与类型)。
|
|
3
|
+
*
|
|
4
|
+
* 两层,一只 `AsyncLocalStorage`:
|
|
5
|
+
* · **腿层**(`withModelReachScope`,三条带用户面投递口的 run 腿各包一次:流式 `POST /v1/tasks/stream`、后台 `POST /v1/runs`、续跑):
|
|
6
|
+
* 带着这条腿的 `sessionId`(通告归属键)与本腿已经说过的目标集合(去重单位 = 每腿每目标一次);
|
|
7
|
+
* · **调用层**(`createModelReachBrain` 每次模型调用包一次):一只可变的 {@link ModelCallReach},出网观察者
|
|
8
|
+
* (`outbound-dispatcher.ts` 的 undici 诊断通道订阅)把这次调用发出的每一条请求的**结局**记在上面 —— 最后一条是
|
|
9
|
+
* 「连上了、有了回答」还是「建连就失败了」。
|
|
10
|
+
*
|
|
11
|
+
* 为什么是 ALS 而不是参数:模型调用的出网发生在 core 的 brain 里(`fetch` → 全局 dispatcher → undici),本仓在那条链上没有任何
|
|
12
|
+
* 参数座;undici 的 `undici:request:create` 通道在创建请求的那一拍同步发布、就在发起 `fetch` 的异步上下文里 —— 读 ALS 就知道
|
|
13
|
+
* 「这条请求属于哪一次模型调用」,不靠目标主机 + 时间去猜(那是把相关写成因果)。
|
|
14
|
+
* 作用域外(side-query、记忆整理、工具自己的出网 …)⇒ 没有腿层 ⇒ 只有运维日志,没有用户通告。
|
|
15
|
+
* 腿里的**辅助**模型调用(WebFetch 摘要器、压实、提示建议、分类器 …)与主循环**同一条规则**:通告说的是「这个模型端点够不着」,
|
|
16
|
+
* 不是「这条 run 失败了」—— 不设豁免名单(codex r1 / r2:豁免名单结构上开放,core 自己发起的辅助调用本仓包不到,列不全)。
|
|
17
|
+
*/
|
|
18
|
+
import { AsyncLocalStorage } from "node:async_hooks";
|
|
19
|
+
import type { OutboundConnectFailure } from "./outbound-dispatcher.js";
|
|
20
|
+
/** 一条请求的结局(观察者按到达顺序覆盖写;模型调用终态时读**最后一条**)。 */
|
|
21
|
+
export type ModelCallOutcome = {
|
|
22
|
+
readonly kind: "reached";
|
|
23
|
+
} | {
|
|
24
|
+
readonly kind: "connect_failed";
|
|
25
|
+
readonly failure: OutboundConnectFailure;
|
|
26
|
+
} | {
|
|
27
|
+
readonly kind: "other";
|
|
28
|
+
};
|
|
29
|
+
/** 一次模型调用的出网观察(可变:观察者写、装饰器读)。 */
|
|
30
|
+
export interface ModelCallReach {
|
|
31
|
+
last?: ModelCallOutcome;
|
|
32
|
+
}
|
|
33
|
+
/** 一条 run 腿的作用域。 */
|
|
34
|
+
export interface ModelReachLegScope {
|
|
35
|
+
readonly sessionId: string;
|
|
36
|
+
/** 本腿已经通告过的目标(`targetHost`)—— 去重单位 = 每腿每目标一次。 */
|
|
37
|
+
readonly announced: Set<string>;
|
|
38
|
+
}
|
|
39
|
+
export interface ModelReachStore {
|
|
40
|
+
readonly leg?: ModelReachLegScope;
|
|
41
|
+
readonly call?: ModelCallReach;
|
|
42
|
+
}
|
|
43
|
+
export declare const modelReachContext: AsyncLocalStorage<ModelReachStore>;
|
|
44
|
+
/**
|
|
45
|
+
* 腿层入口:每条腿都开**自己的**作用域 —— `sessionId` 缺席(无会话腿)⇒ 空作用域(没有归属键就没有用户面可投;也不继承外层腿的
|
|
46
|
+
* 归属键 —— codex r1 旁注:修前缺席即原样执行、会沿用外层腿)。嵌套时内层覆盖外层 —— 归属跟着「这一轮是谁的腿」走。
|
|
47
|
+
*/
|
|
48
|
+
export declare function withModelReachScope<T>(sessionId: string | undefined, fn: () => T): T;
|
|
49
|
+
/** 调用层入口(`createModelReachBrain` 用):在当前腿作用域(可能缺席)之上开一只新的调用观察。 */
|
|
50
|
+
export declare function withModelCallReach<T>(call: ModelCallReach, fn: () => T): T;
|
|
51
|
+
/** 观察者读口:当前异步上下文里的模型调用观察(作用域外 ⇒ `undefined`)。 */
|
|
52
|
+
export declare function currentModelCallReach(): ModelCallReach | undefined;
|
|
53
|
+
/** 装饰器读口:当前腿(作用域外 ⇒ `undefined`)。 */
|
|
54
|
+
export declare function currentModelReachLeg(): ModelReachLegScope | undefined;
|
|
55
|
+
//# sourceMappingURL=model-reach-scope.d.ts.map
|
|
@@ -0,0 +1,16 @@
|
|
|
1
|
+
import { AsyncLocalStorage } from "node:async_hooks";
|
|
2
|
+
export const modelReachContext = new AsyncLocalStorage();
|
|
3
|
+
export function withModelReachScope(sessionId, fn) {
|
|
4
|
+
return modelReachContext.run(sessionId === undefined || sessionId.length === 0 ? {} : { leg: { sessionId, announced: new Set() } }, fn);
|
|
5
|
+
}
|
|
6
|
+
export function withModelCallReach(call, fn) {
|
|
7
|
+
const outer = modelReachContext.getStore();
|
|
8
|
+
return modelReachContext.run({ ...(outer?.leg !== undefined ? { leg: outer.leg } : {}), call }, fn);
|
|
9
|
+
}
|
|
10
|
+
export function currentModelCallReach() {
|
|
11
|
+
return modelReachContext.getStore()?.call;
|
|
12
|
+
}
|
|
13
|
+
export function currentModelReachLeg() {
|
|
14
|
+
return modelReachContext.getStore()?.leg;
|
|
15
|
+
}
|
|
16
|
+
//# sourceMappingURL=model-reach-scope.js.map
|
|
@@ -0,0 +1,38 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* S-925(7.108.0;test [ref] ② 用户报案分诊,验收 `srv7108-s925-connect-timeout-diagnostic`)—— **模型调用因建连失败终态**时
|
|
3
|
+
* 给这条腿的用户铸的通告 `server.model_unreachable` 的**唯一铸点**,与把它挂到每一次模型调用上的 brain 装饰器。
|
|
4
|
+
*
|
|
5
|
+
* ## 病
|
|
6
|
+
* 运营用户看不到日志 / 诊断面:VPN 中途切换之后模型网关建连超时,用户只拿到一句 undici 原句(`fetch failed (connect …)`),
|
|
7
|
+
* 分不清是网络断了还是起服时读进的代理已经失效,也没有「查 VPN / 代理、代理变过就重启」这条出路。只落运维日志不算闭环
|
|
8
|
+
* (test 立案原话)。core 没有现成的「模型不可达」用户通告码(`ENGINE_NOTICE_CODES` 亲查零命中)⇒ 本仓自铸,走既有的
|
|
9
|
+
* `SERVER_NOTICE_AUDIENCE` 族(S-690 形:白名单 / 归属 / 消毒 / 隔离四件判据一个不绕),不开第二条通道。
|
|
10
|
+
*
|
|
11
|
+
* ## 判据(一句):**这一次模型调用的终局是 `stopReason: "error"`,且它发出的最后一条请求在建连时就失败了**
|
|
12
|
+
* 「最后一条请求落在哪」由出网观察者记在调用作用域上(`model-reach-scope.ts`,undici 诊断通道,见 `outbound-dispatcher.ts`)——
|
|
13
|
+
* 不按目标主机 + 时间去猜。于是:
|
|
14
|
+
* · 服务商回了 5xx / 4xx(收到了响应头)⇒ 最后一条是 `reached` ⇒ 不铸(那是服务商的回答);
|
|
15
|
+
* · 建连失败、重试之后连上了并成功 ⇒ 终局不是 error ⇒ 不铸;
|
|
16
|
+
* · 中途断流 / TLS / 主动取消 / 首字节超时 ⇒ 最后一条是 `other` ⇒ 不铸(判不出「够不着」)。
|
|
17
|
+
*
|
|
18
|
+
* ## 投递 / 去重
|
|
19
|
+
* 归属键 = 这条 run 腿的 `sessionId`(腿层作用域,三条带用户面投递口的腿各包一次)。作用域外的模型调用(side-query、记忆整理 …)
|
|
20
|
+
* 没有归属键 ⇒ 不铸(运维日志 `outbound_connect_failed` 照有)。腿里的**辅助**调用(WebFetch 摘要器、压实、提示建议、分类器 …)与
|
|
21
|
+
* 主循环**同一条规则**:本帧说的是「这个模型端点够不着」,不是「这条 run 失败了」(措辞如此,不暗示终态);不设豁免名单 —— codex r1 / r2
|
|
22
|
+
* 两轮证明豁免名单结构上开放(core 自己发起的辅助调用本仓包不到)。按 **run 终态**归属是另一种设计,候裁(收车档 ⑤)。去重单位 = **每腿每目标一次**(同一条腿里
|
|
23
|
+
* 重试 / 子代对同一个网关再失败,不再说一遍)。受众 user;detail = `{ code, targetHost, proxyInUse?, remedy, sessionId }` ——
|
|
24
|
+
* `proxyInUse` 只在这一跳走了代理时在场,且只是**变量名**(代理地址是基础设施细节,不上租户 wire,与 `OutboundProxyError` 的
|
|
25
|
+
* F3 裁定同一条)。`targetHost`(模型端点的 `host:port`)对 user 受众是**新披露的部署事实**(F7 裁定:它是部署自己声明的模型端点,
|
|
26
|
+
* 不是基础设施秘密,可以上;代理地址仍不上)—— 不要读成「终局句里本就有」:代理形下 core 终局句展开到代理根因、不含模型主机,
|
|
27
|
+
* 辅助调用形下 run 可 completed、租户面本无主机句。
|
|
28
|
+
*/
|
|
29
|
+
import type { Brain } from "@sema-agent/core";
|
|
30
|
+
import { type EngineNoticeRouter } from "./trace/engine-notice-wire.js";
|
|
31
|
+
/**
|
|
32
|
+
* `create*`:包在 `createBrain` 装好的那只 brain **最外层**(路由 / 切备 / 断路器 / 降级全在里面 ⇒「这次调用」= 调用方看到的那一次,
|
|
33
|
+
* 重试与切备都在同一个调用作用域里)。每次调用开一只新的调用观察;腿层作用域在**发起调用那一拍**读(之后的回调在别的上下文里)。
|
|
34
|
+
* 不改流、不改消息、不改错误:只在终局 promise 上挂一个观察。
|
|
35
|
+
* 形 = 同型 `Proxy`:表里 `observed` 的成员(内层有它才有)换成观察器;其余成员逐字转给内层、方法绑内层(`this` 不漂)。
|
|
36
|
+
*/
|
|
37
|
+
export declare function createModelReachBrain(inner: Brain, router?: EngineNoticeRouter): Brain;
|
|
38
|
+
//# sourceMappingURL=model-unreachable-notice.d.ts.map
|
|
@@ -0,0 +1,72 @@
|
|
|
1
|
+
import { currentModelReachLeg, withModelCallReach } from "./model-reach-scope.js";
|
|
2
|
+
import { defaultEngineNoticeRouter } from "./trace/engine-notice-wire.js";
|
|
3
|
+
const SERVER_MODEL_UNREACHABLE = "server.model_unreachable";
|
|
4
|
+
const MODEL_UNREACHABLE_REMEDY = "check VPN / proxy; restart if the proxy changed since boot";
|
|
5
|
+
function announceModelUnreachable(leg, failure, router = defaultEngineNoticeRouter) {
|
|
6
|
+
if (leg.announced.has(failure.targetHost))
|
|
7
|
+
return;
|
|
8
|
+
leg.announced.add(failure.targetHost);
|
|
9
|
+
const via = failure.proxyInUse !== null ? ` through the proxy from ${failure.proxyInUse}` : "";
|
|
10
|
+
router.route({
|
|
11
|
+
code: SERVER_MODEL_UNREACHABLE,
|
|
12
|
+
message: `The model endpoint could not be reached: the connection to ${failure.targetHost}${via} failed (${failure.code}). ` +
|
|
13
|
+
"Check the VPN / proxy; if the proxy changed since the server started, restart it (proxy settings are read once at startup).",
|
|
14
|
+
detail: {
|
|
15
|
+
code: failure.code,
|
|
16
|
+
targetHost: failure.targetHost,
|
|
17
|
+
...(failure.proxyInUse !== null ? { proxyInUse: failure.proxyInUse } : {}),
|
|
18
|
+
remedy: MODEL_UNREACHABLE_REMEDY,
|
|
19
|
+
sessionId: leg.sessionId,
|
|
20
|
+
},
|
|
21
|
+
sessionId: leg.sessionId,
|
|
22
|
+
});
|
|
23
|
+
}
|
|
24
|
+
const isThenable = (v) => typeof v.then === "function";
|
|
25
|
+
function settle(leg, call, message, router) {
|
|
26
|
+
if (leg === undefined || message.stopReason !== "error")
|
|
27
|
+
return;
|
|
28
|
+
const last = call.last;
|
|
29
|
+
if (last?.kind === "connect_failed")
|
|
30
|
+
announceModelUnreachable(leg, last.failure, router);
|
|
31
|
+
}
|
|
32
|
+
const BRAIN_MEMBER_TREATMENT = {
|
|
33
|
+
stream: "observed",
|
|
34
|
+
complete: "observed",
|
|
35
|
+
adjudicateRoute: "forwarded",
|
|
36
|
+
};
|
|
37
|
+
export function createModelReachBrain(inner, router = defaultEngineNoticeRouter) {
|
|
38
|
+
const observe = (leg, call, final) => {
|
|
39
|
+
void final.then((message) => settle(leg, call, message, router), () => undefined);
|
|
40
|
+
};
|
|
41
|
+
const MEMBER_OBSERVERS = {
|
|
42
|
+
stream: (innerStream) => (model, context, options) => {
|
|
43
|
+
const leg = currentModelReachLeg();
|
|
44
|
+
const call = {};
|
|
45
|
+
const s = withModelCallReach(call, () => innerStream.call(inner, model, context, options));
|
|
46
|
+
if (isThenable(s))
|
|
47
|
+
observe(leg, call, Promise.resolve(s).then((st) => st.result()));
|
|
48
|
+
else
|
|
49
|
+
observe(leg, call, s.result());
|
|
50
|
+
return s;
|
|
51
|
+
},
|
|
52
|
+
complete: (innerComplete) => (model, context, options) => {
|
|
53
|
+
const leg = currentModelReachLeg();
|
|
54
|
+
const call = {};
|
|
55
|
+
const p = withModelCallReach(call, () => innerComplete.call(inner, model, context, options));
|
|
56
|
+
observe(leg, call, p);
|
|
57
|
+
return p;
|
|
58
|
+
},
|
|
59
|
+
};
|
|
60
|
+
const observedStream = MEMBER_OBSERVERS.stream(inner.stream);
|
|
61
|
+
const observedComplete = inner.complete !== undefined ? MEMBER_OBSERVERS.complete(inner.complete) : undefined;
|
|
62
|
+
const observed = { stream: observedStream, complete: observedComplete };
|
|
63
|
+
return new Proxy(inner, {
|
|
64
|
+
get(target, prop) {
|
|
65
|
+
if (typeof prop === "string" && Object.hasOwn(observed, prop))
|
|
66
|
+
return Reflect.get(observed, prop);
|
|
67
|
+
const v = Reflect.get(target, prop, target);
|
|
68
|
+
return typeof v === "function" ? v.bind(target) : v;
|
|
69
|
+
},
|
|
70
|
+
});
|
|
71
|
+
}
|
|
72
|
+
//# sourceMappingURL=model-unreachable-notice.js.map
|
|
@@ -321,6 +321,10 @@ export declare const FAIL_OPEN_TAGS: {
|
|
|
321
321
|
readonly cls: "F";
|
|
322
322
|
readonly note: "S-582(`plugins/send-file-ledger.ts`,local 车道的发文件账本):一条账行读不出形(`42` / 缺 id·scope·objectKey 三元组 / 中间半截 JSON)⇒ 跳过它继续。最坏后果 = 那条已签发的链接在它租户的列表里**看不见**(对象本身没动;账本本来就不存 URL)。治理面少一行是可见性损失,不是放行 ⇒ F;但它恰是治理面要看的那一类事实,所以必须留痕 —— 修前零披露。";
|
|
323
323
|
};
|
|
324
|
+
readonly "server.outbound.connect-observer-threw": {
|
|
325
|
+
readonly cls: "F";
|
|
326
|
+
readonly note: "S-925(`outbound-dispatcher.ts` 的 undici 诊断通道订阅者:`undici:request:create` / `:headers` / `:error`):一只订阅回调抛了。通道在发布方的调用栈里同步回调订阅者,抛出去在 Node 上是未捕获异常(整进程倒),所以每只回调整体 try、落本 tag。最坏后果 = 这一条请求的建连失败没被记下:少一行 `outbound_connect_failed`、诊断段 `outbound.lastConnectFailure` 不更新、那次模型调用不铸 `server.model_unreachable` 用户通告 —— 请求本身、它的错误、run 的终态一个字节不受影响(观察只读)。计数恒零是证据;非零 = undici 通道载荷的形变了,去看 detail 的 site。";
|
|
327
|
+
};
|
|
324
328
|
};
|
|
325
329
|
/** 词表键推导的闭集类型——未登记的 tag 传不进 {@link recordFailOpen}(编译期拒)。 */
|
|
326
330
|
export type FailOpenTag = keyof typeof FAIL_OPEN_TAGS;
|
|
@@ -294,6 +294,10 @@ export const FAIL_OPEN_TAGS = {
|
|
|
294
294
|
cls: "F",
|
|
295
295
|
note: "S-582(`plugins/send-file-ledger.ts`,local 车道的发文件账本):一条账行读不出形(`42` / 缺 id·scope·objectKey 三元组 / 中间半截 JSON)⇒ 跳过它继续。最坏后果 = 那条已签发的链接在它租户的列表里**看不见**(对象本身没动;账本本来就不存 URL)。治理面少一行是可见性损失,不是放行 ⇒ F;但它恰是治理面要看的那一类事实,所以必须留痕 —— 修前零披露。",
|
|
296
296
|
},
|
|
297
|
+
"server.outbound.connect-observer-threw": {
|
|
298
|
+
cls: "F",
|
|
299
|
+
note: "S-925(`outbound-dispatcher.ts` 的 undici 诊断通道订阅者:`undici:request:create` / `:headers` / `:error`):一只订阅回调抛了。通道在发布方的调用栈里同步回调订阅者,抛出去在 Node 上是未捕获异常(整进程倒),所以每只回调整体 try、落本 tag。最坏后果 = 这一条请求的建连失败没被记下:少一行 `outbound_connect_failed`、诊断段 `outbound.lastConnectFailure` 不更新、那次模型调用不铸 `server.model_unreachable` 用户通告 —— 请求本身、它的错误、run 的终态一个字节不受影响(观察只读)。计数恒零是证据;非零 = undici 通道载荷的形变了,去看 detail 的 site。",
|
|
300
|
+
},
|
|
297
301
|
};
|
|
298
302
|
export function failOpenTagForDroppedFrame(frameType) {
|
|
299
303
|
switch (frameType) {
|
|
@@ -134,7 +134,8 @@ export declare function redactModelText(raw: string): string;
|
|
|
134
134
|
* 工具调用 id(`strandedHumanAnswers[].toolCallId` / `stats.humanReview.gates[].toolCallId`)**出身**同样是服务商,但自 core 7.36.0(#1207)起
|
|
135
135
|
* 它进引擎之前已**准入一次**:三只 brain 在建调用的那一行(`brain/tool-call-id.js` 的 `admitToolCallId`)—— 缺席 ⇒ 车道兜底铸;
|
|
136
136
|
* 在形(可见 ASCII 1–128 且不含 `~`)⇒ 原样;越界 ⇒ 单射折叠成引擎 id(`<前 32 码元,越界字符记 `-`>~<sha256 前 32 hex>`),原字节只挂在
|
|
137
|
-
* 转录的 `ToolCall.wireId` 上、只回给 provider。⇒
|
|
137
|
+
* 转录的 `ToolCall.wireId` 上、只回给 provider。⇒ 一方 brain 在 7.107.0 起新铸的调用,到达本仓任何一张脸的 `toolCallId` 恒在形。
|
|
138
|
+
* 残余两条(S-921,如实):① ≤7.106.0 写下的存量行(待批 park 行 `boundCallId` / 检查点 `pendingAction.toolCallId` / 转录)是服务商原串逐字 —— 新版不对存量行二次准入(e2e G4 钉);② 宿主自己的 `Brain`(不经三只一方 adapter)铸的 id 不准入(core KL-1207-a;本仓生产装配的 brain 恒是 `createBrain` 的一方 adapter 外套委托装饰,这一形只经嵌入 / 测试注入的 brain 到达)。两条残余里的 id 仍是跨面 join 键、每张脸同值,只是形状不保证(本闸照旧不动它们:动一张脸 = join 断)。
|
|
138
139
|
*
|
|
139
140
|
* 🔴 **本闸只施加在「这个值出现的每一张脸都被覆盖到」的回声上**(codex r1 F1,实测成立的回归后改判):工具调用 id 是
|
|
140
141
|
* **跨面 join 键** —— 同一枚 id 同时逐字出现在 `tool_start` / `tool_end` / 审批卡等逐帧面上。只在结果面动它 ⇒ 结果面与逐帧面
|
|
@@ -62,9 +62,28 @@
|
|
|
62
62
|
* `outbound_proxy`,不进 `/health`、不进公告、不进租户 wire(代理那一跳失败的终局句只说「经代理 + 哪只变量」,F3 裁定)。代理变量不可用(undici 构造即拒:无 scheme、`ftp:`、带路径 / 查询)⇒
|
|
63
63
|
* **拒启**(与 Node 24 `NODE_USE_ENV_PROXY=1` 遇坏值的行为同向)—— 静默直连 = 运维以为出网走了代理而实际没有,那是出网策略面的
|
|
64
64
|
* fail-open;拒因只回显 display-safe 值。
|
|
65
|
+
*
|
|
66
|
+
* ── 建连失败的观察(S-925,7.108.0;test [ref] ② Windows 用户 VPN 中途切换)──────────────────────────────
|
|
67
|
+
* 病:建连失败(`UND_ERR_CONNECT_TIMEOUT` 一族)只有 undici 原句,不点名起服时读进的代理、不给「环境变了要重启」的出路。
|
|
68
|
+
* 观察点 = undici 的**公开诊断通道**(`undici:request:create` / `:headers` / `:error`,进程级按名订阅,npm undici 与 Node 内建那一份
|
|
69
|
+
* 同名同发):不包 handler、不动 dispatch 的热路径。{@link installOutboundDispatcher} 装一次:
|
|
70
|
+
* ① 每条请求的建连期失败(码在闭集 {@link OUTBOUND_CONNECT_FAILURE_CODES} 里、且这条请求还没收到过响应头)⇒ 一条结构化 warn
|
|
71
|
+
* `outbound_connect_failed { targetHost, code, proxyInUse, proxyReadAtMs, sinceBootMs, hint }`(`proxyInUse` = 这一跳走的代理**变量名**,
|
|
72
|
+
* 不打值;直连 ⇒ `null`)—— 全进程的出网都算(模型、中心、工具 …),不只模型;
|
|
73
|
+
* ② 最近一次建连失败 + 生效代理的**名字**与读取时刻 ⇒ `GET /v1/diagnostics/wiring` 的 `outbound` 段({@link readOutboundWiring});
|
|
74
|
+
* ③ 请求若创建在一次**模型调用**的作用域里(`model-reach-scope.ts`),结局记到那次调用上 —— 模型调用终态时由
|
|
75
|
+
* `model-unreachable-notice.ts` 决定要不要给这条腿的用户铸 `server.model_unreachable`(只落日志不算闭环:运营用户看不到日志)。
|
|
76
|
+
* 代理 CONNECT 请求本身(`method === "CONNECT"`,打向代理的那条)不单独计 —— 它的失败会以 {@link OutboundProxyError} 的形态落在
|
|
77
|
+
* 目标请求上,那一条才知道「要去哪」。Bun 的原生 fetch 不经 undici ⇒ 通道不发、三件都没有(如实:与回环直连同一条 Bun 例外)。
|
|
65
78
|
*/
|
|
66
79
|
import { Dispatcher } from "undici";
|
|
67
80
|
import type { Logger } from "./observability/logger.js";
|
|
81
|
+
/** 三只变量的读值(boot 冻结;空串 = 未设)。 */
|
|
82
|
+
interface OutboundProxyEnv {
|
|
83
|
+
readonly httpProxy: string;
|
|
84
|
+
readonly httpsProxy: string;
|
|
85
|
+
readonly noProxy: string;
|
|
86
|
+
}
|
|
68
87
|
/**
|
|
69
88
|
* 出网代理姿态(`build*`:纯数据)—— 启动日志 `outbound_proxy` 行与配置目录共读的**同一份**投影。
|
|
70
89
|
* · `httpProxy` / `httpsProxy`:http: / https: 目标**实际**走的代理(display-safe;`null` = 直连)。`httpsProxy` 在 `HTTPS_PROXY`
|
|
@@ -110,7 +129,56 @@ export declare const OUTBOUND_PROXY_INVALID_CODE = "config.outbound_proxy_invali
|
|
|
110
129
|
export declare function createOutboundDispatcher(env?: NodeJS.ProcessEnv): {
|
|
111
130
|
dispatcher: Dispatcher;
|
|
112
131
|
posture: OutboundProxyPosture;
|
|
132
|
+
env: OutboundProxyEnv;
|
|
133
|
+
};
|
|
134
|
+
/**
|
|
135
|
+
* 建连期失败码 —— **闭集**(码 → 给运维的一句读法)。只收**只可能**在建立连接时出现的码:undici 的建连截止
|
|
136
|
+
* (`UND_ERR_CONNECT_TIMEOUT`,TCP + TLS 没在传输层的建连时限内完成)与 OS 的拒连 / 解析不了 / 无路由。可能出现在**已建立**
|
|
137
|
+
* 连接上的码(`ECONNRESET` / `ETIMEDOUT` / `UND_ERR_SOCKET`)刻意不收 —— 它们判不出「够不着」,收进来就会把一次中途断流
|
|
138
|
+
* 说成「模型不可达、去查 VPN」。加一个码 = 加一行并写明它为什么只可能在建连时出现。
|
|
139
|
+
*/
|
|
140
|
+
export declare const OUTBOUND_CONNECT_FAILURE_CODES: {
|
|
141
|
+
readonly UND_ERR_CONNECT_TIMEOUT: "the TCP + TLS connect did not complete within the transport's connect deadline";
|
|
142
|
+
readonly ECONNREFUSED: "the peer actively refused the connection (nothing listening there, or a firewall reset it)";
|
|
143
|
+
readonly ENOTFOUND: "the host name did not resolve";
|
|
144
|
+
readonly EAI_AGAIN: "the DNS lookup failed temporarily";
|
|
145
|
+
readonly EHOSTUNREACH: "there is no route to the host";
|
|
146
|
+
readonly ENETUNREACH: "the network is unreachable";
|
|
113
147
|
};
|
|
148
|
+
export type OutboundConnectFailureCode = keyof typeof OUTBOUND_CONNECT_FAILURE_CODES;
|
|
149
|
+
/** 一次建连失败的事实(纯数据:日志行、诊断段、模型调用结局同一份)。`proxyInUse` = 这一跳走的代理**变量名**(不打值),直连 ⇒ `null`。 */
|
|
150
|
+
export interface OutboundConnectFailure {
|
|
151
|
+
readonly targetHost: string;
|
|
152
|
+
readonly code: OutboundConnectFailureCode;
|
|
153
|
+
readonly proxyInUse: string | null;
|
|
154
|
+
}
|
|
155
|
+
/**
|
|
156
|
+
* 一只请求错误是不是**建连期失败**:在错误图里找第一枚闭集码;路上遇到 {@link OutboundProxyError} ⇒ 这一跳走了代理,
|
|
157
|
+
* 记下它的变量名。找不到闭集码 ⇒ `undefined`(不是建连失败:TLS、中途断、取消、超时、代理 CONNECT 非 200 …)。
|
|
158
|
+
* 纯函数、不抛(只读 `code` / `cause` / `errors` 三个自有数据位)。
|
|
159
|
+
*/
|
|
160
|
+
export declare function classifyOutboundConnectFailure(error: unknown): {
|
|
161
|
+
readonly code: OutboundConnectFailureCode;
|
|
162
|
+
readonly proxyInUse: string | null;
|
|
163
|
+
} | undefined;
|
|
164
|
+
/**
|
|
165
|
+
* `GET /v1/diagnostics/wiring` 的 `outbound` 段(`build*`:纯数据)。**名不打值**:代理只报是哪只变量供的(`HTTP_PROXY` / `HTTPS_PROXY`;
|
|
166
|
+
* https 目标在 `HTTPS_PROXY` 缺席时回落 `HTTP_PROXY`,undici 的读法),`NO_PROXY` 只报在不在场;代理地址一个字节都不上本段
|
|
167
|
+
* (地址在启动日志 `outbound_proxy` 行,display-safe)。未安装(不是由 main.ts / run-local 装起来的进程)⇒ `null`。
|
|
168
|
+
*/
|
|
169
|
+
export interface OutboundWiring {
|
|
170
|
+
readonly proxy: {
|
|
171
|
+
readonly http: "HTTP_PROXY" | null;
|
|
172
|
+
readonly https: "HTTPS_PROXY" | "HTTP_PROXY" | null;
|
|
173
|
+
};
|
|
174
|
+
readonly noProxy: boolean;
|
|
175
|
+
readonly loopbackDirect: boolean;
|
|
176
|
+
readonly proxyReadAtMs: number;
|
|
177
|
+
readonly lastConnectFailure: (OutboundConnectFailure & {
|
|
178
|
+
readonly atMs: number;
|
|
179
|
+
}) | null;
|
|
180
|
+
}
|
|
181
|
+
export declare function readOutboundWiring(): OutboundWiring | null;
|
|
114
182
|
/** 安装结果 = 姿态 + 这条规则在本进程**是否生效**(运行时的 fetch 读不读 undici 全局槽 —— Node 读、Bun 不读)。 */
|
|
115
183
|
export interface OutboundDispatcherInstall extends OutboundProxyPosture {
|
|
116
184
|
readonly loopbackDirect: boolean;
|
|
@@ -122,4 +190,5 @@ export interface OutboundDispatcherInstall extends OutboundProxyPosture {
|
|
|
122
190
|
export declare function installOutboundDispatcher(logger: Logger, env?: NodeJS.ProcessEnv): OutboundDispatcherInstall;
|
|
123
191
|
/** 测试专用:撤回安装(槽还给装之前的那只,本只关掉),下一次 {@link installOutboundDispatcher} 重新读 env。 */
|
|
124
192
|
export declare function resetOutboundDispatcherForTest(): void;
|
|
193
|
+
export {};
|
|
125
194
|
//# sourceMappingURL=outbound-dispatcher.d.ts.map
|
|
@@ -1,6 +1,9 @@
|
|
|
1
1
|
import { Agent, Dispatcher, EnvHttpProxyAgent, getGlobalDispatcher, Pool, setGlobalDispatcher } from "undici";
|
|
2
2
|
import { isIP } from "node:net";
|
|
3
|
+
import diagnosticsChannel from "node:diagnostics_channel";
|
|
3
4
|
import { buildDisplaySafeUrl, DISPLAY_SAFE_URL_TOKEN } from "./trace/redact.js";
|
|
5
|
+
import { recordFailOpen } from "./observability/fail-open.js";
|
|
6
|
+
import { currentModelCallReach } from "./model-reach-scope.js";
|
|
4
7
|
function readOutboundProxyEnv(env = process.env) {
|
|
5
8
|
return {
|
|
6
9
|
httpProxy: env.http_proxy ?? env.HTTP_PROXY ?? "",
|
|
@@ -173,7 +176,120 @@ export function createOutboundDispatcher(env = process.env) {
|
|
|
173
176
|
catch (err) {
|
|
174
177
|
throw refuse(err instanceof Error && "code" in err && typeof err.code === "string" ? err.code : "invalid");
|
|
175
178
|
}
|
|
176
|
-
return { dispatcher: new OutboundDispatcher(new Agent(), proxied), posture: postureOf(v) };
|
|
179
|
+
return { dispatcher: new OutboundDispatcher(new Agent(), proxied), posture: postureOf(v), env: v };
|
|
180
|
+
}
|
|
181
|
+
export const OUTBOUND_CONNECT_FAILURE_CODES = {
|
|
182
|
+
UND_ERR_CONNECT_TIMEOUT: "the TCP + TLS connect did not complete within the transport's connect deadline",
|
|
183
|
+
ECONNREFUSED: "the peer actively refused the connection (nothing listening there, or a firewall reset it)",
|
|
184
|
+
ENOTFOUND: "the host name did not resolve",
|
|
185
|
+
EAI_AGAIN: "the DNS lookup failed temporarily",
|
|
186
|
+
EHOSTUNREACH: "there is no route to the host",
|
|
187
|
+
ENETUNREACH: "the network is unreachable",
|
|
188
|
+
};
|
|
189
|
+
const isConnectFailureCode = (c) => typeof c === "string" && Object.hasOwn(OUTBOUND_CONNECT_FAILURE_CODES, c);
|
|
190
|
+
const CONNECT_ERROR_GRAPH_BUDGET = 16;
|
|
191
|
+
export function classifyOutboundConnectFailure(error) {
|
|
192
|
+
const queue = [error];
|
|
193
|
+
let proxyInUse = null;
|
|
194
|
+
for (let seen = 0; queue.length > 0 && seen < CONNECT_ERROR_GRAPH_BUDGET; seen++) {
|
|
195
|
+
const node = queue.shift();
|
|
196
|
+
if (typeof node !== "object" || node === null)
|
|
197
|
+
continue;
|
|
198
|
+
if (node instanceof OutboundProxyError)
|
|
199
|
+
proxyInUse = node.from;
|
|
200
|
+
const code = Object.getOwnPropertyDescriptor(node, "code")?.value;
|
|
201
|
+
if (isConnectFailureCode(code))
|
|
202
|
+
return { code, proxyInUse };
|
|
203
|
+
const cause = Object.getOwnPropertyDescriptor(node, "cause")?.value;
|
|
204
|
+
if (cause !== undefined)
|
|
205
|
+
queue.push(cause);
|
|
206
|
+
const errors = Object.getOwnPropertyDescriptor(node, "errors")?.value;
|
|
207
|
+
if (Array.isArray(errors))
|
|
208
|
+
queue.push(...errors.slice(0, CONNECT_ERROR_GRAPH_BUDGET));
|
|
209
|
+
}
|
|
210
|
+
return undefined;
|
|
211
|
+
}
|
|
212
|
+
function targetHostOf(origin) {
|
|
213
|
+
const text = typeof origin === "string" ? origin : origin instanceof URL ? origin.href : "";
|
|
214
|
+
return URL.canParse(text) ? new URL(text).host : "(unreadable origin)";
|
|
215
|
+
}
|
|
216
|
+
let observer;
|
|
217
|
+
const requestCalls = new WeakMap();
|
|
218
|
+
const answeredRequests = new WeakSet();
|
|
219
|
+
const requestOf = (message) => {
|
|
220
|
+
const r = typeof message === "object" && message !== null ? Reflect.get(message, "request") : undefined;
|
|
221
|
+
return typeof r === "object" && r !== null ? r : undefined;
|
|
222
|
+
};
|
|
223
|
+
function startConnectObserver(logger, nowMs) {
|
|
224
|
+
const subs = [];
|
|
225
|
+
const current = {
|
|
226
|
+
proxyReadAtMs: nowMs,
|
|
227
|
+
unsubscribe: () => {
|
|
228
|
+
for (const [name, fn] of subs)
|
|
229
|
+
diagnosticsChannel.unsubscribe(name, fn);
|
|
230
|
+
},
|
|
231
|
+
};
|
|
232
|
+
const guard = (site, fn) => (message) => {
|
|
233
|
+
try {
|
|
234
|
+
fn(message);
|
|
235
|
+
}
|
|
236
|
+
catch (e) {
|
|
237
|
+
recordFailOpen("server.outbound.connect-observer-threw", `site=${site} err=${e instanceof Error ? e.name : typeof e}`);
|
|
238
|
+
}
|
|
239
|
+
};
|
|
240
|
+
const onCreate = guard("create", (message) => {
|
|
241
|
+
const request = requestOf(message);
|
|
242
|
+
const call = currentModelCallReach();
|
|
243
|
+
if (request !== undefined && call !== undefined)
|
|
244
|
+
requestCalls.set(request, call);
|
|
245
|
+
});
|
|
246
|
+
const onHeaders = guard("headers", (message) => {
|
|
247
|
+
const request = requestOf(message);
|
|
248
|
+
if (request === undefined)
|
|
249
|
+
return;
|
|
250
|
+
answeredRequests.add(request);
|
|
251
|
+
const call = requestCalls.get(request);
|
|
252
|
+
if (call !== undefined)
|
|
253
|
+
call.last = { kind: "reached" };
|
|
254
|
+
});
|
|
255
|
+
const onError = guard("error", (message) => {
|
|
256
|
+
const request = requestOf(message);
|
|
257
|
+
if (request === undefined || Reflect.get(request, "method") === "CONNECT")
|
|
258
|
+
return;
|
|
259
|
+
const call = requestCalls.get(request);
|
|
260
|
+
const verdict = answeredRequests.has(request) ? undefined : classifyOutboundConnectFailure(Reflect.get(message, "error"));
|
|
261
|
+
if (verdict === undefined) {
|
|
262
|
+
if (call !== undefined)
|
|
263
|
+
call.last = { kind: "other" };
|
|
264
|
+
return;
|
|
265
|
+
}
|
|
266
|
+
const failure = { targetHost: targetHostOf(Reflect.get(request, "origin")), ...verdict };
|
|
267
|
+
current.last = { ...failure, atMs: Date.now() };
|
|
268
|
+
logger.warn("outbound_connect_failed", {
|
|
269
|
+
...failure,
|
|
270
|
+
proxyReadAtMs: current.proxyReadAtMs,
|
|
271
|
+
sinceBootMs: Math.round(process.uptime() * 1000),
|
|
272
|
+
hint: `${OUTBOUND_CONNECT_FAILURE_CODES[failure.code]} — ${failure.proxyInUse !== null ? `this hop went through the proxy from ${failure.proxyInUse} (read once at boot)` : "this hop was a direct connection"}; if the VPN / proxy changed since boot, restart the server so it re-reads HTTP(S)_PROXY / NO_PROXY`,
|
|
273
|
+
});
|
|
274
|
+
if (call !== undefined)
|
|
275
|
+
call.last = { kind: "connect_failed", failure };
|
|
276
|
+
});
|
|
277
|
+
subs.push(["undici:request:create", onCreate], ["undici:request:headers", onHeaders], ["undici:request:error", onError]);
|
|
278
|
+
for (const [name, fn] of subs)
|
|
279
|
+
diagnosticsChannel.subscribe(name, fn);
|
|
280
|
+
return current;
|
|
281
|
+
}
|
|
282
|
+
export function readOutboundWiring() {
|
|
283
|
+
if (installed === undefined || observer === undefined)
|
|
284
|
+
return null;
|
|
285
|
+
const v = installed.env;
|
|
286
|
+
return {
|
|
287
|
+
proxy: { http: v.httpProxy !== "" ? "HTTP_PROXY" : null, https: v.httpsProxy !== "" ? "HTTPS_PROXY" : v.httpProxy !== "" ? "HTTP_PROXY" : null },
|
|
288
|
+
noProxy: postureOf(v).noProxy !== null,
|
|
289
|
+
loopbackDirect: installed.result.loopbackDirect,
|
|
290
|
+
proxyReadAtMs: observer.proxyReadAtMs,
|
|
291
|
+
lastConnectFailure: observer.last ?? null,
|
|
292
|
+
};
|
|
177
293
|
}
|
|
178
294
|
const GLOBAL_DISPATCHER_SLOTS = [Symbol.for("undici.globalDispatcher.1"), Symbol.for("undici.globalDispatcher.2")];
|
|
179
295
|
let installed;
|
|
@@ -181,7 +297,7 @@ export function installOutboundDispatcher(logger, env = process.env) {
|
|
|
181
297
|
if (installed !== undefined)
|
|
182
298
|
return installed.result;
|
|
183
299
|
const prior = getGlobalDispatcher();
|
|
184
|
-
const { dispatcher, posture } = createOutboundDispatcher(env);
|
|
300
|
+
const { dispatcher, posture, env: proxyEnv } = createOutboundDispatcher(env);
|
|
185
301
|
setGlobalDispatcher(dispatcher);
|
|
186
302
|
const loopbackDirect = GLOBAL_DISPATCHER_SLOTS.every((slot) => Reflect.get(globalThis, slot) === dispatcher);
|
|
187
303
|
const result = { ...posture, loopbackDirect };
|
|
@@ -196,10 +312,14 @@ export function installOutboundDispatcher(logger, env = process.env) {
|
|
|
196
312
|
else {
|
|
197
313
|
logger.info("outbound_proxy", { ...result, runtime });
|
|
198
314
|
}
|
|
199
|
-
installed = { dispatcher, prior, result };
|
|
315
|
+
installed = { dispatcher, prior, result, env: proxyEnv };
|
|
316
|
+
observer?.unsubscribe();
|
|
317
|
+
observer = startConnectObserver(logger, Date.now());
|
|
200
318
|
return result;
|
|
201
319
|
}
|
|
202
320
|
export function resetOutboundDispatcherForTest() {
|
|
321
|
+
observer?.unsubscribe();
|
|
322
|
+
observer = undefined;
|
|
203
323
|
if (installed === undefined)
|
|
204
324
|
return;
|
|
205
325
|
const { dispatcher, prior } = installed;
|
|
@@ -0,0 +1,48 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* 🔴 **S-929 F1(7.108.0 [ref] 合并复审 codex 腿 A ①,[high] 回归修)—— park 提交的唯一观察点。**
|
|
3
|
+
*
|
|
4
|
+
* core 的 durable park 就是对本仓 checkpoint 店的一次 `put`(SQL 孪生 / 本地文件店都是本仓的 `CheckpointStore` 实现,
|
|
5
|
+
* 一只实例、四个消费点 —— 见 `boot/stage-06-runners.ts` 的构造注)。审批协调器交出 park 路由(ask 回调返回 `"unavailable"`)
|
|
6
|
+
* **之后** core 才铸行、才 `put`;两者之间隔着 core 自己的 park 前置(会话叶、环境快照…)。所以「ask 回调返回」**不是**
|
|
7
|
+
* 「park 落盘」,有序关停若把前者当后者,就会在 `put` 之下关掉连接池(修前病:park 行没提交,审计账本却已记「结清」)。
|
|
8
|
+
*
|
|
9
|
+
* 本模块给店加**一层**装饰(两店同用,不读 core 私有键 —— [ref]):
|
|
10
|
+
* ① **计在飞 `put`**:`quiesce(deadlineMs)` 等到零或到期(绝对时刻,`Date.now()` 域),如实返回残余数 —— 收尾链在
|
|
11
|
+
* `backend.close()` 之前等它(`boot/shutdown.ts`),审批协调器的关停收尾也等它(`ToolApprovalCoordinator.drainForShutdown`);
|
|
12
|
+
* ② **报 park 提交** —— 「这只店里有了这一行」的两种观察,同一件事实:一次 `put` 落定(resolve);或一次 `put` 抛错之后,同一 token 的
|
|
13
|
+
* `get` 读到了行(写已落、回执丢了 —— core 的挂起序正是用这一次回读确认提交的,本层观察的就是那一次读,不另读;车内 codex r1)。
|
|
14
|
+
* 那一行是审批停车行(`pendingAction.kind === "tool_approval"`,kind 先判 —— core d.ts「Every consumer that reads the tool fields MUST
|
|
15
|
+
* branch on `kind` first」)⇒ 把**停车行自己陈述的**调用身份交给订阅者({@link CommittedParkCalls})。抛错且回读不到 = 没提交,不报。
|
|
16
|
+
* 身份取自**读到的那一行**(不是本进程想写的那一只):哪怕 token 撞了别人的行,报出去的也只是那一行真握着的调用。
|
|
17
|
+
*
|
|
18
|
+
* 订阅者契约:**不得抛**。订阅者跑在 `put` 已提交之后、返回给 core 之前 —— 一只抛错的订阅者会把一次**已提交**的 park 报成失败。
|
|
19
|
+
* 唯一的订阅者是审批协调器(写审计 settle 行,那只 sink 自吞失败并按 [ref] 留痕),它在自己那一侧兜住(同 tag 扩射程,见协调器)。
|
|
20
|
+
*/
|
|
21
|
+
import type { CheckpointStore } from "@sema-agent/core";
|
|
22
|
+
/**
|
|
23
|
+
* 一只**已提交**的审批停车行说它握着哪些调用(停车行自己的陈述,原样,不是推断):
|
|
24
|
+
* · `sessionId` —— 行停住的会话 `cp.sessionId`(执行这次调用的会话:委派 / 后台子代的行落在**子会话**上)。core 铸行用的是 prepare-task 的
|
|
25
|
+
* 同一个 `sessionId`,它也是同一次调用的 ask 上的 `sourceTaskId`(`askSourceIdentity`)⇒ 订阅者按**相等**配,不做别名(车 SO:
|
|
26
|
+
* 调用 id 只在 run 内唯一,宿主会话若当子代的等价别名,宿主停车行会冒充子代的提交);
|
|
27
|
+
* · `toolCallIds` —— 停在门前的那只调用(`pendingAction.toolCallId`)∪ 同一条助手消息里**尚未执行**的调用
|
|
28
|
+
* (`batchToolCallIds − completedCallIds`,core d.ts「#k+1..N are still pending」):那些调用随这一行一起被持久挂起,
|
|
29
|
+
* 续跑时重新过门 —— 它们的等待同样由这一行承载。
|
|
30
|
+
*/
|
|
31
|
+
export interface CommittedParkCalls {
|
|
32
|
+
readonly sessionId: string;
|
|
33
|
+
readonly toolCallIds: readonly string[];
|
|
34
|
+
}
|
|
35
|
+
/** 装饰层的观察面(`create*` 产出的活对象的一半;另一半是装饰后的店本身)。 */
|
|
36
|
+
export interface ParkCommitWatch {
|
|
37
|
+
/** 订阅 park 提交(见模块头「订阅者契约」:不得抛)。 */
|
|
38
|
+
onParkCommitted(listener: (calls: CommittedParkCalls) => void): void;
|
|
39
|
+
/** 等在飞 `put` 归零或到 `deadlineMs`(绝对时刻,`Date.now()` 域);返回那一刻的残余在飞数(0 = 静默了)。从不抛。 */
|
|
40
|
+
quiesce(deadlineMs: number): Promise<{
|
|
41
|
+
inFlight: number;
|
|
42
|
+
}>;
|
|
43
|
+
}
|
|
44
|
+
export declare function createParkCommitWatch<S extends Pick<CheckpointStore, "put" | "get">>(inner: S): {
|
|
45
|
+
store: S;
|
|
46
|
+
watch: ParkCommitWatch;
|
|
47
|
+
};
|
|
48
|
+
//# sourceMappingURL=park-commit-watch.d.ts.map
|