@sema-agent/client-core 0.14.0 → 0.16.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/README.md +5 -3
- package/dist/adapter/downstream/terminalToSdkResult.d.ts +38 -25
- package/dist/adapter/downstream/terminalToSdkResult.js +107 -58
- package/dist/adapter/runStream.js +12 -8
- package/dist/controlRouter.d.ts +11 -0
- package/dist/controlRouter.js +11 -0
- package/dist/detachWire.d.ts +6 -0
- package/dist/detachWire.js +6 -0
- package/dist/finalVerifyWire.d.ts +5 -0
- package/dist/finalVerifyWire.js +5 -0
- package/dist/fleet/fleetLedger.d.ts +9 -0
- package/dist/fleet/fleetLedger.js +10 -1
- package/dist/fleet/fleetProjection.d.ts +13 -1
- package/dist/fleet/fleetProjection.js +13 -1
- package/dist/fleetAgentPanelProjection.js +25 -3
- package/dist/index.d.ts +2 -0
- package/dist/index.js +23 -0
- package/dist/interactiveToolsWire.d.ts +6 -1
- package/dist/interactiveToolsWire.js +6 -1
- package/dist/limitsWire.d.ts +77 -23
- package/dist/limitsWire.js +108 -35
- package/dist/model/catalogLoader.d.ts +170 -0
- package/dist/model/catalogLoader.js +382 -0
- package/dist/model/providerAuth.d.ts +155 -0
- package/dist/model/providerAuth.js +190 -0
- package/dist/model/providerPresets.d.ts +16 -0
- package/dist/notifications.d.ts +4 -0
- package/dist/notifications.js +134 -13
- package/dist/sandboxWire.d.ts +12 -1
- package/dist/sandboxWire.js +12 -1
- package/dist/scenarioWire.d.ts +12 -1
- package/dist/scenarioWire.js +12 -1
- package/dist/seatContract.js +25 -5
- package/dist/steering.js +11 -2
- package/dist/subagentContentStore.d.ts +13 -0
- package/package.json +1 -1
|
@@ -0,0 +1,155 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* providerAuth.ts — **模型供应商凭证轨**的双轨 seam(design/166 §5「B 档:设备码流」,2026-08-04)。
|
|
3
|
+
*
|
|
4
|
+
* ## 边界(先说清楚它**不是**什么)
|
|
5
|
+
*
|
|
6
|
+
* 🔴 [reuse-single-sso-no-parallel-auth]:registry SSO(cloudAuth,「登录 sema」那条)**绝不复用、
|
|
7
|
+
* 绝不被改造**。本文件是**模型供应商**的凭证轨(拿一把 API key 去调某家模型),与 registry 登录轨
|
|
8
|
+
* 物理分文件、零共享代码。两者唯一的相似点是「都会弹一个浏览器」,那不构成复用理由。
|
|
9
|
+
*
|
|
10
|
+
* 🔴 design/166 §6 已定谳:**授权码 + PKCE(C 档)不做、不留 seam、不留枚举位** —— Anthropic 侧
|
|
11
|
+
* 条款明文禁止第三方代理其订阅凭证,而公开实现全都依赖冒用官方 client_id + 伪装 UA。本文件里
|
|
12
|
+
* 因此**只有** api_key 与 device_code 两轨,没有第三个 kind 位。
|
|
13
|
+
*
|
|
14
|
+
* ## 能力真值 = 目录键 ∧ 包内实现(两者与)
|
|
15
|
+
*
|
|
16
|
+
* 目录(design/165 §2)里的 `deviceAuth` 键**只是能力开关**,client_id 与端点**绝不目录化下发**
|
|
17
|
+
* (远端可改鉴权端点 = 钓鱼面)。所以 UI 是否显示「Sign in with device code」的判据是
|
|
18
|
+
* `supportsDeviceCodeAuth(id, preset.deviceAuth)`:
|
|
19
|
+
* · 目录说有、包里没有 ⇒ **不显示**(防目录先行造出一个点了没反应的假 affordance);
|
|
20
|
+
* · 包里有、目录没说 ⇒ **不显示**(那家还没被目录确认支持)。
|
|
21
|
+
*
|
|
22
|
+
* ## 🔴 `DEVICE_AUTH_PROVIDERS` 今天是**空表**(宁空勿假 —— 这是有意的,不是没写完)
|
|
23
|
+
*
|
|
24
|
+
* 设计档写「首发 1 家:GitHub Copilot 形」。落码时的事实是:device flow 需要一个 **client_id**,
|
|
25
|
+
* 而公开可查到的 Copilot client_id 全部是**别的编辑器插件自己的** app id(copilot.vim / VS Code
|
|
26
|
+
* 扩展)。把它们编进我们的包 = 以别人的 app 身份向 GitHub 发起授权,与 §6 判 C 档出局时用的
|
|
27
|
+
* **同一条判据**(「冒用官方 client_id」是主动欺骗,不是灰区)。我们自己名下的 OAuth app 尚未
|
|
28
|
+
* 申请 ⇒ 这一格的诚实值是**空**,而不是先填一个能跑的别人的号。
|
|
29
|
+
*
|
|
30
|
+
* 空表不是把这条腿废了:轮询器、退避、上限、终态词表全部在位并被门覆盖(门用一份合成 descriptor
|
|
31
|
+
* 跑真 HTTP)。**注册一家 = 往这张表加一行**,零代码改动。在那之前 `supportsDeviceCodeAuth`
|
|
32
|
+
* 对每一家如实返回 false,UI 不显示该选项 —— 这正是「能力真值 = 两者与」自带的正确降级。
|
|
33
|
+
*
|
|
34
|
+
* ## key 到手之后
|
|
35
|
+
*
|
|
36
|
+
* 🔴 **落盘不在包内做**。`poll()` 把 key 原样返给宿主,宿主走**与手填完全相同的那条路径**
|
|
37
|
+
* (壳 = `applyOnboard.ts` 写 settings `env.MODEL_API_KEY`)。两树都没有 auth.json 实体,
|
|
38
|
+
* 本文件也不新建任何持久面(design/166 §5 复核 M4:零新持久面,禁造)。
|
|
39
|
+
*/
|
|
40
|
+
import type { ProviderPreset, ProviderDeviceAuthHint } from './providerPresets.js';
|
|
41
|
+
/**
|
|
42
|
+
* 一个 provider 可用的凭证轨。
|
|
43
|
+
* `api_key` 是现状唯一轨(手填,env 名来自 preset.authEnv);`device_code` 是 B 档新轨。
|
|
44
|
+
*/
|
|
45
|
+
export type ProviderAuthMethod = {
|
|
46
|
+
kind: 'api_key';
|
|
47
|
+
env: string;
|
|
48
|
+
} | {
|
|
49
|
+
kind: 'device_code';
|
|
50
|
+
providerId: string;
|
|
51
|
+
};
|
|
52
|
+
/**
|
|
53
|
+
* 设备码端点三件套 —— **编译进包**(design/165 §2 红线:client_id / token 端点 / authorize 端点
|
|
54
|
+
* 一律不进目录)。
|
|
55
|
+
*/
|
|
56
|
+
export interface DeviceCodeEndpoints {
|
|
57
|
+
clientId: string;
|
|
58
|
+
/** RFC 8628 device authorization endpoint。 */
|
|
59
|
+
deviceCodeUrl: string;
|
|
60
|
+
/** token endpoint(轮询这一个)。 */
|
|
61
|
+
tokenUrl: string;
|
|
62
|
+
/** 申请的 scope(留空 = 不发这个参数)。 */
|
|
63
|
+
scope?: string;
|
|
64
|
+
}
|
|
65
|
+
/** 包内登记的一家设备码 provider。 */
|
|
66
|
+
export interface DeviceAuthProvider {
|
|
67
|
+
/** 与目录/preset 的 `id` 同一个命名空间(用户 settings 里的持久引用)。 */
|
|
68
|
+
providerId: string;
|
|
69
|
+
label: string;
|
|
70
|
+
endpoints: DeviceCodeEndpoints;
|
|
71
|
+
}
|
|
72
|
+
/**
|
|
73
|
+
* 包内实现的设备码 provider 表。
|
|
74
|
+
* 🔴 **今天是空的,理由见文件头**(宁空勿假:没有我们自己名下的 client_id 之前,填一行 = 冒用)。
|
|
75
|
+
* 加一家 = 加一行;端点必须 https(门 ④a 逐行校)。
|
|
76
|
+
*/
|
|
77
|
+
export declare const DEVICE_AUTH_PROVIDERS: readonly DeviceAuthProvider[];
|
|
78
|
+
/** 包内是否实现了这一家(能力真值的一半)。 */
|
|
79
|
+
export declare function deviceAuthProviderFor(providerId: string): DeviceAuthProvider | undefined;
|
|
80
|
+
/**
|
|
81
|
+
* UI 是否显示「设备码登录」= **目录说有** ∧ **包里有实现**。
|
|
82
|
+
* 任一缺席 ⇒ false(见文件头「能力真值」段)。
|
|
83
|
+
*/
|
|
84
|
+
export declare function supportsDeviceCodeAuth(providerId: string, catalogHint: ProviderDeviceAuthHint | undefined): boolean;
|
|
85
|
+
/**
|
|
86
|
+
* 一家 provider 摆给用户的轨(顺序 = UI 呈现序)。`api_key` 恒在;`device_code` 只在能力真值成立时出。
|
|
87
|
+
*/
|
|
88
|
+
export declare function providerAuthMethods(preset: ProviderPreset): ProviderAuthMethod[];
|
|
89
|
+
/**
|
|
90
|
+
* 一次 `poll()` 的结局。
|
|
91
|
+
*
|
|
92
|
+
* 🔴 比设计档草案多两个终态,理由是诚实:草案只有 `pending|ok|expired`,于是
|
|
93
|
+
* 「用户点了拒绝」与「会话被本地取消」都只能冒充 `expired`(= 骗用户「过期了,再来一次」,
|
|
94
|
+
* 而事实是再来一次还会被拒 / 是他自己取消的)。两者各占一位:
|
|
95
|
+
* · `denied` —— 授权服务器说 access_denied;
|
|
96
|
+
* · `cancelled` —— 本地调过 `cancel()`。
|
|
97
|
+
*/
|
|
98
|
+
export type DeviceCodeStatus = 'pending' | 'ok' | 'expired' | 'denied' | 'cancelled';
|
|
99
|
+
export interface DeviceCodePollResult {
|
|
100
|
+
status: DeviceCodeStatus;
|
|
101
|
+
/** 只在 `ok` 时在场。🔴 宿主拿去走与手填同一条落盘路径;包内零持久面。 */
|
|
102
|
+
key?: string;
|
|
103
|
+
/** 人话细节(分诊用;绝不带 key、绝不带完整响应体)。 */
|
|
104
|
+
detail?: string;
|
|
105
|
+
}
|
|
106
|
+
/**
|
|
107
|
+
* 一次设备码会话。**宿主驱动**:UI 渲大字码 + URL,自己按 `intervalMs` 起节拍调 `poll()`
|
|
108
|
+
* (等待型判据,不固定拍)。包内不起定时器 —— 定时器是宿主资产(与 `TimersPort` 同一条纪律)。
|
|
109
|
+
*/
|
|
110
|
+
export interface DeviceCodeSession {
|
|
111
|
+
/** 用户要在浏览器里输入的大字码。 */
|
|
112
|
+
userCode: string;
|
|
113
|
+
/** 用户要打开的验证页。 */
|
|
114
|
+
verificationUrl: string;
|
|
115
|
+
/** 建议轮询间隔(毫秒)。429 / slow_down 之后会**变大**,宿主每拍都该重读这个值。 */
|
|
116
|
+
intervalMs: number;
|
|
117
|
+
/** 本会话的墙钟终点(min(服务端 expires_in, 15min 上限))。 */
|
|
118
|
+
expiresAtMs: number;
|
|
119
|
+
poll(): Promise<DeviceCodePollResult>;
|
|
120
|
+
cancel(): void;
|
|
121
|
+
}
|
|
122
|
+
/**
|
|
123
|
+
* 设备码流的宿主注入面。
|
|
124
|
+
* 🔴 `nowMs` **必填**:5s 间隔与 15min 上限都是墙钟判据,而本包绝不偷读时钟
|
|
125
|
+
* (与 `catalog.ts` / `AdapterContext.now` 同一口径)。宿主传 `() => Date.now()`。
|
|
126
|
+
*/
|
|
127
|
+
export interface DeviceCodeAuthOptions {
|
|
128
|
+
nowMs: () => number;
|
|
129
|
+
/** 传输注入口(缺省全局 `fetch`)。 */
|
|
130
|
+
fetchImpl?: typeof fetch;
|
|
131
|
+
/** 单次 HTTP 预算(缺省 `DEVICE_CODE_HTTP_TIMEOUT_MS`)。 */
|
|
132
|
+
timeoutMs?: number;
|
|
133
|
+
}
|
|
134
|
+
/** 轮询间隔下限(RFC 8628 建议 5s;服务端给更大的值时听服务端的)。 */
|
|
135
|
+
export declare const DEVICE_CODE_MIN_INTERVAL_MS = 5000;
|
|
136
|
+
/** 会话墙钟上限(design/166 §5:15min)。服务端 `expires_in` 更短时听服务端的。 */
|
|
137
|
+
export declare const DEVICE_CODE_MAX_LIFETIME_MS: number;
|
|
138
|
+
/** 429 / slow_down 的退避增量(RFC 8628 §3.5 就是「加 5 秒」)。 */
|
|
139
|
+
export declare const DEVICE_CODE_BACKOFF_STEP_MS = 5000;
|
|
140
|
+
/** 单次 HTTP 预算。 */
|
|
141
|
+
export declare const DEVICE_CODE_HTTP_TIMEOUT_MS = 10000;
|
|
142
|
+
/**
|
|
143
|
+
* 开一次设备码会话 —— **端点由调用方给**(包内表里那一行,或门的合成 descriptor)。
|
|
144
|
+
* 🔴 端点是**编译进包的常量**,不是远端数据,所以本函数不对它再做白名单;真正的门是
|
|
145
|
+
* 「client_id/端点绝不目录化下发」(design/165 §2 红线)+ 表内每行必须 https(门 ④a)。
|
|
146
|
+
*
|
|
147
|
+
* 失败(网络 / 非 2xx / 缺 user_code)⇒ **throw**(§C1 三选一里的 throw):开不出会话就是开不出,
|
|
148
|
+
* 绝不返回一个永远 pending 的假会话。
|
|
149
|
+
*/
|
|
150
|
+
export declare function openDeviceCodeSession(endpoints: DeviceCodeEndpoints, opts: DeviceCodeAuthOptions): Promise<DeviceCodeSession>;
|
|
151
|
+
/**
|
|
152
|
+
* 按 providerId 开设备码会话(UI 入口)。
|
|
153
|
+
* 包内没有这一家 ⇒ **throw**(UI 本就不该显示这个选项;走到这里说明能力判据被绕过了)。
|
|
154
|
+
*/
|
|
155
|
+
export declare function beginDeviceCodeAuth(providerId: string, opts: DeviceCodeAuthOptions): Promise<DeviceCodeSession>;
|
|
@@ -0,0 +1,190 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* 包内实现的设备码 provider 表。
|
|
3
|
+
* 🔴 **今天是空的,理由见文件头**(宁空勿假:没有我们自己名下的 client_id 之前,填一行 = 冒用)。
|
|
4
|
+
* 加一家 = 加一行;端点必须 https(门 ④a 逐行校)。
|
|
5
|
+
*/
|
|
6
|
+
export const DEVICE_AUTH_PROVIDERS = [];
|
|
7
|
+
/** 包内是否实现了这一家(能力真值的一半)。 */
|
|
8
|
+
export function deviceAuthProviderFor(providerId) {
|
|
9
|
+
return DEVICE_AUTH_PROVIDERS.find((p) => p.providerId === providerId);
|
|
10
|
+
}
|
|
11
|
+
/**
|
|
12
|
+
* UI 是否显示「设备码登录」= **目录说有** ∧ **包里有实现**。
|
|
13
|
+
* 任一缺席 ⇒ false(见文件头「能力真值」段)。
|
|
14
|
+
*/
|
|
15
|
+
export function supportsDeviceCodeAuth(providerId, catalogHint) {
|
|
16
|
+
if (catalogHint?.kind !== 'device_code')
|
|
17
|
+
return false;
|
|
18
|
+
return deviceAuthProviderFor(providerId) !== undefined;
|
|
19
|
+
}
|
|
20
|
+
/**
|
|
21
|
+
* 一家 provider 摆给用户的轨(顺序 = UI 呈现序)。`api_key` 恒在;`device_code` 只在能力真值成立时出。
|
|
22
|
+
*/
|
|
23
|
+
export function providerAuthMethods(preset) {
|
|
24
|
+
const out = [{ kind: 'api_key', env: preset.authEnv }];
|
|
25
|
+
if (supportsDeviceCodeAuth(preset.id, preset.deviceAuth)) {
|
|
26
|
+
out.push({ kind: 'device_code', providerId: preset.id });
|
|
27
|
+
}
|
|
28
|
+
return out;
|
|
29
|
+
}
|
|
30
|
+
/** 轮询间隔下限(RFC 8628 建议 5s;服务端给更大的值时听服务端的)。 */
|
|
31
|
+
export const DEVICE_CODE_MIN_INTERVAL_MS = 5000;
|
|
32
|
+
/** 会话墙钟上限(design/166 §5:15min)。服务端 `expires_in` 更短时听服务端的。 */
|
|
33
|
+
export const DEVICE_CODE_MAX_LIFETIME_MS = 15 * 60 * 1000;
|
|
34
|
+
/** 429 / slow_down 的退避增量(RFC 8628 §3.5 就是「加 5 秒」)。 */
|
|
35
|
+
export const DEVICE_CODE_BACKOFF_STEP_MS = 5000;
|
|
36
|
+
/** 单次 HTTP 预算。 */
|
|
37
|
+
export const DEVICE_CODE_HTTP_TIMEOUT_MS = 10_000;
|
|
38
|
+
/** 异常 → 短 detail(绝不带栈、绝不带响应体)。 */
|
|
39
|
+
function shortError(e) {
|
|
40
|
+
return (e instanceof Error ? e.message : String(e)).slice(0, 160);
|
|
41
|
+
}
|
|
42
|
+
/** POST application/x-www-form-urlencoded,收 JSON。返回 {status, body}(body 解析不动 ⇒ null)。 */
|
|
43
|
+
async function postForm(url, form, fetchImpl, timeoutMs, signal) {
|
|
44
|
+
const controller = new AbortController();
|
|
45
|
+
const onAbort = () => controller.abort();
|
|
46
|
+
if (signal.aborted)
|
|
47
|
+
controller.abort();
|
|
48
|
+
else
|
|
49
|
+
signal.addEventListener('abort', onAbort, { once: true });
|
|
50
|
+
const timer = setTimeout(() => controller.abort(), timeoutMs);
|
|
51
|
+
try {
|
|
52
|
+
const res = await fetchImpl(url, {
|
|
53
|
+
method: 'POST',
|
|
54
|
+
headers: { accept: 'application/json', 'content-type': 'application/x-www-form-urlencoded' },
|
|
55
|
+
body: new URLSearchParams(form).toString(),
|
|
56
|
+
signal: controller.signal,
|
|
57
|
+
});
|
|
58
|
+
const text = await res.text();
|
|
59
|
+
let body = null;
|
|
60
|
+
try {
|
|
61
|
+
body = JSON.parse(text);
|
|
62
|
+
}
|
|
63
|
+
catch {
|
|
64
|
+
body = null; // 非 JSON 响应 ⇒ 当作「说不出所以然」,由调用方按 status 判(绝不回显正文)
|
|
65
|
+
}
|
|
66
|
+
return { status: res.status, body };
|
|
67
|
+
}
|
|
68
|
+
finally {
|
|
69
|
+
clearTimeout(timer);
|
|
70
|
+
signal.removeEventListener('abort', onAbort);
|
|
71
|
+
}
|
|
72
|
+
}
|
|
73
|
+
/**
|
|
74
|
+
* 开一次设备码会话 —— **端点由调用方给**(包内表里那一行,或门的合成 descriptor)。
|
|
75
|
+
* 🔴 端点是**编译进包的常量**,不是远端数据,所以本函数不对它再做白名单;真正的门是
|
|
76
|
+
* 「client_id/端点绝不目录化下发」(design/165 §2 红线)+ 表内每行必须 https(门 ④a)。
|
|
77
|
+
*
|
|
78
|
+
* 失败(网络 / 非 2xx / 缺 user_code)⇒ **throw**(§C1 三选一里的 throw):开不出会话就是开不出,
|
|
79
|
+
* 绝不返回一个永远 pending 的假会话。
|
|
80
|
+
*/
|
|
81
|
+
export async function openDeviceCodeSession(endpoints, opts) {
|
|
82
|
+
const fetchImpl = opts.fetchImpl ?? fetch;
|
|
83
|
+
const timeoutMs = opts.timeoutMs ?? DEVICE_CODE_HTTP_TIMEOUT_MS;
|
|
84
|
+
const abort = new AbortController();
|
|
85
|
+
const started = opts.nowMs();
|
|
86
|
+
const { status, body } = await postForm(endpoints.deviceCodeUrl, { client_id: endpoints.clientId, ...(endpoints.scope !== undefined ? { scope: endpoints.scope } : {}) }, fetchImpl, timeoutMs, abort.signal);
|
|
87
|
+
if (status < 200 || status >= 300) {
|
|
88
|
+
throw new Error(`device code request failed: HTTP ${status}`);
|
|
89
|
+
}
|
|
90
|
+
const grant = (body ?? {});
|
|
91
|
+
const deviceCode = typeof grant.device_code === 'string' ? grant.device_code : '';
|
|
92
|
+
const userCode = typeof grant.user_code === 'string' ? grant.user_code : '';
|
|
93
|
+
const verificationUrl = typeof grant.verification_uri_complete === 'string'
|
|
94
|
+
? grant.verification_uri_complete
|
|
95
|
+
: typeof grant.verification_uri === 'string'
|
|
96
|
+
? grant.verification_uri
|
|
97
|
+
: '';
|
|
98
|
+
if (deviceCode === '' || userCode === '' || verificationUrl === '') {
|
|
99
|
+
throw new Error('device code response missing device_code / user_code / verification_uri');
|
|
100
|
+
}
|
|
101
|
+
const serverLifetimeMs = typeof grant.expires_in === 'number' && grant.expires_in > 0 ? grant.expires_in * 1000 : undefined;
|
|
102
|
+
const lifetimeMs = Math.min(serverLifetimeMs ?? DEVICE_CODE_MAX_LIFETIME_MS, DEVICE_CODE_MAX_LIFETIME_MS);
|
|
103
|
+
const serverInterval = typeof grant.interval === 'number' && grant.interval > 0 ? grant.interval * 1000 : undefined;
|
|
104
|
+
const session = {
|
|
105
|
+
userCode,
|
|
106
|
+
verificationUrl,
|
|
107
|
+
intervalMs: Math.max(serverInterval ?? DEVICE_CODE_MIN_INTERVAL_MS, DEVICE_CODE_MIN_INTERVAL_MS),
|
|
108
|
+
expiresAtMs: started + lifetimeMs,
|
|
109
|
+
poll: async () => poll(),
|
|
110
|
+
cancel: () => {
|
|
111
|
+
if (terminal === null)
|
|
112
|
+
terminal = { status: 'cancelled', detail: 'cancelled by the host' };
|
|
113
|
+
abort.abort();
|
|
114
|
+
},
|
|
115
|
+
};
|
|
116
|
+
/** 终态一旦落定就恒定(拿到 key 之后再 poll 不重复拨号)。 */
|
|
117
|
+
let terminal = null;
|
|
118
|
+
/** 下一次允许真正拨号的墙钟时刻(间隔是硬的,不靠调用方自律)。 */
|
|
119
|
+
let nextDialAtMs = started;
|
|
120
|
+
async function poll() {
|
|
121
|
+
if (terminal !== null)
|
|
122
|
+
return terminal;
|
|
123
|
+
const now = opts.nowMs();
|
|
124
|
+
if (now >= session.expiresAtMs) {
|
|
125
|
+
// 本地上限先于服务端判:上限是我们对用户的承诺,不该靠服务端良心。
|
|
126
|
+
terminal = { status: 'expired', detail: 'device code session exceeded its lifetime — start a new one' };
|
|
127
|
+
return terminal;
|
|
128
|
+
}
|
|
129
|
+
if (now < nextDialAtMs) {
|
|
130
|
+
return { status: 'pending', detail: 'polled before the interval elapsed — no request was sent' };
|
|
131
|
+
}
|
|
132
|
+
let res;
|
|
133
|
+
try {
|
|
134
|
+
res = await postForm(endpoints.tokenUrl, {
|
|
135
|
+
client_id: endpoints.clientId,
|
|
136
|
+
device_code: deviceCode,
|
|
137
|
+
grant_type: 'urn:ietf:params:oauth:grant-type:device_code',
|
|
138
|
+
}, fetchImpl, timeoutMs, abort.signal);
|
|
139
|
+
}
|
|
140
|
+
catch (e) {
|
|
141
|
+
if (terminal !== null)
|
|
142
|
+
return terminal; // cancel() 把在飞请求打断了 —— 终态已落定
|
|
143
|
+
// 单次网络失败不是终态:设备码流本就是长轮询,下一拍再试(退避一格避免打点)。
|
|
144
|
+
nextDialAtMs = opts.nowMs() + session.intervalMs;
|
|
145
|
+
return { status: 'pending', detail: `poll failed, will retry: ${shortError(e)}` };
|
|
146
|
+
}
|
|
147
|
+
if (terminal !== null)
|
|
148
|
+
return terminal;
|
|
149
|
+
nextDialAtMs = opts.nowMs() + session.intervalMs;
|
|
150
|
+
const token = (res.body ?? {});
|
|
151
|
+
const err = typeof token.error === 'string' ? token.error : '';
|
|
152
|
+
if (res.status === 429 || err === 'slow_down') {
|
|
153
|
+
session.intervalMs += DEVICE_CODE_BACKOFF_STEP_MS;
|
|
154
|
+
nextDialAtMs = opts.nowMs() + session.intervalMs;
|
|
155
|
+
return { status: 'pending', detail: 'rate limited — backing off' };
|
|
156
|
+
}
|
|
157
|
+
if (typeof token.access_token === 'string' && token.access_token.length > 0) {
|
|
158
|
+
terminal = { status: 'ok', key: token.access_token };
|
|
159
|
+
return terminal;
|
|
160
|
+
}
|
|
161
|
+
if (err === 'authorization_pending')
|
|
162
|
+
return { status: 'pending' };
|
|
163
|
+
if (err === 'expired_token') {
|
|
164
|
+
terminal = { status: 'expired', detail: 'the device code expired — start a new one' };
|
|
165
|
+
return terminal;
|
|
166
|
+
}
|
|
167
|
+
if (err === 'access_denied') {
|
|
168
|
+
terminal = { status: 'denied', detail: 'the user declined the authorization request' };
|
|
169
|
+
return terminal;
|
|
170
|
+
}
|
|
171
|
+
if (res.status < 200 || res.status >= 300) {
|
|
172
|
+
return { status: 'pending', detail: `poll got HTTP ${res.status}, will retry` };
|
|
173
|
+
}
|
|
174
|
+
// 认不出的 error 码:不冒充终态(别把一个我们没见过的码说成「过期了」),下一拍再试。
|
|
175
|
+
return { status: 'pending', detail: err === '' ? 'unrecognised token response' : `unrecognised error: ${err}` };
|
|
176
|
+
}
|
|
177
|
+
return session;
|
|
178
|
+
}
|
|
179
|
+
/**
|
|
180
|
+
* 按 providerId 开设备码会话(UI 入口)。
|
|
181
|
+
* 包内没有这一家 ⇒ **throw**(UI 本就不该显示这个选项;走到这里说明能力判据被绕过了)。
|
|
182
|
+
*/
|
|
183
|
+
export async function beginDeviceCodeAuth(providerId, opts) {
|
|
184
|
+
const provider = deviceAuthProviderFor(providerId);
|
|
185
|
+
if (provider === undefined) {
|
|
186
|
+
throw new Error(`device code auth is not supported for provider "${providerId}" in this build ` +
|
|
187
|
+
'(DEVICE_AUTH_PROVIDERS has no entry — the UI must gate on supportsDeviceCodeAuth())');
|
|
188
|
+
}
|
|
189
|
+
return openDeviceCodeSession(provider.endpoints, opts);
|
|
190
|
+
}
|
|
@@ -25,7 +25,23 @@ export type ProviderPreset = {
|
|
|
25
25
|
consoleUrl?: string;
|
|
26
26
|
/** 没账号时的注册入口。与 `consoleUrl` 同页的家不重复填(缺席 = 用 consoleUrl 那条)。 */
|
|
27
27
|
signupUrl?: string;
|
|
28
|
+
/**
|
|
29
|
+
* 🆕 design/165 §2 + design/166 §5(2026-08-04):**设备码能力开关**(B 档)。
|
|
30
|
+
*
|
|
31
|
+
* 🔴 它只是**开关**,不携带 client_id / token 端点 / authorize 端点 —— 那些编译进
|
|
32
|
+
* `model/providerAuth.ts` 的 `DEVICE_AUTH_PROVIDERS`。理由:目录是远端可改的数据,
|
|
33
|
+
* 让它决定鉴权端点 = 把钓鱼面下发给客户端(design/165 §2 红线④,CI 的 validate.mjs
|
|
34
|
+
* 里有对应的机械不变式)。
|
|
35
|
+
* 🔴 能力真值 = **本键在场 ∧ 包内有实现**(`supportsDeviceCodeAuth`)——目录先行不造 affordance。
|
|
36
|
+
*/
|
|
37
|
+
deviceAuth?: ProviderDeviceAuthHint;
|
|
28
38
|
};
|
|
39
|
+
/** 目录里的设备码能力开关(只有 kind 与文档链接;凭证参数一律不进目录)。 */
|
|
40
|
+
export interface ProviderDeviceAuthHint {
|
|
41
|
+
kind: 'device_code';
|
|
42
|
+
/** 该家自己的设备码说明页(纯链接;拿不准就不填,绝不编 URL)。 */
|
|
43
|
+
docsUrl?: string;
|
|
44
|
+
}
|
|
29
45
|
export type ModelFamily = {
|
|
30
46
|
id: string;
|
|
31
47
|
name: string;
|
package/dist/notifications.d.ts
CHANGED
|
@@ -202,6 +202,10 @@ export declare function outstandingAbandonedCount(): number;
|
|
|
202
202
|
export interface NotificationDropCounters {
|
|
203
203
|
bgDedupDropped: number;
|
|
204
204
|
bgCrossChannelDropped: number;
|
|
205
|
+
/** 🔴 [2393] notif-F1:workflow 侧「已通知过」而被静默吞掉的投递次数(第三处漏网拼法)。
|
|
206
|
+
* 与 bg 两格同理:去重与误吞在行为上都是「不入队」,不记账就在观测上等价。恒可 >0(推送帧与
|
|
207
|
+
* probe 链双投自免是正当的);**异常增长** = runId 铸法变了或两条链 id 不同域,真完成在被吞。 */
|
|
208
|
+
workflowDedupDropped: number;
|
|
205
209
|
stuckTickResets: number;
|
|
206
210
|
bgWatchRevivalPromoted: number;
|
|
207
211
|
bgWatchCollectedUnprobed: number;
|
package/dist/notifications.js
CHANGED
|
@@ -381,6 +381,19 @@ const outstandingRuns = new Map(); // runId → registeredAt(ms)
|
|
|
381
381
|
// workflow(s) to finish」(CC 2.1.212 同形,wf-ui-cc2 ground truth)。仅通知计数变化 ——
|
|
382
382
|
// notif-03 起「计数」含 `outstandingAbandonedCount()`,故 bg 半场的 TTL 放弃也发一次通知
|
|
383
383
|
// (那一格变了,消费方要重渲「N 个后台任务已停止等待」)。
|
|
384
|
+
//
|
|
385
|
+
// 🔴 [2393] notif-F9(2026-08-03 全窗复审)**判据纠偏 + 契约写清**(裁定:机制不改,把口径写死)。
|
|
386
|
+
// 复审的观察是「bg 半场信号不对称:放弃发通知、登记不发,而订阅面两个计数口都不含 bg ⇒ 一次 bg
|
|
387
|
+
// 放弃叫醒全部订阅者却什么都没变(空转重渲)」。逐口核过之后这条**不成立**,但它指出的真缺口
|
|
388
|
+
// (契约没写清)是真的,所以写在这里:
|
|
389
|
+
// · 订阅契约 = 「**任一可读计数**发生变化」,可读计数恰好三个:`outstandingWorkflowCount()` /
|
|
390
|
+
// `outstandingDeliverableWorkflowCount()` / `outstandingAbandonedCount()`;
|
|
391
|
+
// · bg **放弃**必须发:它改变第三个(`abandonedCount++`)—— 不是空转;
|
|
392
|
+
// · bg **登记**必须不发:bg 在飞数**没有**可读口(刻意:headless 退出门只认 workflow 半场,
|
|
393
|
+
// 见 `outstandingDeliverableWorkflowCount` 的头注),不改变任何可读量的事件不许叫醒订阅者
|
|
394
|
+
// (叫醒 = 每次 async_launched 回执触发一次全树重渲,而消费方一个字都读不到新东西)。
|
|
395
|
+
// ⇒ 「不对称」是这条契约的**正确后果**,不是漏。谁想给 bg 在飞数开一个可读口,必须同批让
|
|
396
|
+
// `registerOutstandingBgTask` 发通知(两半共享前提,[paired-mechanisms-must-share-premise])。
|
|
384
397
|
const outstandingListeners = new Set();
|
|
385
398
|
function notifyOutstanding() {
|
|
386
399
|
for (const l of [...outstandingListeners]) {
|
|
@@ -417,8 +430,20 @@ function abandonOutstanding(kind, id, reason) {
|
|
|
417
430
|
if (!removed)
|
|
418
431
|
return;
|
|
419
432
|
abandonedCount++;
|
|
420
|
-
|
|
421
|
-
'no completion notification will ever be delivered for it'
|
|
433
|
+
const line = `abandoned ${kind} ${id} — reason=${reason}, waited > WATCH_TTL_MS(${WATCH_TTL_MS}ms); ` +
|
|
434
|
+
'no completion notification will ever be delivered for it';
|
|
435
|
+
traceNotif(line);
|
|
436
|
+
// 🔴 [2393] notif-F2(2026-08-03 全窗复审)**边界定谳**:本条修复到库边界为止,用户可见那一端
|
|
437
|
+
// 由宿主闭合,已挂提货单(`docs/refactor/README.md`「提货单 / 破坏性变更清单」节的**宿主侧待办**表,
|
|
438
|
+
// 背景与「库侧为什么停在这里」见 `docs/refactor/WAVE2-RESIDUALS.md` [2393] 那一节第 2 条)。
|
|
439
|
+
// 为什么不在库内补偿:
|
|
440
|
+
// · `traceNotif` 受 SEMA_DEBUG 门控是**刻意**的 —— 那是诊断面;把放弃行为默认打进用户 stderr
|
|
441
|
+
// 等于库替宿主决定 UI(headless `-p` 的 stdout 是协议面,污染它是真回归);
|
|
442
|
+
// · 走 `hostLog` 口需要 `notifications.ts` import `host.ts`,实测会把 **A 层闭包 22→24 文件**
|
|
443
|
+
// (`run-client-core-portability-test.mjs` 的 `MAX_ADAPT_CLOSURE_FILES` 只许降)—— 为一条诊断
|
|
444
|
+
// 线把 A 层拽进宿主口体系,代价大于收益([no-hasty-compensation-final-fix-first] 三问);
|
|
445
|
+
// · 用户可见那一端的**量**已经在场且可读:`outstandingAbandonedCount()`(+ `notificationDropCounters()`
|
|
446
|
+
// 六格)。缺的是三端的渲染,那是宿主工单,不是库内补偿位。
|
|
422
447
|
notifyOutstanding();
|
|
423
448
|
}
|
|
424
449
|
/**
|
|
@@ -431,11 +456,19 @@ export function outstandingAbandonedCount() {
|
|
|
431
456
|
}
|
|
432
457
|
let bgDedupDropped = 0;
|
|
433
458
|
let bgCrossChannelDropped = 0;
|
|
459
|
+
let workflowDedupDropped = 0;
|
|
434
460
|
let stuckTickResets = 0;
|
|
435
461
|
let bgWatchRevivalPromoted = 0;
|
|
436
462
|
let bgWatchCollectedUnprobed = 0;
|
|
437
463
|
export function notificationDropCounters() {
|
|
438
|
-
return {
|
|
464
|
+
return {
|
|
465
|
+
bgDedupDropped,
|
|
466
|
+
bgCrossChannelDropped,
|
|
467
|
+
workflowDedupDropped,
|
|
468
|
+
stuckTickResets,
|
|
469
|
+
bgWatchRevivalPromoted,
|
|
470
|
+
bgWatchCollectedUnprobed,
|
|
471
|
+
};
|
|
439
472
|
}
|
|
440
473
|
let statusProbe = null;
|
|
441
474
|
let watchTimer = null;
|
|
@@ -447,6 +480,18 @@ export function installWorkflowStatusProbe(probe) {
|
|
|
447
480
|
}
|
|
448
481
|
/** bg 子代生命周期号的首周期值(SendMessage 复活即 +1;wire 缺 seq ⇒ 按首周期解释)。 */
|
|
449
482
|
const BG_FIRST_SEQ = 1;
|
|
483
|
+
/**
|
|
484
|
+
* 🔴 [2393] notif-F8(2026-08-03 全窗复审):seq 归一的**唯一铸点**。
|
|
485
|
+
* 修前有两处各写各的:登记口 `Number.isFinite && >= BG_FIRST_SEQ` 才收(否则钳到首周期,再 floor),
|
|
486
|
+
* enqueue 口只有 `n.seq ?? BG_FIRST_SEQ`(零校验),而帧臂(`fleet/fleetLedger.ts` 的
|
|
487
|
+
* `applyBgNotification`)是**原样透传**。后果不是「多写一遍」而是两条通道的去重键会**错开**:
|
|
488
|
+
* server 哪天发 0 基 seq(或小数)⇒ watcher 合成走已钳成 1 的键 `id:1:status`,帧通道走原值键
|
|
489
|
+
* `id:0:status`,两键不撞 ⇒ 跨通道去重整体失效,同一次完成被喂给模型两遍。
|
|
490
|
+
* notif-02 立的是「键必须含 seq」,归一没收成单口就等于键的**语义**没收口。
|
|
491
|
+
*/
|
|
492
|
+
function normalizeBgSeq(seq) {
|
|
493
|
+
return typeof seq === 'number' && Number.isFinite(seq) && seq >= BG_FIRST_SEQ ? Math.floor(seq) : BG_FIRST_SEQ;
|
|
494
|
+
}
|
|
450
495
|
/**
|
|
451
496
|
* 🔴 notif-02:观察台账的键是 **(taskId, seq) 复合键**,不是裸 taskId。
|
|
452
497
|
* 裸 taskId 时 seq≥2 的复活周期与首周期同键 ⇒ 复活周期结构性进不了台账,而 watcher 存在的
|
|
@@ -495,7 +540,7 @@ export function registerOutstandingBgTask(taskId, description, prompt, seq) {
|
|
|
495
540
|
if (typeof prompt === 'string' && prompt.length > 0 && !bgTaskPrompts.has(taskId)) {
|
|
496
541
|
bgTaskPrompts.set(taskId, prompt);
|
|
497
542
|
}
|
|
498
|
-
const cycle =
|
|
543
|
+
const cycle = normalizeBgSeq(seq); // notif-F8:唯一铸点
|
|
499
544
|
const key = bgOutstandingKey(taskId, cycle);
|
|
500
545
|
if (outstandingBgTasks.has(key))
|
|
501
546
|
return;
|
|
@@ -595,7 +640,14 @@ function withProbeDeadline(p, timeoutMs) {
|
|
|
595
640
|
const timer = setTimeout(() => {
|
|
596
641
|
reject(new ProbeDeadlineError(timeoutMs));
|
|
597
642
|
}, timeoutMs);
|
|
598
|
-
|
|
643
|
+
// 🔴 [2393] notif-F7(2026-08-03 全窗复审)**不 unref**:这正是 `unrefTimer.ts:6-9` 逐字写死的
|
|
644
|
+
// 域词表-13 定谳所指的那一类 —— 「被 await 的 race/budget 臂上的定时器绝不能 unref(会让进程
|
|
645
|
+
// 在它 settle 前提前退出,RB-447 同形)」。本 deadline 定时器是 `await withProbeDeadline(...)`
|
|
646
|
+
// **唯一的 settle 来源**(probe 永不 settle 时):unref 之后若宿主此刻没有别的活句柄
|
|
647
|
+
// (watchTimer 自己也是 unref 的),事件循环判定可退出,回调不再执行,那一拍 tick 永不 settle
|
|
648
|
+
// —— notif-01① 「必有 settle 路径」的保证就不是自持的,而是押在「宿主别处总有活句柄」上。
|
|
649
|
+
// 两条成对臂已经保证它不会拖住进程:①probe settle 时下面两个 `clearTimeout(timer)` 必执行;
|
|
650
|
+
// ②它自己到点就 reject 并被 clear,寿命上界 = timeoutMs(默认 PROBE_TIMEOUT_MS)。
|
|
599
651
|
p.then(v => {
|
|
600
652
|
clearTimeout(timer);
|
|
601
653
|
resolve(v);
|
|
@@ -616,14 +668,46 @@ let tickGeneration = 0;
|
|
|
616
668
|
* 🔴 必须在重入护栏**之前**跑:清扫住在 tickWatchInner 里时,一个卡死的 probe 会连坐 TTL,
|
|
617
669
|
* 于是「2h 上界」这个 `outstandingDeliverableWorkflowCount()` 正当性的另一半也一起失效。
|
|
618
670
|
*/
|
|
671
|
+
/**
|
|
672
|
+
* 🔴 [2393] notif-F10(2026-08-03 全窗复审):**正被在飞 probe 臂持有**的键集(bg 侧存复合键,
|
|
673
|
+
* workflow 侧存 runId)。`abandonOutstanding` 自己的前提是「放弃必须是**真发生过**的事」,但它
|
|
674
|
+
* 上一版只挡住了「键不在台账」,没挡住「键正被在飞臂持有」:上一拍 probe 还在飞(它手里握着这
|
|
675
|
+
* 条目的快照),这一拍 sweep 判它超 TTL ⇒ 计数 +1 并打出「no completion notification will ever
|
|
676
|
+
* be delivered for it」,几十毫秒后那个 probe 返回 terminal ⇒ 照常合成并投递通知。
|
|
677
|
+
* 留痕是假话,`outstandingAbandonedCount()`(消费方据以渲「N 个后台任务已停止等待」的诚实计数)
|
|
678
|
+
* 被灌一格假数 —— 正是 notif-03 立案要根治的那一类观测面撒谎。
|
|
679
|
+
* 🔴 让位必须**自己有界**,否则它就变成 notif-01③ 刚修掉的那个形(「清扫住在 tick 体内 ⇒ 一个卡死的
|
|
680
|
+
* probe 连坐 TTL ⇒ headless 退出门恒真、进程永不退出」)。所以记的是**在飞臂的起始时刻**,
|
|
681
|
+
* 让位只在「这条臂还没超过它自己的 deadline」时成立:臂一旦活过 `probeTimeoutMs()`(= 它本该被
|
|
682
|
+
* 截断的时刻),它就不再是「马上会给出答案的那条臂」,TTL 照常执行。
|
|
683
|
+
* ⇒ 正常路径:多等一拍(5s),换来放弃计数不被灌假数;病理路径:与修前逐字同形,liveness 不变。
|
|
684
|
+
*/
|
|
685
|
+
const bgProbeInFlight = new Map();
|
|
686
|
+
const runProbeInFlight = new Map();
|
|
687
|
+
/** 在飞臂是否仍在它自己的 deadline 之内(超了就不再让位 —— 见上方 notif-F10 头注)。 */
|
|
688
|
+
function probeArmStillWithinDeadline(startedAt, now) {
|
|
689
|
+
return startedAt !== undefined && now - startedAt <= probeTimeoutMs();
|
|
690
|
+
}
|
|
619
691
|
function sweepExpiredOutstanding(now) {
|
|
620
692
|
for (const [key, meta] of [...outstandingBgTasks]) {
|
|
621
|
-
if (now - meta.registeredAt
|
|
622
|
-
|
|
693
|
+
if (now - meta.registeredAt <= WATCH_TTL_MS)
|
|
694
|
+
continue;
|
|
695
|
+
if (probeArmStillWithinDeadline(bgProbeInFlight.get(key), now)) {
|
|
696
|
+
// notif-F10:在飞臂正握着它、且还没超它自己的 deadline —— 这一拍不判放弃
|
|
697
|
+
// (判了就可能马上被那条臂证伪:计数与留痕当场成假话)。
|
|
698
|
+
traceNotif(`ttl sweep deferred for bg-task ${key}: a probe arm is still in flight within its deadline`);
|
|
699
|
+
continue;
|
|
700
|
+
}
|
|
701
|
+
abandonOutstanding('bg-task', key, 'ttl');
|
|
623
702
|
}
|
|
624
703
|
for (const [runId, registeredAt] of [...outstandingRuns]) {
|
|
625
|
-
if (now - registeredAt
|
|
626
|
-
|
|
704
|
+
if (now - registeredAt <= WATCH_TTL_MS)
|
|
705
|
+
continue;
|
|
706
|
+
if (probeArmStillWithinDeadline(runProbeInFlight.get(runId), now)) {
|
|
707
|
+
traceNotif(`ttl sweep deferred for workflow-run ${runId}: a probe arm is still in flight within its deadline`);
|
|
708
|
+
continue;
|
|
709
|
+
}
|
|
710
|
+
abandonOutstanding('workflow-run', runId, 'ttl');
|
|
627
711
|
}
|
|
628
712
|
}
|
|
629
713
|
/** @param nowMs 时钟注入(仅测试钩用;生产走 Date.now())。 */
|
|
@@ -647,7 +731,7 @@ async function tickWatch(nowMs) {
|
|
|
647
731
|
const myGeneration = ++tickGeneration;
|
|
648
732
|
tickStartedAtMs = now;
|
|
649
733
|
try {
|
|
650
|
-
await tickWatchInner();
|
|
734
|
+
await tickWatchInner(now);
|
|
651
735
|
}
|
|
652
736
|
finally {
|
|
653
737
|
// 被强制复位过就别清:那面时间戳已经属于后来的那一拍了。
|
|
@@ -655,7 +739,9 @@ async function tickWatch(nowMs) {
|
|
|
655
739
|
tickStartedAtMs = null;
|
|
656
740
|
}
|
|
657
741
|
}
|
|
658
|
-
|
|
742
|
+
/** @param now 本拍的时钟(与 `sweepExpiredOutstanding` **同一个来源** —— notif-F10 的让位判据比的是
|
|
743
|
+
* 「在飞臂活了多久 vs 它自己的 deadline」,两边取不同的钟就会得出 3 小时前起飞的荒谬结论)。 */
|
|
744
|
+
async function tickWatchInner(now) {
|
|
659
745
|
// bg agent 半场(与 workflow 半场同节拍;TTL 已提到 tickWatch 的护栏之前统一清扫。
|
|
660
746
|
// probe 未装=mock/离线,只等推送补发)
|
|
661
747
|
const bgProbe = bgStatusProbe;
|
|
@@ -686,6 +772,7 @@ async function tickWatchInner() {
|
|
|
686
772
|
continue;
|
|
687
773
|
}
|
|
688
774
|
let alive;
|
|
775
|
+
bgProbeInFlight.set(key, now); // notif-F10:在飞期间(且未超 deadline)sweep 不许把它判成「放弃」
|
|
689
776
|
try {
|
|
690
777
|
const res = await withProbeDeadline(bgProbe(meta.taskId), probeTimeoutMs());
|
|
691
778
|
if (res === null || res === undefined)
|
|
@@ -695,6 +782,9 @@ async function tickWatchInner() {
|
|
|
695
782
|
catch {
|
|
696
783
|
continue; // 探测失败(含 ProbeDeadlineError)不构成删除证据
|
|
697
784
|
}
|
|
785
|
+
finally {
|
|
786
|
+
bgProbeInFlight.delete(key);
|
|
787
|
+
}
|
|
698
788
|
if (!alive) {
|
|
699
789
|
outstandingBgTasks.delete(key); // 真终局 = 已送达的那个周期,收摊
|
|
700
790
|
continue;
|
|
@@ -713,6 +803,7 @@ async function tickWatchInner() {
|
|
|
713
803
|
}
|
|
714
804
|
if (!bgProbe)
|
|
715
805
|
continue;
|
|
806
|
+
bgProbeInFlight.set(key, now); // notif-F10:同上 —— 在飞臂持有期间(未超 deadline)TTL 让位
|
|
716
807
|
try {
|
|
717
808
|
const res = await withProbeDeadline(bgProbe(meta.taskId), probeTimeoutMs());
|
|
718
809
|
if (res?.terminal) {
|
|
@@ -730,6 +821,9 @@ async function tickWatchInner() {
|
|
|
730
821
|
catch {
|
|
731
822
|
// 单次探测失败(含 ProbeDeadlineError 截断)不放弃:网络抖动照旧,TTL 兜底
|
|
732
823
|
}
|
|
824
|
+
finally {
|
|
825
|
+
bgProbeInFlight.delete(key);
|
|
826
|
+
}
|
|
733
827
|
}
|
|
734
828
|
const probe = statusProbe;
|
|
735
829
|
for (const runId of [...outstandingRuns.keys()]) {
|
|
@@ -740,6 +834,7 @@ async function tickWatchInner() {
|
|
|
740
834
|
}
|
|
741
835
|
if (!probe)
|
|
742
836
|
continue; // live client 未装(mock/离线)→ 只等推送
|
|
837
|
+
runProbeInFlight.set(runId, now); // notif-F10:在飞期间(且未超 deadline)sweep 不许把它判成「放弃」
|
|
743
838
|
try {
|
|
744
839
|
const res = await withProbeDeadline(probe(runId), probeTimeoutMs());
|
|
745
840
|
if (res?.terminal) {
|
|
@@ -755,6 +850,9 @@ async function tickWatchInner() {
|
|
|
755
850
|
catch {
|
|
756
851
|
// 单次探测失败(含 ProbeDeadlineError 截断)不放弃:网络抖动照旧,TTL 兜底
|
|
757
852
|
}
|
|
853
|
+
finally {
|
|
854
|
+
runProbeInFlight.delete(runId);
|
|
855
|
+
}
|
|
758
856
|
}
|
|
759
857
|
}
|
|
760
858
|
/**
|
|
@@ -765,7 +863,7 @@ async function tickWatchInner() {
|
|
|
765
863
|
*/
|
|
766
864
|
const bgNotifiedKeys = new Set();
|
|
767
865
|
export function enqueueBgChildNotification(n) {
|
|
768
|
-
const cycle = n.seq
|
|
866
|
+
const cycle = normalizeBgSeq(n.seq); // notif-F8:与登记口同一个铸点(零校验的 `?? 1` 会让两条通道的键错开)
|
|
769
867
|
const key = `${n.taskId}:${cycle}:${n.status}`;
|
|
770
868
|
// 🔴 notif-03 同族(C5):两条静默 return 都记账 + 留痕 —— 不记账时「按设计去重」与
|
|
771
869
|
// 「seq 丢了导致完成被吞」在观测上完全等价,而后者是用户永远收不到通知的那一类。
|
|
@@ -812,8 +910,17 @@ export function enqueueBgChildNotification(n) {
|
|
|
812
910
|
});
|
|
813
911
|
}
|
|
814
912
|
export function enqueueEngineWorkflowNotification(c) {
|
|
815
|
-
|
|
913
|
+
// 🔴 [2393] notif-F1(2026-08-03 全窗复审):这条静默 return 是 C5 收口的**第三处漏网拼法**。
|
|
914
|
+
// 同批把 bg 侧两条静默 return 都补了计数+留痕(上面 bgDedupDropped / bgCrossChannelDropped),
|
|
915
|
+
// 而它的孪生体 workflow 侧一条都没有 —— 而 `traceNotif` 的头注把执行标准写成「每条丢弃/放弃
|
|
916
|
+
// 路径都能在同一个前缀下 grep 到」。失败场景:`markEngineWorkflowNotified()`(外部通道 seed)
|
|
917
|
+
// 或 probe 链先记账后,推送帧再送同一 runId 的完成 ⇒ 这里静默吞掉,而「按设计去重」与
|
|
918
|
+
// 「一条真完成被误吞(runId 铸法变了 / 两条链 id 不同域)」在观测面上完全等价。
|
|
919
|
+
if (notifiedRunIds.has(c.runId)) {
|
|
920
|
+
workflowDedupDropped++;
|
|
921
|
+
traceNotif(`workflow notification suppressed (run already notified) runId=${c.runId} status=${c.status}`);
|
|
816
922
|
return;
|
|
923
|
+
}
|
|
817
924
|
// [2393] F-1:workflow 侧没有周期概念,周期维按首周期记(与 markEngineWorkflowNotified 同理)。
|
|
818
925
|
markRunNotified(c.runId);
|
|
819
926
|
cardEnqueuedRunIds.add(c.runId);
|
|
@@ -838,12 +945,26 @@ export function _resetEngineTaskNotificationForTest() {
|
|
|
838
945
|
ownWorkflowRuns.clear();
|
|
839
946
|
bgNotifiedKeys.clear();
|
|
840
947
|
outstandingListeners.clear();
|
|
948
|
+
// 🔴 [2393] notif-F11(2026-08-03 全窗复审):这三件此前漏清 —— 头注自称「清空**全部**通知台账」。
|
|
949
|
+
// · `bgTaskPrompts`:首值粘性(已登记的 taskId 不覆写),门里两个用例复用同一 taskId 时
|
|
950
|
+
// `outstandingBgTaskPrompt()` 会跨用例返回上一用例的 prompt,reset 拿不掉(既有钉侥幸没撞上,
|
|
951
|
+
// 不是被挡住了)。它的 process-lifetime 无逐出是**生产**决定(bg 子代 = 人手派发量级),
|
|
952
|
+
// 与测试钩要不要清是两件事。
|
|
953
|
+
// · `tickGeneration`:世代号不复位,跨用例累加(今天只做相等比较,不是现症;但「清空全部」
|
|
954
|
+
// 这句话要么成立要么改掉,不留半真)。
|
|
955
|
+
// · `bgProbeInFlight` / `runProbeInFlight`(notif-F10 新增):上一个用例的 probe 若被用例
|
|
956
|
+
// 提前收尾抛下,残留的键会让下一个用例的 TTL sweep 永远让位 = 静默失效的 TTL。
|
|
957
|
+
bgTaskPrompts.clear();
|
|
958
|
+
bgProbeInFlight.clear();
|
|
959
|
+
runProbeInFlight.clear();
|
|
960
|
+
tickGeneration = 0;
|
|
841
961
|
statusProbe = null;
|
|
842
962
|
bgStatusProbe = null;
|
|
843
963
|
tickStartedAtMs = null;
|
|
844
964
|
abandonedCount = 0;
|
|
845
965
|
bgDedupDropped = 0;
|
|
846
966
|
bgCrossChannelDropped = 0;
|
|
967
|
+
workflowDedupDropped = 0;
|
|
847
968
|
stuckTickResets = 0;
|
|
848
969
|
bgWatchRevivalPromoted = 0;
|
|
849
970
|
bgWatchCollectedUnprobed = 0;
|