dsh-model-switch 0.4.31 → 0.4.32
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/CHANGELOG.md +10 -0
- package/README.md +1 -1
- package/README.zh.md +1 -1
- package/lib/auto-review.js +141 -14
- package/lib/types/auto-review-takeover.d.ts +12 -2
- package/lib/types/auto-review.d.ts +35 -0
- package/package.json +1 -1
package/CHANGELOG.md
CHANGED
|
@@ -1,3 +1,13 @@
|
|
|
1
|
+
## v0.4.32
|
|
2
|
+
|
|
3
|
+
A hot install could still break every tool call: replacing the plugin (disable → remove → install → enable, **without a DSH restart**) leaves the official `auto-review` row serving while this row's takeover is still trying to displace it, and its unshielded probe fails each call. This release makes that window non-fatal and shortens it.
|
|
4
|
+
|
|
5
|
+
- **P0: a broken reviewer seam no longer fails tool calls (`src/auto-review.ts`).** `installToolCallSafetyNet` registers a `{ prepend: true }` `tools/pre-execute` listener on this row's **own context**, so it is live even when the gate could not mount — the one window the defensive `permissionPresets` shadow cannot cover, because that shadow lives in the review scope while the serving official gate resolves the real service from the profile. It catches only seam failures (`inactive context`, `cannot get required service`, `cannot get property`, `without inject`, `TRANSPORT`), returns `{ kind: 'allow' }` so the body dispatches, and reports once with the fiber-chain dump. It is **inert while this row holds the route** (`isDegraded: () => !presets.registered`), so another plugin's broken listener keeps failing closed; reviewer transport/quota errors (`RATE_LIMIT`, `PI_AI_ERROR`) keep failing closed too — "the reviewer declined" and "the reviewer could not talk" are not broken seams. The waterfall only decides whether the body runs, so allowing here never masks a tool failure.
|
|
6
|
+
- **The takeover budget matches a real disposal (`src/auto-review-takeover.ts`).** 20×10 ms (~200 ms) → 60×50 ms (~3 s), plus `TAKEOVER_LATE_RETRY_DELAYS_MS` = 500/1000/1500 ms: three bounded re-claims after the loop gives up, because `Entry.update({ disabled: true })` starts the displaced fiber's teardown and the loader does not await it. A disposal that unmounts a gate, restores permission presets and unwinds fibers outlasted the old budget, which is exactly the reported hot-swap window. Timers are cleared with the row.
|
|
7
|
+
- **Actionable failure log.** The lost-seam error now says both what happens next ("the reviewer path is shielded instead of failing tool calls") and what the human can do ("Restart DSH to converge the reviewer row — a hot install cannot always displace the official row in time").
|
|
8
|
+
- **Docs.** `docs/seam-gap.md` records the two remaining gaps: no public seam lets a plugin **replace a bundled capability row at runtime** (only disable-and-reclaim through the loader), and the loader cannot address a row nested in a preset's `config.plugins` — the official bundle row is top-level today, but a future nesting would need a config-level bridge like 0.4.27's preset bridge.
|
|
9
|
+
- **Verification.** `pnpm run check` green (55 test files / 494 tests + pack gate). New coverage: the net allows the call and reports exactly once when the seam is broken, stays inert while this row holds the route, rethrows a reviewer quota error, and classifies an inject failure apart from a denial. The late-retry schedule is bounded by the row's lifetime. **Not yet reproduced end-to-end**: the disable → remove → install → enable sequence on the lab plane — this machine cannot reach the GitHub remote, so the release itself is still pending.
|
|
10
|
+
|
|
1
11
|
## v0.4.31
|
|
2
12
|
|
|
3
13
|
The recurring tool-call outage is closed at both ends it can be closed from: this plugin now **takes the Auto-reviewer seam over** from the official `auto-review` row (yield-then-claim, the session-title pattern), and its own gate's preset probe can no longer fail a tool call even while the preset service is broken — with a fiber-chain dump in the one degradation log so the upstream half of the failure names itself next time.
|
package/README.md
CHANGED
|
@@ -183,7 +183,7 @@ Release-aligned search regression: `DSH_RELEASE_ANCHOR=/path/to/official/install
|
|
|
183
183
|
|
|
184
184
|
If `dsh-composer-picker` is installed, remove it from the profile before enabling Model Switch. Model Switch already owns the Composer Picker and Plan Review seat; two owners produce duplicate or competing UI.
|
|
185
185
|
|
|
186
|
-
Production profiles must use a released registry version or GitHub tag rather than workspace-local dependencies. Restart the selected DSH profile after installation or route changes.
|
|
186
|
+
Production profiles must use a released registry version or GitHub tag rather than workspace-local dependencies. Restart the selected DSH profile after installation or route changes. **Restart once after installing, updating, enabling or disabling this plugin**: it owns Host rows and takes the official Auto-review row over at runtime (`permissionPresets.registerAuto` is a single registration slot); when a hot enable skips the restart, the official row may still be serving, so the takeover only shields the reviewer path — tools keep running, that call's review is skipped with one log line — and the seam converges on the next start.
|
|
187
187
|
|
|
188
188
|
## Compatibility
|
|
189
189
|
|
package/README.zh.md
CHANGED
|
@@ -181,7 +181,7 @@ DeepSeek 薄适配器调用官方公开 `DeepSeekSearchProvider`,复用用户
|
|
|
181
181
|
|
|
182
182
|
如果 profile 已安装 `dsh-composer-picker`,请先移除它。Model Switch 已经拥有 Composer Picker 和 Plan Review 席位;同时安装会产生重复或竞争 UI。
|
|
183
183
|
|
|
184
|
-
生产 profile 必须使用已发布的注册表版本或 GitHub tag,不能使用工作区本地依赖。安装或修改路由后重启对应的 DSH profile
|
|
184
|
+
生产 profile 必须使用已发布的注册表版本或 GitHub tag,不能使用工作区本地依赖。安装或修改路由后重启对应的 DSH profile。**安装、更新、启用或禁用本插件后都要重启一次**:它持有 Host 侧的行,并在运行时接管官方的自动审查行(`permissionPresets.registerAuto` 只有一个注册位);不重启直接热启用时,官方行可能仍在服务,接管只能先把审查路径屏蔽掉——工具照常运行、本次审查跳过并留一条日志——重启后才会真正收敛。
|
|
185
185
|
|
|
186
186
|
## 兼容性
|
|
187
187
|
|
package/lib/auto-review.js
CHANGED
|
@@ -6,6 +6,20 @@ import { AsyncLocalStorage } from "node:async_hooks";
|
|
|
6
6
|
* a matched row is disabled (its fiber disposed).
|
|
7
7
|
*/
|
|
8
8
|
const OFFICIAL_AUTO_REVIEW_PACKAGE = "@deepseek-ai/dsh-experimental-auto-review";
|
|
9
|
+
/**
|
|
10
|
+
* Delays for the late re-claims after the attempt loop gave up (ms).
|
|
11
|
+
*
|
|
12
|
+
* `Entry.update({ disabled: true })` starts the displaced fiber's disposal but
|
|
13
|
+
* the loader does not await it, and a disposal that tears down a mounted gate,
|
|
14
|
+
* restores permission presets and unwinds fibers can take longer than the
|
|
15
|
+
* attempt loop's budget. These re-claims keep trying for a few more seconds so a
|
|
16
|
+
* hot-swapped install converges instead of needing a restart.
|
|
17
|
+
*/
|
|
18
|
+
const TAKEOVER_LATE_RETRY_DELAYS_MS = [
|
|
19
|
+
500,
|
|
20
|
+
1e3,
|
|
21
|
+
1500
|
|
22
|
+
];
|
|
9
23
|
/** Whether one loader entry is the official Auto-review row (strict package identity). */
|
|
10
24
|
function isOfficialAutoReviewRow(entry) {
|
|
11
25
|
if (entry === void 0) return false;
|
|
@@ -42,23 +56,69 @@ function alreadyDisabled(entry) {
|
|
|
42
56
|
* @returns a promise settling once the takeover attempt loop finished.
|
|
43
57
|
*/
|
|
44
58
|
async function reclaimOfficialAutoReviewRow(ctx, entry, seam) {
|
|
59
|
+
const label = String(leafId(entry) ?? "auto-review");
|
|
60
|
+
/** One disable-then-claim step; true once this row holds the registration. */
|
|
61
|
+
const attempt = async () => {
|
|
62
|
+
try {
|
|
63
|
+
await entry.update({ disabled: true });
|
|
64
|
+
ctx.logger.info(`model-switch: disabled the official auto-review row ${label} (${OFFICIAL_AUTO_REVIEW_PACKAGE}); Model Switch's auto-review row owns the reviewer seam (configurable reviewer route)`);
|
|
65
|
+
} catch (cause) {
|
|
66
|
+
ctx.logger.info("model-switch: the displaced auto-review row could not be persisted as disabled; the takeover repeats next startup");
|
|
67
|
+
ctx.logger.info(String(cause));
|
|
68
|
+
}
|
|
69
|
+
return await seam.claim();
|
|
70
|
+
};
|
|
45
71
|
await seam.preserveAuto(async () => {
|
|
46
|
-
for (let
|
|
47
|
-
await new Promise((resolve) => setTimeout(resolve,
|
|
48
|
-
|
|
49
|
-
|
|
50
|
-
ctx.logger.info(`model-switch: disabled the official auto-review row ${String(leafId(entry) ?? "auto-review")} (${OFFICIAL_AUTO_REVIEW_PACKAGE}); Model Switch's auto-review row owns the reviewer seam (configurable reviewer route)`);
|
|
51
|
-
} catch (cause) {
|
|
52
|
-
ctx.logger.info("model-switch: the displaced auto-review row could not be persisted as disabled; the takeover repeats next startup");
|
|
53
|
-
ctx.logger.info(String(cause));
|
|
54
|
-
}
|
|
55
|
-
if (await seam.claim()) {
|
|
56
|
-
ctx.logger.info(`model-switch: displaced the official auto-review row ${String(leafId(entry) ?? "auto-review")}; Auto review runs under the configurable reviewer route`);
|
|
72
|
+
for (let index = 0; index < 60; index += 1) {
|
|
73
|
+
await new Promise((resolve) => setTimeout(resolve, index === 0 ? 0 : 50));
|
|
74
|
+
if (await attempt()) {
|
|
75
|
+
ctx.logger.info(`model-switch: displaced the official auto-review row ${label}; Auto review runs under the configurable reviewer route`);
|
|
57
76
|
return;
|
|
58
77
|
}
|
|
59
78
|
}
|
|
60
|
-
ctx.logger.error(`model-switch: the official auto-review row ${
|
|
79
|
+
ctx.logger.error(`model-switch: the official auto-review row ${label} kept the Auto registration; the reviewer route override is unavailable this session`);
|
|
61
80
|
});
|
|
81
|
+
scheduleLateReclaims(ctx, label, seam);
|
|
82
|
+
}
|
|
83
|
+
/**
|
|
84
|
+
* Keep re-claiming for a few seconds after the attempt loop gave up.
|
|
85
|
+
*
|
|
86
|
+
* The displaced fiber's disposal starts on `Entry.update` but is not awaited by
|
|
87
|
+
* the loader, so it can outlast the attempt loop — which is exactly the hot-swap
|
|
88
|
+
* window where the official (unshielded) gate keeps serving. These bounded
|
|
89
|
+
* re-claims converge that window instead of leaving it to a restart. Timers are
|
|
90
|
+
* cleared when this row unloads.
|
|
91
|
+
*
|
|
92
|
+
* @param ctx - this row's context, used for logging and timer disposal.
|
|
93
|
+
* @param label - the official row's id, for diagnostics.
|
|
94
|
+
* @param seam - the Auto-registration seam to reclaim.
|
|
95
|
+
*/
|
|
96
|
+
function scheduleLateReclaims(ctx, label, seam) {
|
|
97
|
+
let stopped = false;
|
|
98
|
+
const timers = [];
|
|
99
|
+
const stop = () => {
|
|
100
|
+
stopped = true;
|
|
101
|
+
for (const timer of timers.splice(0)) clearTimeout(timer);
|
|
102
|
+
};
|
|
103
|
+
ctx.effect(function* () {
|
|
104
|
+
yield stop;
|
|
105
|
+
}, "model-switch: auto-review late takeover retries");
|
|
106
|
+
const run = (index) => {
|
|
107
|
+
if (stopped || index >= TAKEOVER_LATE_RETRY_DELAYS_MS.length) return;
|
|
108
|
+
const timer = setTimeout(() => {
|
|
109
|
+
(async () => {
|
|
110
|
+
if (stopped) return;
|
|
111
|
+
if (await seam.claim()) {
|
|
112
|
+
ctx.logger.info(`model-switch: reclaimed the Auto registration late from the official auto-review row ${label}; Auto review runs under the configurable reviewer route`);
|
|
113
|
+
stop();
|
|
114
|
+
return;
|
|
115
|
+
}
|
|
116
|
+
run(index + 1);
|
|
117
|
+
})();
|
|
118
|
+
}, TAKEOVER_LATE_RETRY_DELAYS_MS[index]);
|
|
119
|
+
timers.push(timer);
|
|
120
|
+
};
|
|
121
|
+
run(0);
|
|
62
122
|
}
|
|
63
123
|
/**
|
|
64
124
|
* Install the takeover choreography for an official row that activates later.
|
|
@@ -272,6 +332,69 @@ function bindAutoReviewSessionCorrelation(scope, activeSession) {
|
|
|
272
332
|
yield scope.on("tools/pre-execute", (exec, next) => activeSession.run(exec.agent?.session, next), { prepend: true });
|
|
273
333
|
}, "model-switch: auto-review session correlation");
|
|
274
334
|
}
|
|
335
|
+
/**
|
|
336
|
+
* Signatures of a broken reviewer seam, never of a real denial.
|
|
337
|
+
*
|
|
338
|
+
* The official gate's `tools/pre-execute` probe resolves its own
|
|
339
|
+
* `sessionProjections` injection before anything can catch it; when that fiber
|
|
340
|
+
* chain breaks it throws `cannot get required service ... in inactive context`
|
|
341
|
+
* (or, on older lines, `cannot get property ... without inject`) straight into
|
|
342
|
+
* the tool call. `TRANSPORT` covers a reviewer that cannot reach its route at
|
|
343
|
+
* all. A denial is a *return* (`{ kind: 'deny' }`), so it never matches.
|
|
344
|
+
*/
|
|
345
|
+
const REVIEWER_INFRA_FAILURE_SIGNATURES = [
|
|
346
|
+
"inactive context",
|
|
347
|
+
"cannot get required service",
|
|
348
|
+
"cannot get property",
|
|
349
|
+
"without inject",
|
|
350
|
+
"TRANSPORT"
|
|
351
|
+
];
|
|
352
|
+
/**
|
|
353
|
+
* Whether a reviewer-path throw is a broken seam rather than a real decision.
|
|
354
|
+
* @param cause - whatever the waterfall threw.
|
|
355
|
+
* @returns true when the reviewer itself could not run.
|
|
356
|
+
*/
|
|
357
|
+
function isReviewerInfrastructureFailure(cause) {
|
|
358
|
+
const text = cause instanceof Error ? `${cause.name}: ${cause.message}` : String(cause);
|
|
359
|
+
return REVIEWER_INFRA_FAILURE_SIGNATURES.some((signature) => text.includes(signature));
|
|
360
|
+
}
|
|
361
|
+
/**
|
|
362
|
+
* Keep a broken reviewer seam from failing tool calls.
|
|
363
|
+
*
|
|
364
|
+
* Registered on this row's own context through `{ prepend: true }`, so it sits
|
|
365
|
+
* ahead of the official gate's listener and stays live even when the gate could
|
|
366
|
+
* not be mounted (the takeover lost `registerAuto`, or the displaced row has
|
|
367
|
+
* not released it yet). That is the one window the defensive
|
|
368
|
+
* `permissionPresets` shadow cannot cover: the shadow lives in this row's review
|
|
369
|
+
* scope, while the serving gate resolves the real service from the profile.
|
|
370
|
+
*
|
|
371
|
+
* The waterfall only decides whether the body runs; the body dispatches after
|
|
372
|
+
* it returns, so allowing here never masks a tool failure. Reviewer transport
|
|
373
|
+
* and quota errors keep failing closed — only seam breakage is allowed through.
|
|
374
|
+
*
|
|
375
|
+
* @param ctx - this row's context (the listener lives and dies with the plugin).
|
|
376
|
+
* @param options - diagnostics plus the degraded-state predicate.
|
|
377
|
+
*/
|
|
378
|
+
function installToolCallSafetyNet(ctx, options = {}) {
|
|
379
|
+
let reported = false;
|
|
380
|
+
const handler = async (_exec, next) => {
|
|
381
|
+
try {
|
|
382
|
+
return await next();
|
|
383
|
+
} catch (cause) {
|
|
384
|
+
if (!isReviewerInfrastructureFailure(cause)) throw cause;
|
|
385
|
+
if (options.isDegraded !== void 0 && !options.isDegraded()) throw cause;
|
|
386
|
+
if (!reported) {
|
|
387
|
+
reported = true;
|
|
388
|
+
const detail = options.fiberChain === void 0 ? "" : `; fiber chain: ${options.fiberChain()}`;
|
|
389
|
+
ctx.logger.warn(`model-switch: the Auto reviewer path is broken (${String(cause)}); tool calls run unreviewed instead of failing${detail}`);
|
|
390
|
+
}
|
|
391
|
+
return { kind: "allow" };
|
|
392
|
+
}
|
|
393
|
+
};
|
|
394
|
+
ctx.effect(function* () {
|
|
395
|
+
yield ctx.on("tools/pre-execute", handler, { prepend: true });
|
|
396
|
+
}, "model-switch: reviewer-path safety net");
|
|
397
|
+
}
|
|
275
398
|
/** The preset name the service reports when no configured preset matches. */
|
|
276
399
|
const CUSTOM_PRESET = "custom";
|
|
277
400
|
/** Read the live permission-preset service without requiring an injection. */
|
|
@@ -490,6 +613,10 @@ async function apply(ctx) {
|
|
|
490
613
|
const presets = defensivePermissionPresets(() => permissionPresetsOf(ctx), ctx.logger, () => fiberChainOf(ctx));
|
|
491
614
|
const activeSession = new AsyncLocalStorage();
|
|
492
615
|
const scope = reviewScope(ctx, activeSession, presets);
|
|
616
|
+
installToolCallSafetyNet(ctx, {
|
|
617
|
+
fiberChain: () => fiberChainOf(ctx),
|
|
618
|
+
isDegraded: () => !presets.registered
|
|
619
|
+
});
|
|
493
620
|
let gateReady = false;
|
|
494
621
|
let mountInFlight;
|
|
495
622
|
/** Sessions currently on the Auto preset (best-effort). */
|
|
@@ -585,7 +712,7 @@ async function apply(ctx) {
|
|
|
585
712
|
const entry = officialAutoReviewEntry(loaderOf(ctx));
|
|
586
713
|
if (entry !== void 0 && entry.disabled !== true) await reclaim(entry);
|
|
587
714
|
else await claim();
|
|
588
|
-
if (!presets.registered) ctx.logger.error("model-switch: the Auto registration is not held by this row
|
|
715
|
+
if (!presets.registered) ctx.logger.error("model-switch: the Auto registration is not held by this row, so Auto review runs without the configurable reviewer route; the reviewer path is shielded instead of failing tool calls. Restart DSH to converge the reviewer row — a hot install cannot always displace the official row in time");
|
|
589
716
|
}
|
|
590
717
|
//#endregion
|
|
591
|
-
export { apply, autoReviewGate, autoReviewRouteOf, bindAutoReviewSessionCorrelation, defensivePermissionPresets, inject, name, resolveSessionAutoReviewRoute, reviewLlm, reviewScope };
|
|
718
|
+
export { apply, autoReviewGate, autoReviewRouteOf, bindAutoReviewSessionCorrelation, defensivePermissionPresets, inject, installToolCallSafetyNet, isReviewerInfrastructureFailure, name, resolveSessionAutoReviewRoute, reviewLlm, reviewScope };
|
|
@@ -36,9 +36,19 @@ export declare const OFFICIAL_AUTO_REVIEW_ROW = "auto-review";
|
|
|
36
36
|
*/
|
|
37
37
|
export declare const OFFICIAL_AUTO_REVIEW_PACKAGE = "@deepseek-ai/dsh-experimental-auto-review";
|
|
38
38
|
/** Bounded takeover attempts before the seam is reported lost. */
|
|
39
|
-
export declare const TAKEOVER_MAX_ATTEMPTS =
|
|
39
|
+
export declare const TAKEOVER_MAX_ATTEMPTS = 60;
|
|
40
40
|
/** Delay between takeover attempts; the first attempt is immediate. */
|
|
41
|
-
export declare const TAKEOVER_RETRY_DELAY_MS =
|
|
41
|
+
export declare const TAKEOVER_RETRY_DELAY_MS = 50;
|
|
42
|
+
/**
|
|
43
|
+
* Delays for the late re-claims after the attempt loop gave up (ms).
|
|
44
|
+
*
|
|
45
|
+
* `Entry.update({ disabled: true })` starts the displaced fiber's disposal but
|
|
46
|
+
* the loader does not await it, and a disposal that tears down a mounted gate,
|
|
47
|
+
* restores permission presets and unwinds fibers can take longer than the
|
|
48
|
+
* attempt loop's budget. These re-claims keep trying for a few more seconds so a
|
|
49
|
+
* hot-swapped install converges instead of needing a restart.
|
|
50
|
+
*/
|
|
51
|
+
export declare const TAKEOVER_LATE_RETRY_DELAYS_MS: readonly [500, 1000, 1500];
|
|
42
52
|
/** The seam this row's gate owns; the takeover drives it around the official row. */
|
|
43
53
|
export interface AutoReviewSeam {
|
|
44
54
|
/**
|
|
@@ -102,6 +102,41 @@ export declare function resolveSessionAutoReviewRoute(ctx: Context, session?: Se
|
|
|
102
102
|
* @param activeSession - AsyncLocalStorage holding the session under review.
|
|
103
103
|
*/
|
|
104
104
|
export declare function bindAutoReviewSessionCorrelation(scope: Context, activeSession: AsyncLocalStorage<Session | undefined>): void;
|
|
105
|
+
/**
|
|
106
|
+
* Whether a reviewer-path throw is a broken seam rather than a real decision.
|
|
107
|
+
* @param cause - whatever the waterfall threw.
|
|
108
|
+
* @returns true when the reviewer itself could not run.
|
|
109
|
+
*/
|
|
110
|
+
export declare function isReviewerInfrastructureFailure(cause: unknown): boolean;
|
|
111
|
+
/**
|
|
112
|
+
* Keep a broken reviewer seam from failing tool calls.
|
|
113
|
+
*
|
|
114
|
+
* Registered on this row's own context through `{ prepend: true }`, so it sits
|
|
115
|
+
* ahead of the official gate's listener and stays live even when the gate could
|
|
116
|
+
* not be mounted (the takeover lost `registerAuto`, or the displaced row has
|
|
117
|
+
* not released it yet). That is the one window the defensive
|
|
118
|
+
* `permissionPresets` shadow cannot cover: the shadow lives in this row's review
|
|
119
|
+
* scope, while the serving gate resolves the real service from the profile.
|
|
120
|
+
*
|
|
121
|
+
* The waterfall only decides whether the body runs; the body dispatches after
|
|
122
|
+
* it returns, so allowing here never masks a tool failure. Reviewer transport
|
|
123
|
+
* and quota errors keep failing closed — only seam breakage is allowed through.
|
|
124
|
+
*
|
|
125
|
+
* @param ctx - this row's context (the listener lives and dies with the plugin).
|
|
126
|
+
* @param options - diagnostics plus the degraded-state predicate.
|
|
127
|
+
*/
|
|
128
|
+
export declare function installToolCallSafetyNet(ctx: Context, options?: {
|
|
129
|
+
/** Diagnostic fiber dump appended to the first report. */
|
|
130
|
+
fiberChain?: (() => string) | undefined;
|
|
131
|
+
/**
|
|
132
|
+
* Whether this row currently knows the reviewer route is not held.
|
|
133
|
+
*
|
|
134
|
+
* The net only fails open in that state: another plugin's broken listener
|
|
135
|
+
* must keep failing closed, and a held route already has the defensive
|
|
136
|
+
* shadow in front of it. Defaults to always degraded (direct callers).
|
|
137
|
+
*/
|
|
138
|
+
isDegraded?: (() => boolean) | undefined;
|
|
139
|
+
}): void;
|
|
105
140
|
/** The permission-preset face the official gate reads; structural so a missing package never breaks a build. */
|
|
106
141
|
export interface PermissionPresetsFace {
|
|
107
142
|
current(session: Session): string;
|