mirror of
https://github.com/Tencent/BrowserSkill.git
synced 2026-09-10 13:01:23 +08:00
fix(dsh-plugin): stabilize skill loading and browser sessions
Register bundled browser instructions at agent scope, prioritize foreground commands over thumbnail capture, and unify cancellation and session cleanup.
This commit is contained in:
@@ -18,7 +18,12 @@ import { tmpdir } from "node:os";
|
||||
import { join } from "node:path";
|
||||
import type { Context } from "@deepseek-ai/cordis";
|
||||
import type { KeyedExecutor } from "./queue";
|
||||
import type { BskRunner } from "./runner";
|
||||
import {
|
||||
BskError,
|
||||
type BskRunner,
|
||||
parseBskJson,
|
||||
runWithSessionBusyRetry,
|
||||
} from "./runner";
|
||||
import type { SessionRegistry } from "./sessions";
|
||||
|
||||
/** One owned session's live observation record (wire-stable shape). */
|
||||
@@ -90,6 +95,10 @@ export class ObservationService {
|
||||
private readonly listeners = new Set<(event: ObservationEvent) => void>();
|
||||
private readonly captureTimers = new Map<string, unknown>();
|
||||
private readonly captureInFlight = new Set<string>();
|
||||
/** Captures intentionally cancelled to make way for foreground work. */
|
||||
private readonly capturePreempted = new Set<string>();
|
||||
/** Number of queued/running model-facing calls that currently outrank thumbnails. */
|
||||
private readonly foregroundDepth = new Map<string, number>();
|
||||
private readonly captureFailures = new Map<string, number>();
|
||||
private readonly lastActivity = new Map<string, number>();
|
||||
/** Ephemeral frame id → PNG bytes. Only the live ring members are present. */
|
||||
@@ -175,6 +184,8 @@ export class ObservationService {
|
||||
/** Drop a session (called from browser_session_stop). */
|
||||
removeSession(sessionId: string): void {
|
||||
this.cancelCapture(sessionId);
|
||||
this.foregroundDepth.delete(sessionId);
|
||||
this.capturePreempted.delete(sessionId);
|
||||
this.captureFailures.delete(sessionId);
|
||||
this.lastActivity.delete(sessionId);
|
||||
this.dropSessionFrames(sessionId);
|
||||
@@ -186,6 +197,37 @@ export class ObservationService {
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Give model-facing work priority over the best-effort thumbnail lane.
|
||||
* The first lease cancels a pending timer and gracefully interrupts an
|
||||
* active bsk screenshot; the last release schedules one fresh frame.
|
||||
*/
|
||||
acquireForeground(sessionId: string): () => void {
|
||||
if (!this.deps.options.enabled || this.disposed || !this.observations.has(sessionId)) {
|
||||
return () => {};
|
||||
}
|
||||
const depth = this.foregroundDepth.get(sessionId) ?? 0;
|
||||
this.foregroundDepth.set(sessionId, depth + 1);
|
||||
if (depth === 0) {
|
||||
this.cancelCapture(sessionId);
|
||||
if (this.deps.runner.killFor(`observation:${sessionId}`) > 0) {
|
||||
this.capturePreempted.add(sessionId);
|
||||
}
|
||||
}
|
||||
let released = false;
|
||||
return () => {
|
||||
if (released) return;
|
||||
released = true;
|
||||
const remaining = (this.foregroundDepth.get(sessionId) ?? 1) - 1;
|
||||
if (remaining > 0) {
|
||||
this.foregroundDepth.set(sessionId, remaining);
|
||||
return;
|
||||
}
|
||||
this.foregroundDepth.delete(sessionId);
|
||||
if (!this.disposed && this.observations.has(sessionId)) this.scheduleCapture(sessionId, 0);
|
||||
};
|
||||
}
|
||||
|
||||
/** Mark an action starting on a session (tool entry instrumentation). */
|
||||
beginAction(sessionId: string, action: string): void {
|
||||
if (!this.deps.options.enabled || this.disposed) return;
|
||||
@@ -213,7 +255,7 @@ export class ObservationService {
|
||||
if (error !== undefined) next.lastError = error;
|
||||
else delete next.lastError;
|
||||
this.put(next);
|
||||
this.scheduleCapture(sessionId, 0);
|
||||
if (!this.foregroundDepth.has(sessionId)) this.scheduleCapture(sessionId, 0);
|
||||
}
|
||||
|
||||
/** Record the settled page URL (navigate success / start with url). */
|
||||
@@ -242,29 +284,48 @@ export class ObservationService {
|
||||
* button — same end state as `browser_session_stop`). Never waits behind a
|
||||
* hung in-flight command: tool children are killed first so the session's
|
||||
* keyed queue drains immediately, and no further captures queue up. A
|
||||
* session the daemon already forgot (dead strip entries) stops
|
||||
* idempotently — the goal state (entry gone) is identical.
|
||||
* @returns whether the session ended up stopped/removed.
|
||||
* session the daemon already forgot stops idempotently — the goal state
|
||||
* (entry gone) is identical.
|
||||
* @returns false only for a foreign session; bsk failures reject so callers
|
||||
* can preserve the structured error instead of silently leaving a ghost.
|
||||
*/
|
||||
async stopSession(sessionId: string): Promise<boolean> {
|
||||
async stopSession(sessionId: string, signal?: AbortSignal): Promise<boolean> {
|
||||
if (!this.deps.registry.isOwned(sessionId)) return false;
|
||||
this.cancelCapture(sessionId);
|
||||
this.deps.runner.killFor(sessionId);
|
||||
const result = await this.deps.queue.run(sessionId, () =>
|
||||
this.deps.runner.run(["session", "stop", sessionId], { timeoutMs: 30_000 }),
|
||||
);
|
||||
if (result.code !== 0) {
|
||||
let code: string | undefined;
|
||||
const releaseForeground = this.acquireForeground(sessionId);
|
||||
let actionError: string | undefined;
|
||||
this.beginAction(sessionId, "stopping");
|
||||
try {
|
||||
this.deps.runner.killFor(sessionId);
|
||||
const result = await this.deps.queue.run(
|
||||
sessionId,
|
||||
() =>
|
||||
runWithSessionBusyRetry(
|
||||
() =>
|
||||
this.deps.runner.run(["session", "stop", sessionId], {
|
||||
signal,
|
||||
timeoutMs: 30_000,
|
||||
tag: sessionId,
|
||||
}),
|
||||
signal,
|
||||
),
|
||||
signal,
|
||||
);
|
||||
if (result.aborted) throw abortError();
|
||||
try {
|
||||
code = (JSON.parse(result.stdout) as { code?: string }).code;
|
||||
} catch {
|
||||
code = undefined;
|
||||
parseBskJson(result, "session stop");
|
||||
} catch (error) {
|
||||
if (!isSessionNotFoundError(error)) throw error;
|
||||
}
|
||||
if (code !== "session_not_found") return false;
|
||||
this.deps.registry.remove(sessionId);
|
||||
this.removeSession(sessionId);
|
||||
return true;
|
||||
} catch (error) {
|
||||
actionError = error instanceof Error ? error.message.split("\n")[0] : String(error);
|
||||
throw error;
|
||||
} finally {
|
||||
this.endAction(sessionId, actionError);
|
||||
releaseForeground();
|
||||
}
|
||||
this.deps.registry.remove(sessionId);
|
||||
this.removeSession(sessionId);
|
||||
return true;
|
||||
}
|
||||
|
||||
/**
|
||||
@@ -287,6 +348,8 @@ export class ObservationService {
|
||||
for (const sessionId of [...this.captureTimers.keys()]) this.cancelCapture(sessionId);
|
||||
this.captureTimers.clear();
|
||||
this.captureInFlight.clear();
|
||||
this.capturePreempted.clear();
|
||||
this.foregroundDepth.clear();
|
||||
this.captureFailures.clear();
|
||||
this.lastActivity.clear();
|
||||
this.frames.clear();
|
||||
@@ -313,6 +376,7 @@ export class ObservationService {
|
||||
/** Schedule the next capture for a session; `delayMs` 0 means "as soon as the event loop allows". */
|
||||
private scheduleCapture(sessionId: string, delayMs?: number): void {
|
||||
if (!this.deps.options.enabled || this.disposed) return;
|
||||
if (this.foregroundDepth.has(sessionId)) return;
|
||||
const entry = this.observations.get(sessionId);
|
||||
if (entry === undefined || entry.dead === true) return;
|
||||
this.cancelCapture(sessionId);
|
||||
@@ -349,12 +413,15 @@ export class ObservationService {
|
||||
const outPath = join(tmpdir(), `bsk-obs-${this.scratchNamespace}-${sessionKey}.png`);
|
||||
let writtenPath = outPath;
|
||||
try {
|
||||
const result = await this.deps.queue.run(sessionId, () =>
|
||||
this.deps.runner.run(["screenshot", "--session", sessionId, "--out", outPath], {
|
||||
const result = await this.deps.queue.run(sessionId, () => {
|
||||
// A foreground lease may have arrived while this capture was queued.
|
||||
if (this.foregroundDepth.has(sessionId)) return Promise.resolve(undefined);
|
||||
return this.deps.runner.run(["screenshot", "--session", sessionId, "--out", outPath], {
|
||||
timeoutMs: 15_000,
|
||||
tag: `observation:${sessionId}`,
|
||||
}),
|
||||
);
|
||||
});
|
||||
});
|
||||
if (result === undefined) return;
|
||||
if (result.code !== 0) {
|
||||
let code: string | undefined;
|
||||
try {
|
||||
@@ -362,8 +429,12 @@ export class ObservationService {
|
||||
} catch {
|
||||
code = undefined;
|
||||
}
|
||||
if (code === "session_not_found") {
|
||||
this.markDead(sessionId);
|
||||
if (isSessionNotFoundCode(code)) {
|
||||
// Observation entries are owned sessions. If the daemon has already
|
||||
// forgotten one (external close / restart), reconcile both stores so
|
||||
// the PiP cannot retain a ghost strip or keep retrying screenshots.
|
||||
this.deps.registry.remove(sessionId);
|
||||
this.removeSession(sessionId);
|
||||
return;
|
||||
}
|
||||
throw new Error(`screenshot exited ${result.code}`);
|
||||
@@ -378,12 +449,17 @@ export class ObservationService {
|
||||
this.setAvailable(true);
|
||||
this.publishFrame(sessionId, entry, data);
|
||||
} catch {
|
||||
// Silent by design: keep the previous frame, count toward backoff.
|
||||
this.captureFailures.set(sessionId, (this.captureFailures.get(sessionId) ?? 0) + 1);
|
||||
this.globalFailures += 1;
|
||||
if (this.globalFailures >= FAILURE_BACKOFF_THRESHOLD) this.setAvailable(false);
|
||||
// Foreground preemption is healthy scheduling, not browser/daemon
|
||||
// unavailability; do not let normal tool traffic trip capture backoff.
|
||||
if (!this.capturePreempted.delete(sessionId)) {
|
||||
// Silent by design: keep the previous frame, count toward backoff.
|
||||
this.captureFailures.set(sessionId, (this.captureFailures.get(sessionId) ?? 0) + 1);
|
||||
this.globalFailures += 1;
|
||||
if (this.globalFailures >= FAILURE_BACKOFF_THRESHOLD) this.setAvailable(false);
|
||||
}
|
||||
} finally {
|
||||
await unlink(writtenPath).catch(() => {});
|
||||
this.capturePreempted.delete(sessionId);
|
||||
this.captureInFlight.delete(sessionId);
|
||||
if (!this.disposed && this.observations.has(sessionId)) this.scheduleCapture(sessionId);
|
||||
}
|
||||
@@ -415,13 +491,20 @@ export class ObservationService {
|
||||
this.seqs.delete(sessionId);
|
||||
}
|
||||
|
||||
/** Mark a session dead: grey it out, stop asking for frames, keep the entry for the strip. */
|
||||
private markDead(sessionId: string): void {
|
||||
const entry = this.observations.get(sessionId);
|
||||
if (entry === undefined || entry.dead === true) return;
|
||||
this.cancelCapture(sessionId);
|
||||
this.put({ ...entry, dead: true, action: "idle" });
|
||||
}
|
||||
}
|
||||
|
||||
function isSessionNotFoundCode(code: string | undefined): boolean {
|
||||
return code === "not_found" || code === "session_not_found";
|
||||
}
|
||||
|
||||
function isSessionNotFoundError(error: unknown): boolean {
|
||||
return error instanceof BskError && isSessionNotFoundCode(error.code);
|
||||
}
|
||||
|
||||
function abortError(): Error {
|
||||
const error = new Error("tool call aborted");
|
||||
error.name = "AbortError";
|
||||
return error;
|
||||
}
|
||||
|
||||
/** Map a bsk command label onto its observation action verb. */
|
||||
|
||||
Reference in New Issue
Block a user