@bivy/bivy 0.9.1-staging.124 → 0.9.1-staging.126
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/dist/server.js +93 -5
- package/dist/session/abort-recovery.js +16 -0
- package/package.json +1 -1
package/dist/server.js
CHANGED
|
@@ -46,6 +46,7 @@ import { readEphemeralTeardownConfig, shouldSelfTeardown, performSelfTeardown }
|
|
|
46
46
|
import { buildSessionSnapshot, applySessionSnapshot } from "./session/snapshot.js";
|
|
47
47
|
import { createCheckpointBundle, applyCheckpointBundle, materializeCheckpoint } from "./session/checkpoint-pack.js";
|
|
48
48
|
import { configuredTurnTimeoutMs } from "./session/turn-watchdog.js";
|
|
49
|
+
import { forceAbortTurn } from "./session/abort-recovery.js";
|
|
49
50
|
import { runRequiredAutomationChecks } from "./automation-checks.js";
|
|
50
51
|
import { PolicyEngine } from "./policy/policy-engine.js";
|
|
51
52
|
import { TerminalManager } from "./terminal.js";
|
|
@@ -2798,8 +2799,28 @@ const RELAY_COMMANDS = {
|
|
|
2798
2799
|
ctx.broadcast({ type: "session.renamed", sessionId: rec.id, sessionFile: rec.sessionFile, name: newName });
|
|
2799
2800
|
scheduleAdvertise();
|
|
2800
2801
|
},
|
|
2801
|
-
|
|
2802
|
-
|
|
2802
|
+
abort(msg, ctx) {
|
|
2803
|
+
const record = resolveSession(msg.sessionId);
|
|
2804
|
+
if (!record || !sessionBusy(record))
|
|
2805
|
+
return;
|
|
2806
|
+
// A wedged runtime may never resolve abort() or emit agent_end. Settle the
|
|
2807
|
+
// daemon and client first, then make the SDK abort best-effort. The synthetic
|
|
2808
|
+
// agent_end also closes running tool cards and drains visible follow-ups.
|
|
2809
|
+
forceAbortTurn({
|
|
2810
|
+
settle: () => {
|
|
2811
|
+
questionManager.cancelForSession(record.id);
|
|
2812
|
+
approvals.cancelForSession(record.id);
|
|
2813
|
+
clearSessionWorking(record, "idle");
|
|
2814
|
+
},
|
|
2815
|
+
notifySettled: () => ctx.broadcast({
|
|
2816
|
+
type: "session.event",
|
|
2817
|
+
sessionId: record.id,
|
|
2818
|
+
event: { type: "agent_end", aborted: true },
|
|
2819
|
+
}),
|
|
2820
|
+
abort: () => record.session.abort(),
|
|
2821
|
+
onAbortError: (error) => console.warn(`[session-abort] runtime abort failed for ${record.id}:`, error),
|
|
2822
|
+
});
|
|
2823
|
+
record.abortRecovery = recoverRecordAfterAbort(record);
|
|
2803
2824
|
},
|
|
2804
2825
|
async "session.command.invoke"(msg, ctx) {
|
|
2805
2826
|
// Invoke a protocol-mode agent command (AgentCommand.mode === "protocol")
|
|
@@ -3549,6 +3570,7 @@ const RELAY_COMMANDS = {
|
|
|
3549
3570
|
broadcast({ type: "session.error", sessionId: requestedSessionId, error: error instanceof Error ? error.message : String(error) });
|
|
3550
3571
|
return;
|
|
3551
3572
|
}
|
|
3573
|
+
await record.abortRecovery;
|
|
3552
3574
|
record.remoteActive = true;
|
|
3553
3575
|
if (record.tuiTermId || record.tuiRefreshing) {
|
|
3554
3576
|
broadcast({ type: "session.error", sessionId: record.id, error: record.tuiRefreshing ? "This session is returning from the terminal. Try again in a moment." : "This session is open in the terminal (TUI). Close the TUI to chat here." });
|
|
@@ -7169,7 +7191,7 @@ function armTurnWatchdog(record) {
|
|
|
7169
7191
|
// Clear/persist first so the session and an ephemeral runner cannot remain
|
|
7170
7192
|
// pinned in a false working state if the runtime's abort path fails to emit
|
|
7171
7193
|
// agent_end. abort() is still invoked to kill the underlying process group.
|
|
7172
|
-
clearSessionWorking(record);
|
|
7194
|
+
clearSessionWorking(record, "idle");
|
|
7173
7195
|
metadata.touchSession(record.id, "failed");
|
|
7174
7196
|
broadcast({ type: "session.outcome", sessionId: record.id, status: "timed_out", completedAt: new Date().toISOString(), error: message });
|
|
7175
7197
|
broadcast({ type: "session.error", sessionId: record.id, error: message });
|
|
@@ -7208,7 +7230,7 @@ function markSessionWorking(record, activity) {
|
|
|
7208
7230
|
if (!wasWorking)
|
|
7209
7231
|
scheduleAdvertise(); // idle → working transition
|
|
7210
7232
|
}
|
|
7211
|
-
function clearSessionWorking(record) {
|
|
7233
|
+
function clearSessionWorking(record, forcedStatus) {
|
|
7212
7234
|
clearTurnWatchdog(record);
|
|
7213
7235
|
touchSession(record);
|
|
7214
7236
|
record.isWorking = false;
|
|
@@ -7217,7 +7239,10 @@ function clearSessionWorking(record) {
|
|
|
7217
7239
|
// A completed turn clears any pending manual-resume offer: the session has now
|
|
7218
7240
|
// moved on (whether it was the resume itself or an unrelated new message).
|
|
7219
7241
|
metadata.setResumePending(record.id, false);
|
|
7220
|
-
|
|
7242
|
+
// A stuck runtime can keep its own isStreaming bit true forever. Recovery
|
|
7243
|
+
// callers explicitly force idle so that stale SDK state is not persisted as
|
|
7244
|
+
// a permanently-working session after Bivy has settled the turn.
|
|
7245
|
+
persistSessionMetadata(record, forcedStatus ?? sessionStatus(record));
|
|
7221
7246
|
scheduleAdvertise(); // working → idle transition
|
|
7222
7247
|
}
|
|
7223
7248
|
/**
|
|
@@ -8117,6 +8142,68 @@ async function tryAttachLiveRemote(sessionId, options) {
|
|
|
8117
8142
|
return { result: undefined, gone, error };
|
|
8118
8143
|
}
|
|
8119
8144
|
}
|
|
8145
|
+
/** Re-open a runtime that still reports streaming shortly after a manual Stop.
|
|
8146
|
+
* The client is settled immediately by the abort handler; prompts arriving from
|
|
8147
|
+
* its follow-up queue await this promise and therefore cannot be steered into the
|
|
8148
|
+
* stale in-process Pi turn. */
|
|
8149
|
+
async function recoverRecordAfterAbort(record) {
|
|
8150
|
+
const oldSession = record.session;
|
|
8151
|
+
try {
|
|
8152
|
+
await new Promise((resolve) => setTimeout(resolve, 250));
|
|
8153
|
+
if (record.session !== oldSession || !oldSession.isStreaming)
|
|
8154
|
+
return;
|
|
8155
|
+
if (!record.sessionFile) {
|
|
8156
|
+
broadcast({ type: "session.error", sessionId: record.id, error: "The stopped agent did not settle. Reopen this chat to continue." });
|
|
8157
|
+
return;
|
|
8158
|
+
}
|
|
8159
|
+
const rt = await ensureRuntimeAvailable(record.runtimeId, record.sandbox);
|
|
8160
|
+
const workspace = record.worktree?.path || oldSession.cwd || record.workspace;
|
|
8161
|
+
const runtimeSessionOptions = {
|
|
8162
|
+
workspace,
|
|
8163
|
+
toolProvider: integrations.toolProvider({ current: record.id }),
|
|
8164
|
+
...(rt.capabilities.toolInterception ? { toolInterceptor: guardianInterceptor } : {}),
|
|
8165
|
+
};
|
|
8166
|
+
const { session, warning } = await runtimeHost.openSession(rt, {
|
|
8167
|
+
...runtimeSessionOptions,
|
|
8168
|
+
sessionFile: record.sessionFile,
|
|
8169
|
+
});
|
|
8170
|
+
// The original abort may have completed while openSession was in flight.
|
|
8171
|
+
if (record.session !== oldSession || !oldSession.isStreaming) {
|
|
8172
|
+
session.dispose();
|
|
8173
|
+
return;
|
|
8174
|
+
}
|
|
8175
|
+
if (session.id !== record.id) {
|
|
8176
|
+
session.dispose();
|
|
8177
|
+
throw new Error(`runtime reopened session as ${session.id} instead of ${record.id}`);
|
|
8178
|
+
}
|
|
8179
|
+
record.unsubscribe?.();
|
|
8180
|
+
record.unsubscribe = undefined;
|
|
8181
|
+
record.session = session;
|
|
8182
|
+
record.sessionFile = session.sessionFile ?? record.sessionFile;
|
|
8183
|
+
record.workspace = session.cwd || workspace;
|
|
8184
|
+
record.warning = warning;
|
|
8185
|
+
oldSession.dispose();
|
|
8186
|
+
attachSessionListeners(record);
|
|
8187
|
+
persistSessionMetadata(record, "idle");
|
|
8188
|
+
broadcast(buildHistoryEvent({
|
|
8189
|
+
sessionId: record.id,
|
|
8190
|
+
workspace: record.workspace,
|
|
8191
|
+
source: record.source,
|
|
8192
|
+
runtimeId: record.runtimeId,
|
|
8193
|
+
isStreaming: false,
|
|
8194
|
+
messages: conversationMessages(record),
|
|
8195
|
+
}));
|
|
8196
|
+
if (warning)
|
|
8197
|
+
broadcast({ type: "session.warning", sessionId: record.id, warning });
|
|
8198
|
+
console.warn(`[session-abort] reopened stuck runtime for ${record.id}`);
|
|
8199
|
+
}
|
|
8200
|
+
catch (error) {
|
|
8201
|
+
broadcast({ type: "session.error", sessionId: record.id, error: `Could not recover the stopped agent: ${error instanceof Error ? error.message : String(error)}` });
|
|
8202
|
+
}
|
|
8203
|
+
finally {
|
|
8204
|
+
record.abortRecovery = undefined;
|
|
8205
|
+
}
|
|
8206
|
+
}
|
|
8120
8207
|
async function createSession(workspace = defaultWorkspace, sessionFile, opts = {}) {
|
|
8121
8208
|
const makeActive = opts.makeActive !== false;
|
|
8122
8209
|
// Normalize the resume ref into the token the owning runtime actually expects.
|
|
@@ -10803,6 +10890,7 @@ app.post("/api/session/prompt", async (req, res, next) => {
|
|
|
10803
10890
|
if (!record) {
|
|
10804
10891
|
record = await createWorkspaceSession(requestedWorkspace ?? defaultWorkspace, { title: titleText, runtimeId: agentFrom(req.body ?? {}) });
|
|
10805
10892
|
}
|
|
10893
|
+
await record.abortRecovery;
|
|
10806
10894
|
if (record.tuiTermId || record.tuiRefreshing) {
|
|
10807
10895
|
return res.status(409).json({ error: record.tuiRefreshing ? "This session is returning from the terminal. Try again in a moment." : "This session is open in the terminal (TUI). Close the TUI to chat here." });
|
|
10808
10896
|
}
|
|
@@ -0,0 +1,16 @@
|
|
|
1
|
+
// SPDX-License-Identifier: FSL-1.1-ALv2
|
|
2
|
+
// Copyright (c) 2026 Petter André Sjulstad
|
|
3
|
+
/**
|
|
4
|
+
* Settle Bivy's view of a turn before asking the runtime to abort it.
|
|
5
|
+
*
|
|
6
|
+
* Runtime aborts are deliberately best-effort: an in-process SDK can be wedged
|
|
7
|
+
* in the very operation abort is meant to interrupt. Stop must therefore not
|
|
8
|
+
* await that SDK before the client is allowed to leave its working state.
|
|
9
|
+
*/
|
|
10
|
+
export function forceAbortTurn(options) {
|
|
11
|
+
options.settle();
|
|
12
|
+
options.notifySettled();
|
|
13
|
+
void Promise.resolve()
|
|
14
|
+
.then(options.abort)
|
|
15
|
+
.catch((error) => options.onAbortError?.(error));
|
|
16
|
+
}
|
package/package.json
CHANGED