dsh-ntr 0.3.3 → 0.3.5
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/lib/bridge.js +741 -94
- package/lib/client.js.map +1 -1
- package/lib/impl.js +138 -16
- package/lib/index.js +25 -1
- package/lib/types/bridge.d.ts +220 -37
- package/lib/types/impl.d.ts +25 -0
- package/lib/types/protocol.d.ts +3 -2
- package/package.json +1 -1
- package/src/bridge.ts +783 -107
- package/src/impl.ts +147 -17
- package/src/index.ts +26 -3
- package/src/protocol.ts +3 -2
package/lib/bridge.js
CHANGED
|
@@ -6,13 +6,13 @@
|
|
|
6
6
|
* Connection RPC channel (authenticated) into `BridgeManager`, which is the
|
|
7
7
|
* only object allowed to spawn a configured command.
|
|
8
8
|
*/
|
|
9
|
-
import { spawn } from 'node:child_process';
|
|
9
|
+
import { spawn, spawnSync } from 'node:child_process';
|
|
10
10
|
import { homedir } from 'node:os';
|
|
11
11
|
import { delimiter, dirname, join } from 'node:path';
|
|
12
12
|
import { Writable, Readable } from 'node:stream';
|
|
13
13
|
import { accessSync, constants, existsSync, mkdirSync, readFileSync, realpathSync, renameSync, statSync, writeFileSync } from 'node:fs';
|
|
14
14
|
import { client, methods, ndJsonStream, PROTOCOL_VERSION, RequestError, } from '@agentclientprotocol/sdk';
|
|
15
|
-
import { AcpError, EventHub, PermissionLedger, Transcript, acpToolCallDelta, acpToolCallFromUpdate, buildAdapterModelId, childEnv, isAcpToolContinuation, isUnderPath, mergeCatalogShape, parseCatalogOptions, usableConfigOptions, permissionRequestFrame, pickAutoOption, quoteCommandToken, randomUUID, resolveFsPath, resolveSessionCwd, selectForwardPrompt, isAbsolutePath, acpPresetStatusFromRoster, splitAdapterModelId, updateContentToText, describeUnhandledUpdate, BOOKKEEPING_UPDATES, validateAgentDef, validateTagHalo, validateApprovalDeadlines, validatePermissionTimeoutMode, normalizeApprovalWindow, } from "./impl.js";
|
|
15
|
+
import { AcpError, EventHub, PermissionLedger, Transcript, acpToolCallDelta, acpToolCallFromUpdate, buildAdapterModelId, childEnv, hasUnambiguousModelSelector, isAcpToolContinuation, isUnderPath, mergeCatalogShape, parseCatalogOptions, usableConfigOptions, permissionRequestFrame, pickAutoOption, quoteCommandToken, randomUUID, resolveFsPath, resolveSessionCwd, selectForwardPrompt, isAbsolutePath, acpPresetStatusFromRoster, splitAdapterModelId, updateContentToText, describeUnhandledUpdate, BOOKKEEPING_UPDATES, validateAgentDef, validateTagHalo, validateApprovalDeadlines, validatePermissionTimeoutMode, normalizeApprovalWindow, } from "./impl.js";
|
|
16
16
|
import { DEFAULT_IDLE_TIMEOUT_MS, DEFAULT_TAG_HALO, IDLE_RELEASE_MS } from "./protocol.js";
|
|
17
17
|
import { OUTPUT_CHARS_PER_TOKEN, TurnTpsTracker } from "./tps.js";
|
|
18
18
|
/**
|
|
@@ -42,6 +42,37 @@ const FS_MAX_WRITE_BYTES = 2 * 1024 * 1024;
|
|
|
42
42
|
* stop — and with it the stall reclaim that calls it — forever.
|
|
43
43
|
*/
|
|
44
44
|
const SESSION_CLOSE_TIMEOUT_MS = 5_000;
|
|
45
|
+
/**
|
|
46
|
+
* The second — and last — slice after the escalation kill (`SIGKILL` on POSIX,
|
|
47
|
+
* a second `taskkill /T /F` on Windows). Deliberately short: past this point
|
|
48
|
+
* the child is the OS's problem (plus the exit reaper, see `reapLiveChildren`).
|
|
49
|
+
*
|
|
50
|
+
* `disposeChild` RESERVES this inside the budget it is handed, so every caller's
|
|
51
|
+
* number means the WHOLE disposal — graceful wait plus escalation wait — and
|
|
52
|
+
* never just the first one.
|
|
53
|
+
*/
|
|
54
|
+
const KILL_ESCALATION_GRACE_MS = 500;
|
|
55
|
+
/**
|
|
56
|
+
* Fire-and-forget kill: the whole disposal budget for the non-teardown paths
|
|
57
|
+
* (see `KILL_ESCALATION_GRACE_MS` for how it splits). Same 3s the old inline
|
|
58
|
+
* SIGKILL timer used before escalating.
|
|
59
|
+
*/
|
|
60
|
+
const KILL_ESCALATION_DELAY_MS = 3_000;
|
|
61
|
+
/**
|
|
62
|
+
* Budget for disposing a child whose start was invalidated by a teardown (see
|
|
63
|
+
* `AgentBridge.spawnAndConnect`). Short: that child was spawned moments ago and
|
|
64
|
+
* never served anything, so there is nothing to drain.
|
|
65
|
+
*/
|
|
66
|
+
const STALE_START_KILL_BUDGET_MS = 2_000;
|
|
67
|
+
/**
|
|
68
|
+
* The WHOLE host-shutdown budget for killing children, shared by every bridge
|
|
69
|
+
* (bridges run concurrently, so this is the worst case for all of them, not
|
|
70
|
+
* per bridge). One number, one deadline: `AgentBridge.shutdown` hands what is
|
|
71
|
+
* LEFT of it to `killChildAndWait`, and `disposeChild` reserves the escalation
|
|
72
|
+
* grace inside that — so the graceful wait gets at most 4s − 500ms and the
|
|
73
|
+
* whole disposal, escalation included, never exceeds 4s.
|
|
74
|
+
*/
|
|
75
|
+
const SHUTDOWN_BUDGET_MS = 4_000;
|
|
45
76
|
/**
|
|
46
77
|
* The plugin's normalized MCP server shape → the ACP wire shape.
|
|
47
78
|
*
|
|
@@ -110,6 +141,7 @@ const DEFAULT_TUNING = {
|
|
|
110
141
|
ambientQuietMs: 10_000,
|
|
111
142
|
ambientMaxMs: 120_000,
|
|
112
143
|
backgroundTaskMaxMs: 600_000,
|
|
144
|
+
backgroundTaskCeilingMs: 0,
|
|
113
145
|
backgroundParkedGraceMs: 30_000,
|
|
114
146
|
ambientStaleMs: 300_000,
|
|
115
147
|
};
|
|
@@ -129,6 +161,24 @@ export class AgentBridge {
|
|
|
129
161
|
stderrTail = [];
|
|
130
162
|
child;
|
|
131
163
|
conn;
|
|
164
|
+
/**
|
|
165
|
+
* Teardown generation, bumped ONLY by a deliberate teardown (`stop` /
|
|
166
|
+
* `shutdown` / `forceReset`), so a start that was already in flight can tell
|
|
167
|
+
* that nobody owns its outcome any more: `spawnAndConnect` re-checks this
|
|
168
|
+
* after each await and disposes the child it would otherwise have published
|
|
169
|
+
* as `ready`. Without it, a teardown landing during `ensureAgentExecutable`
|
|
170
|
+
* (a global npm install can take minutes) left a freshly spawned agent
|
|
171
|
+
* running with no owner.
|
|
172
|
+
*
|
|
173
|
+
* `releaseAll` and `killChild` deliberately do NOT bump it: both are also the
|
|
174
|
+
* failure path of a start's own handshake — a child dying during startup goes
|
|
175
|
+
* `exit` → `connectionLost` → `releaseAll('died')` — and that start must
|
|
176
|
+
* still publish `error` with its stderr tail rather than be mistaken for a
|
|
177
|
+
* teardown (see `ensureReady`). It cannot leak a process either way: a crash
|
|
178
|
+
* release only happens once the child exists, and `spawnAndConnect` has no
|
|
179
|
+
* spawn point left after that.
|
|
180
|
+
*/
|
|
181
|
+
teardownEpoch = 0;
|
|
132
182
|
starting;
|
|
133
183
|
sessions = new Map();
|
|
134
184
|
acpIndex = new Map();
|
|
@@ -609,10 +659,20 @@ export class AgentBridge {
|
|
|
609
659
|
return Promise.resolve();
|
|
610
660
|
if (this.starting !== undefined)
|
|
611
661
|
return this.starting;
|
|
662
|
+
const epoch = this.teardownEpoch;
|
|
612
663
|
this.state = 'starting';
|
|
613
664
|
this.publishStatus();
|
|
614
|
-
|
|
665
|
+
const started = this.spawnAndConnect(epoch)
|
|
615
666
|
.then(() => {
|
|
667
|
+
// A teardown landed while the handshake was in flight: it owns the
|
|
668
|
+
// bridge now, and `spawnAndConnect` has already disposed whatever it
|
|
669
|
+
// spawned. Publishing `ready` here would paint a live status back over
|
|
670
|
+
// the stopped bridge. `state` catches the one teardown that does NOT
|
|
671
|
+
// bump the epoch — a crash release (`releaseAll`): without it, a
|
|
672
|
+
// handshake that resolved just as the child died would paint `ready`
|
|
673
|
+
// over the `stopped` that the crash left behind.
|
|
674
|
+
if (this.teardownEpoch !== epoch || this.state !== 'starting')
|
|
675
|
+
return;
|
|
616
676
|
this.state = 'ready';
|
|
617
677
|
this.lastError = undefined;
|
|
618
678
|
this.publishStatus();
|
|
@@ -621,6 +681,16 @@ export class AgentBridge {
|
|
|
621
681
|
this.touch();
|
|
622
682
|
})
|
|
623
683
|
.catch((error) => {
|
|
684
|
+
// Same guard: a start nobody owns any more must not publish its
|
|
685
|
+
// outcome. A start's OWN failure — `withStartupTimeout`, or the child
|
|
686
|
+
// dying mid-handshake (which runs through `releaseAll`) — still reaches
|
|
687
|
+
// the lines below and publishes `error` with its stderr tail; neither
|
|
688
|
+
// of those paths bumps the epoch.
|
|
689
|
+
if (this.teardownEpoch !== epoch) {
|
|
690
|
+
// Publish nothing, but keep the caller's error contract: every
|
|
691
|
+
// interrupted start has always surfaced as `agent_unreachable`.
|
|
692
|
+
throw new AcpError('agent_unreachable', `acp: agent "${this.def.id}" failed to start: ${error instanceof Error ? error.message : String(error)}`);
|
|
693
|
+
}
|
|
624
694
|
this.state = 'error';
|
|
625
695
|
this.lastError = error instanceof Error ? error.message : String(error);
|
|
626
696
|
this.clearIdleTimer();
|
|
@@ -628,15 +698,31 @@ export class AgentBridge {
|
|
|
628
698
|
this.publishStatus();
|
|
629
699
|
throw new AcpError('agent_unreachable', `acp: agent "${this.def.id}" failed to start: ${this.lastError}`);
|
|
630
700
|
})
|
|
631
|
-
.finally(() => {
|
|
632
|
-
|
|
701
|
+
.finally(() => {
|
|
702
|
+
// Only clear a slot that is still OURS: a teardown may have cleared it
|
|
703
|
+
// (and a fresh `ensureReady` may already hold it).
|
|
704
|
+
if (this.starting === started)
|
|
705
|
+
this.starting = undefined;
|
|
706
|
+
});
|
|
707
|
+
this.starting = started;
|
|
708
|
+
return started;
|
|
633
709
|
}
|
|
634
|
-
|
|
710
|
+
/**
|
|
711
|
+
* @param epoch - `teardownEpoch` captured when this start claimed the bridge.
|
|
712
|
+
* Every await below is a window in which a teardown can land; a stale start
|
|
713
|
+
* owns nothing and must leave no process behind (see `teardownEpoch`).
|
|
714
|
+
*/
|
|
715
|
+
async spawnAndConnect(epoch) {
|
|
635
716
|
this.stderrTail = [];
|
|
636
717
|
// Resolve (and, with npmPackage, auto-install) the executable before the
|
|
637
718
|
// spawn; the resolved absolute path also pins the lookup to the host PATH
|
|
638
719
|
// plus the well-known extra dirs, not the child env the agent may override.
|
|
639
720
|
const { command } = await ensureAgentExecutable(this.def, undefined, line => this.noteStderr(line));
|
|
721
|
+
// A teardown landed while the executable was being resolved — a global
|
|
722
|
+
// install can hold this await for minutes. Nothing has been spawned yet, so
|
|
723
|
+
// there is nothing to reap; just refuse to create the process.
|
|
724
|
+
if (this.teardownEpoch !== epoch)
|
|
725
|
+
return;
|
|
640
726
|
const cwd = safeSpawnCwd(this.def, this.wantedSpawnCwd);
|
|
641
727
|
this.spawnCwd = cwd;
|
|
642
728
|
const launch = windowsShimLaunch(command, this.def.args ?? []);
|
|
@@ -652,6 +738,7 @@ export class AgentBridge {
|
|
|
652
738
|
...(launch.verbatim ? { windowsVerbatimArguments: true } : {}),
|
|
653
739
|
});
|
|
654
740
|
this.child = child;
|
|
741
|
+
trackAgentChild(child);
|
|
655
742
|
child.on('error', (error) => {
|
|
656
743
|
// Identity guard: a stale event from a PREVIOUS child must not tear down
|
|
657
744
|
// a connection that replaced it (an `exit` can land after the respawn
|
|
@@ -709,6 +796,25 @@ export class AgentBridge {
|
|
|
709
796
|
},
|
|
710
797
|
clientInfo: { name: 'dsh-ntr', title: 'DSH NTR', version: PLUGIN_VERSION },
|
|
711
798
|
}), 'initialize');
|
|
799
|
+
// The second — and widest — window: `initialize` waits up to the startup
|
|
800
|
+
// budget, and a shutdown / stop during it already dropped the connection and
|
|
801
|
+
// killed the child we spawned. That child is OURS even though the bridge no
|
|
802
|
+
// longer points at it, so dispose it with the awaitable kill instead of
|
|
803
|
+
// leaving a process nobody can reach.
|
|
804
|
+
if (this.teardownEpoch !== epoch) {
|
|
805
|
+
// Only when it is still the CURRENT connection: a newer one belongs to a
|
|
806
|
+
// fresh start and must not be closed from here.
|
|
807
|
+
if (this.conn === conn) {
|
|
808
|
+
this.conn = undefined;
|
|
809
|
+
conn.close();
|
|
810
|
+
}
|
|
811
|
+
if (this.child === child) {
|
|
812
|
+
this.child = undefined;
|
|
813
|
+
this.spawnCwd = undefined;
|
|
814
|
+
}
|
|
815
|
+
await disposeChild(child, STALE_START_KILL_BUDGET_MS);
|
|
816
|
+
return;
|
|
817
|
+
}
|
|
712
818
|
this.initResult = init;
|
|
713
819
|
// The fork capability is agent knowledge that must outlive the process:
|
|
714
820
|
// report it so the manager can persist a LAST KNOWN copy and serve the
|
|
@@ -1144,14 +1250,18 @@ export class AgentBridge {
|
|
|
1144
1250
|
// workspace from its own cwd would come back in the plugin's sandbox.
|
|
1145
1251
|
const workspaceRoot = chatWorkspaceRoot(this.sessionWorkspace?.(), session.dshSessionId);
|
|
1146
1252
|
await this.ensureReady(workspaceRoot);
|
|
1253
|
+
// The CONNECTION first: a teardown that landed during the start leaves no
|
|
1254
|
+
// `initResult` either, and reporting that as `resume_unsupported` would
|
|
1255
|
+
// blame the agent for a capability it may well have. "The bridge is not
|
|
1256
|
+
// up" is the honest answer.
|
|
1257
|
+
const conn = this.conn;
|
|
1258
|
+
if (conn === undefined)
|
|
1259
|
+
throw new AcpError('agent_unreachable', 'acp: connection is not open');
|
|
1147
1260
|
// The capability comes from the FRESH initialize: after a crash the
|
|
1148
1261
|
// bridge restarts the process, so check the (new) initResult here.
|
|
1149
1262
|
if (this.initResult?.agentCapabilities?.loadSession !== true) {
|
|
1150
1263
|
throw new AcpError('resume_unsupported', `acp: agent "${this.def.id}" does not advertise the loadSession capability; start a new session`);
|
|
1151
1264
|
}
|
|
1152
|
-
const conn = this.conn;
|
|
1153
|
-
if (conn === undefined)
|
|
1154
|
-
throw new AcpError('agent_unreachable', 'acp: connection is not open');
|
|
1155
1265
|
const resolved = resolveSessionCwd(this.def, session.cwd, p => realpathSync(p), workspaceRoot === undefined ? [] : [workspaceRoot]);
|
|
1156
1266
|
// Replay arrives as brand-new updates, so the transcript restarts too;
|
|
1157
1267
|
// seq keeps meaning "position in the current epoch". What the buffer held
|
|
@@ -1215,9 +1325,41 @@ export class AgentBridge {
|
|
|
1215
1325
|
throw error;
|
|
1216
1326
|
}
|
|
1217
1327
|
}
|
|
1218
|
-
/**
|
|
1328
|
+
/**
|
|
1329
|
+
* Release all live sessions (process died or went idle): keep them resumable,
|
|
1330
|
+
* settle permissions, drop the connection.
|
|
1331
|
+
*
|
|
1332
|
+
* Deliberately does NOT bump `teardownEpoch`: a child's death during startup
|
|
1333
|
+
* runs through `connectionLost` into this method, and that start must still
|
|
1334
|
+
* publish `error` with its stderr tail — the settings card expands on exactly
|
|
1335
|
+
* that pair (see `view` / `teardownEpoch`).
|
|
1336
|
+
*/
|
|
1219
1337
|
releaseAll(reason, cause) {
|
|
1220
1338
|
this.clearIdleTimer();
|
|
1339
|
+
this.parkSessions(reason, cause);
|
|
1340
|
+
this.conn?.close();
|
|
1341
|
+
this.conn = undefined;
|
|
1342
|
+
this.killChild();
|
|
1343
|
+
this.state = 'stopped';
|
|
1344
|
+
this.initResult = undefined;
|
|
1345
|
+
this.publishStatus();
|
|
1346
|
+
}
|
|
1347
|
+
/**
|
|
1348
|
+
* Park every live session as RELEASED — resumable through `session/load`,
|
|
1349
|
+
* never destroyed — and persist the records.
|
|
1350
|
+
*
|
|
1351
|
+
* This is the local half shared by `releaseAll` (crash / idle / workspace) and
|
|
1352
|
+
* `shutdown` (host exit): those paths differ only in what happens to the child
|
|
1353
|
+
* afterwards. A `closed` session is skipped — its record belongs to
|
|
1354
|
+
* `closeSession`, and flipping it to released would let a resume resurrect a
|
|
1355
|
+
* conversation that is being destroyed (constraint 10).
|
|
1356
|
+
*
|
|
1357
|
+
* Returns how many sessions were parked, so a shutdown with nothing to park
|
|
1358
|
+
* does not rewrite the state file (a second `shutdownAll` must not erase the
|
|
1359
|
+
* records the first one persisted).
|
|
1360
|
+
*/
|
|
1361
|
+
parkSessions(reason, cause) {
|
|
1362
|
+
let parked = 0;
|
|
1221
1363
|
for (const session of [...this.sessions.values()]) {
|
|
1222
1364
|
// A session already being closed is `closeSession`'s to delete. Marking
|
|
1223
1365
|
// it released would park it as resumable, and `doResumeSession` flips
|
|
@@ -1240,19 +1382,16 @@ export class AgentBridge {
|
|
|
1240
1382
|
this.settlePermissions(session);
|
|
1241
1383
|
this.released.set(session.sessionId, session);
|
|
1242
1384
|
this.hub.broadcast({ type: 'session/released', sessionId: session.sessionId, session: snapshot(session), reason, cause });
|
|
1385
|
+
parked += 1;
|
|
1243
1386
|
}
|
|
1244
1387
|
this.sessions.clear();
|
|
1245
1388
|
this.acpIndex.clear();
|
|
1246
|
-
this.conn?.close();
|
|
1247
|
-
this.conn = undefined;
|
|
1248
|
-
this.killChild();
|
|
1249
|
-
this.state = 'stopped';
|
|
1250
|
-
this.initResult = undefined;
|
|
1251
|
-
this.publishStatus();
|
|
1252
1389
|
// The parked records are what keeps them resumable after a host restart —
|
|
1253
1390
|
// persist now, while their transcripts (the `hasHistory` evidence) are
|
|
1254
1391
|
// still the ones they were parked with.
|
|
1255
|
-
|
|
1392
|
+
if (parked > 0)
|
|
1393
|
+
this.onSessionsDirty?.();
|
|
1394
|
+
return parked;
|
|
1256
1395
|
}
|
|
1257
1396
|
/** Detach one released session record for a resume attempt. */
|
|
1258
1397
|
takeReleased(sessionId) {
|
|
@@ -1390,6 +1529,13 @@ export class AgentBridge {
|
|
|
1390
1529
|
}
|
|
1391
1530
|
/** Kill the child and drop every local session bound to this connection. */
|
|
1392
1531
|
async stop(reason = 'agent-stopped') {
|
|
1532
|
+
// Deliberate teardown: an in-flight start owns nothing any more, and must
|
|
1533
|
+
// not be able to publish `ready` over the stopped state that follows.
|
|
1534
|
+
this.teardownEpoch += 1;
|
|
1535
|
+
// Drop the memoized start: a later `ensureReady` must not adopt a promise
|
|
1536
|
+
// whose result was already discarded. That promise's own `finally`
|
|
1537
|
+
// re-checks identity, so it cannot clear a NEWER start's slot.
|
|
1538
|
+
this.starting = undefined;
|
|
1393
1539
|
this.clearIdleTimer();
|
|
1394
1540
|
for (const session of [...this.sessions.values()]) {
|
|
1395
1541
|
await this.closeSession(session, reason);
|
|
@@ -1415,6 +1561,8 @@ export class AgentBridge {
|
|
|
1415
1561
|
* message. This is a stop, not a discard.
|
|
1416
1562
|
*/
|
|
1417
1563
|
forceReset(reason) {
|
|
1564
|
+
this.teardownEpoch += 1;
|
|
1565
|
+
this.starting = undefined;
|
|
1418
1566
|
this.clearIdleTimer();
|
|
1419
1567
|
for (const session of [...this.sessions.values()]) {
|
|
1420
1568
|
session.closed = true;
|
|
@@ -1433,6 +1581,13 @@ export class AgentBridge {
|
|
|
1433
1581
|
this.initResult = undefined;
|
|
1434
1582
|
this.publishStatus();
|
|
1435
1583
|
}
|
|
1584
|
+
/**
|
|
1585
|
+
* Fire-and-forget kill for the NON-teardown paths: idle release, workspace
|
|
1586
|
+
* move, crash handling, `forceReset`, and a start's own failure. None of them
|
|
1587
|
+
* may block on a dying process, and CRASH ≠ DESTROY (constraint 10): the
|
|
1588
|
+
* sessions were already parked by the caller, this only takes the process
|
|
1589
|
+
* away. The awaitable variant is `killChildAndWait`, used by host shutdown.
|
|
1590
|
+
*/
|
|
1436
1591
|
killChild() {
|
|
1437
1592
|
const child = this.child;
|
|
1438
1593
|
this.child = undefined;
|
|
@@ -1440,36 +1595,59 @@ export class AgentBridge {
|
|
|
1440
1595
|
// `spawnAndConnect`). Leaving it behind would make `ensureReady` believe a
|
|
1441
1596
|
// process is still running in that directory.
|
|
1442
1597
|
this.spawnCwd = undefined;
|
|
1443
|
-
if (child === undefined
|
|
1598
|
+
if (child === undefined)
|
|
1444
1599
|
return;
|
|
1445
|
-
|
|
1446
|
-
|
|
1447
|
-
|
|
1448
|
-
|
|
1449
|
-
|
|
1450
|
-
|
|
1451
|
-
|
|
1452
|
-
|
|
1453
|
-
|
|
1454
|
-
|
|
1455
|
-
|
|
1456
|
-
|
|
1600
|
+
void disposeChild(child, KILL_ESCALATION_DELAY_MS);
|
|
1601
|
+
}
|
|
1602
|
+
/**
|
|
1603
|
+
* Take the current child away and WAIT until it is gone (bounded). Only the
|
|
1604
|
+
* host shutdown path uses this — every other teardown must stay
|
|
1605
|
+
* fire-and-forget.
|
|
1606
|
+
*
|
|
1607
|
+
* Always resolves: a teardown that could hang on an agent ignoring SIGTERM
|
|
1608
|
+
* would be worse than a leaked process, and the process-exit reaper
|
|
1609
|
+
* (`reapLiveChildren`) still holds the pid if even the escalation loses.
|
|
1610
|
+
*/
|
|
1611
|
+
async killChildAndWait(budgetMs) {
|
|
1612
|
+
const child = this.child;
|
|
1613
|
+
this.child = undefined;
|
|
1614
|
+
this.spawnCwd = undefined;
|
|
1615
|
+
if (child === undefined)
|
|
1457
1616
|
return;
|
|
1458
|
-
|
|
1459
|
-
|
|
1460
|
-
|
|
1461
|
-
|
|
1462
|
-
|
|
1463
|
-
|
|
1464
|
-
|
|
1465
|
-
|
|
1466
|
-
|
|
1467
|
-
|
|
1468
|
-
|
|
1469
|
-
|
|
1470
|
-
|
|
1471
|
-
|
|
1472
|
-
|
|
1617
|
+
await disposeChild(child, budgetMs);
|
|
1618
|
+
}
|
|
1619
|
+
/**
|
|
1620
|
+
* Host shutdown: park every live session as RELEASED (resumable, exactly as a
|
|
1621
|
+
* crash would leave it), then WAIT for the child to die.
|
|
1622
|
+
*
|
|
1623
|
+
* Deliberately not `stop`: `stop` asks every session's agent to
|
|
1624
|
+
* `session/close` and then clears the released records — i.e. it DESTROYS the
|
|
1625
|
+
* conversations. A host shutdown is not the user's "stop this agent" gesture:
|
|
1626
|
+
* the host is coming back, and the conversations must come back with it
|
|
1627
|
+
* (constraint 10, plus the `chatSessions` records that outlive the restart).
|
|
1628
|
+
* Skipping the per-session RPC is also what keeps teardown bounded: N sessions
|
|
1629
|
+
* would otherwise spend up to N × SESSION_CLOSE_TIMEOUT_MS before the child
|
|
1630
|
+
* was even signalled, and the process is about to die anyway — "the agent
|
|
1631
|
+
* forgot this session" costs nothing a `session/load` cannot rebuild.
|
|
1632
|
+
*
|
|
1633
|
+
* Always resolves: `budgetMs` bounds the WHOLE kill — parking is synchronous,
|
|
1634
|
+
* the graceful wait and the escalation both come out of the same deadline,
|
|
1635
|
+
* and whatever still lives after it is the exit reaper's.
|
|
1636
|
+
*/
|
|
1637
|
+
async shutdown(budgetMs = SHUTDOWN_BUDGET_MS) {
|
|
1638
|
+
const deadline = Date.now() + Math.max(0, budgetMs);
|
|
1639
|
+
// A deliberate teardown: an in-flight start must not publish `ready` (or
|
|
1640
|
+
// leave its process running) over the stopped state this leaves behind.
|
|
1641
|
+
this.teardownEpoch += 1;
|
|
1642
|
+
this.starting = undefined;
|
|
1643
|
+
this.clearIdleTimer();
|
|
1644
|
+
this.parkSessions('the host is shutting down', 'shutdown');
|
|
1645
|
+
this.conn?.close();
|
|
1646
|
+
this.conn = undefined;
|
|
1647
|
+
this.state = 'stopped';
|
|
1648
|
+
this.initResult = undefined;
|
|
1649
|
+
this.publishStatus();
|
|
1650
|
+
await this.killChildAndWait(Math.max(0, deadline - Date.now()));
|
|
1473
1651
|
}
|
|
1474
1652
|
/**
|
|
1475
1653
|
* Answer one ACP permission ask.
|
|
@@ -1837,6 +2015,16 @@ function codebuddyMetaOf(source) {
|
|
|
1837
2015
|
out.isBackground = true;
|
|
1838
2016
|
if (meta['codebuddy.ai/isSubagent'] === true)
|
|
1839
2017
|
out.isSubagent = true;
|
|
2018
|
+
const subagentType = meta['codebuddy.ai/subagentType'];
|
|
2019
|
+
if (typeof subagentType === 'string' && subagentType !== '')
|
|
2020
|
+
out.subagentType = subagentType;
|
|
2021
|
+
const subagentDescription = meta['codebuddy.ai/description'];
|
|
2022
|
+
if (typeof subagentDescription === 'string' && subagentDescription !== '') {
|
|
2023
|
+
out.subagentDescription = subagentDescription;
|
|
2024
|
+
}
|
|
2025
|
+
const subagentName = meta['codebuddy.ai/memberName'];
|
|
2026
|
+
if (typeof subagentName === 'string' && subagentName !== '')
|
|
2027
|
+
out.subagentName = subagentName;
|
|
1840
2028
|
const unsolicited = meta['codebuddy.ai/unsolicitedTurn'];
|
|
1841
2029
|
if (typeof unsolicited === 'object' && unsolicited !== null) {
|
|
1842
2030
|
const run = unsolicited;
|
|
@@ -2010,6 +2198,71 @@ export function permissionAskReason(toolCall) {
|
|
|
2010
2198
|
const line = detail === undefined || detail === title ? title : `${title} · ${detail}`;
|
|
2011
2199
|
return line.length > APPROVAL_REASON_MAX ? `${line.slice(0, APPROVAL_REASON_MAX - 1)}…` : line;
|
|
2012
2200
|
}
|
|
2201
|
+
/** Longest label the host's subagent catalog row carries for an ACP worker. */
|
|
2202
|
+
const SUBAGENT_CATALOG_LABEL_MAX = 160;
|
|
2203
|
+
/** The structured input object an ACP tool call declared, if any. */
|
|
2204
|
+
function rawInputObjectOf(update) {
|
|
2205
|
+
const raw = update.rawInput ?? update.arguments;
|
|
2206
|
+
if (raw !== null && typeof raw === 'object' && !Array.isArray(raw))
|
|
2207
|
+
return raw;
|
|
2208
|
+
if (typeof raw === 'string' && raw.length <= 16_384) {
|
|
2209
|
+
try {
|
|
2210
|
+
const parsed = JSON.parse(raw);
|
|
2211
|
+
if (parsed !== null && typeof parsed === 'object' && !Array.isArray(parsed))
|
|
2212
|
+
return parsed;
|
|
2213
|
+
}
|
|
2214
|
+
catch {
|
|
2215
|
+
return undefined;
|
|
2216
|
+
}
|
|
2217
|
+
}
|
|
2218
|
+
return undefined;
|
|
2219
|
+
}
|
|
2220
|
+
/**
|
|
2221
|
+
* One line naming a detached ACP worker in the host's subagent catalog.
|
|
2222
|
+
*
|
|
2223
|
+
* The identity is read from what CodeBuddy actually sends, in order: the
|
|
2224
|
+
* `_meta` keys on the dispatch call's result frames (`subagentType` /
|
|
2225
|
+
* `description` / `memberName` — the FIRST `tool_call` omits `rawInput`, so
|
|
2226
|
+
* these are often the only source), then the structured `rawInput`, then the
|
|
2227
|
+
* tool title. The prompt body is only used when nothing shorter exists. Capped
|
|
2228
|
+
* so a pathological input cannot fill the catalog row.
|
|
2229
|
+
*/
|
|
2230
|
+
export function subagentDispatchLabel(update) {
|
|
2231
|
+
const meta = codebuddyMetaOf(update);
|
|
2232
|
+
const input = rawInputObjectOf(update) ?? {};
|
|
2233
|
+
const type = meta?.subagentType
|
|
2234
|
+
?? nonEmptyStringOf(input.subagent_type)
|
|
2235
|
+
?? nonEmptyStringOf(input.subagentType)
|
|
2236
|
+
?? permissionToolName(update);
|
|
2237
|
+
const detail = meta?.subagentName
|
|
2238
|
+
?? nonEmptyStringOf(input.name)
|
|
2239
|
+
?? meta?.subagentDescription
|
|
2240
|
+
?? nonEmptyStringOf(input.description)
|
|
2241
|
+
?? nonEmptyStringOf(input.prompt);
|
|
2242
|
+
const line = detail === undefined || detail === type ? type : `${type} · ${detail}`;
|
|
2243
|
+
return line.length > SUBAGENT_CATALOG_LABEL_MAX ? `${line.slice(0, SUBAGENT_CATALOG_LABEL_MAX - 1)}…` : line;
|
|
2244
|
+
}
|
|
2245
|
+
/**
|
|
2246
|
+
* Whether the update declared the worker's IDENTITY (role / task / name)
|
|
2247
|
+
* rather than just restating the `Agent` tool. A bare dispatch names the tool
|
|
2248
|
+
* as "Agent"; reporting that as the catalog label says nothing, so the report
|
|
2249
|
+
* waits for one of these facts (or for the dispatch to complete).
|
|
2250
|
+
*/
|
|
2251
|
+
function subagentDispatchDeclaresIdentity(update) {
|
|
2252
|
+
const meta = codebuddyMetaOf(update);
|
|
2253
|
+
if (meta?.subagentType !== undefined
|
|
2254
|
+
|| meta?.subagentDescription !== undefined
|
|
2255
|
+
|| meta?.subagentName !== undefined)
|
|
2256
|
+
return true;
|
|
2257
|
+
const input = rawInputObjectOf(update);
|
|
2258
|
+
if (input === undefined)
|
|
2259
|
+
return false;
|
|
2260
|
+
return ['subagent_type', 'subagentType', 'name', 'description', 'prompt']
|
|
2261
|
+
.some(key => nonEmptyStringOf(input[key]) !== undefined);
|
|
2262
|
+
}
|
|
2263
|
+
function nonEmptyStringOf(value) {
|
|
2264
|
+
return typeof value === 'string' && value !== '' ? value : undefined;
|
|
2265
|
+
}
|
|
2013
2266
|
function snapshot(session) {
|
|
2014
2267
|
return {
|
|
2015
2268
|
sessionId: session.sessionId,
|
|
@@ -2490,8 +2743,141 @@ export function killProcessTree(pid) {
|
|
|
2490
2743
|
try {
|
|
2491
2744
|
const killer = spawn('taskkill', ['/pid', String(pid), '/t', '/f'], { stdio: 'ignore', windowsHide: true });
|
|
2492
2745
|
killer.on('error', () => { });
|
|
2746
|
+
// Handed back so a caller that wants to wait can; the existing
|
|
2747
|
+
// fire-and-forget callers simply ignore it.
|
|
2748
|
+
return killer;
|
|
2749
|
+
}
|
|
2750
|
+
catch {
|
|
2751
|
+
return undefined;
|
|
2752
|
+
}
|
|
2753
|
+
}
|
|
2754
|
+
/** Every agent child this host started and has not seen exit yet, by pid. */
|
|
2755
|
+
const liveChildPids = new Set();
|
|
2756
|
+
/** Whether the process-exit reaper is already installed (installed once). */
|
|
2757
|
+
let exitReaperArmed = false;
|
|
2758
|
+
/** Remember a freshly spawned agent child for the last-resort reaper. */
|
|
2759
|
+
function trackAgentChild(child) {
|
|
2760
|
+
const pid = child.pid;
|
|
2761
|
+
if (pid === undefined)
|
|
2762
|
+
return;
|
|
2763
|
+
liveChildPids.add(pid);
|
|
2764
|
+
// Both events: `exit` is the process fact, `close` is it plus its stdio being
|
|
2765
|
+
// closed. Whichever comes first forgets the pid.
|
|
2766
|
+
const forget = () => { liveChildPids.delete(pid); };
|
|
2767
|
+
child.once('exit', forget);
|
|
2768
|
+
child.once('close', forget);
|
|
2769
|
+
if (exitReaperArmed)
|
|
2770
|
+
return;
|
|
2771
|
+
exitReaperArmed = true;
|
|
2772
|
+
process.once('exit', reapLiveChildren);
|
|
2773
|
+
}
|
|
2774
|
+
/**
|
|
2775
|
+
* Last resort for a host that leaves without a graceful teardown: an unhandled
|
|
2776
|
+
* crash that still runs `exit`, an explicit `process.exit()`, or a disposer the
|
|
2777
|
+
* host never awaited. Only SYNC calls are legal here — the `exit` event cannot
|
|
2778
|
+
* wait for a promise — hence `spawnSync` and a bare `process.kill`.
|
|
2779
|
+
*
|
|
2780
|
+
* Deliberately NOT a `SIGINT` / `SIGTERM` handler: registering one flips the
|
|
2781
|
+
* host's own default disposition (killing the default "exit on Ctrl-C", or
|
|
2782
|
+
* double-handling the host's shutdown and hanging it). A host that handles the
|
|
2783
|
+
* signal itself still reaches `exit`; one that is SIGKILLed can run nothing at
|
|
2784
|
+
* all (see AGENTS/protocol.md §7 for that limit).
|
|
2785
|
+
*/
|
|
2786
|
+
function reapLiveChildren() {
|
|
2787
|
+
for (const pid of liveChildPids) {
|
|
2788
|
+
try {
|
|
2789
|
+
if (process.platform === 'win32') {
|
|
2790
|
+
spawnSync('taskkill', ['/pid', String(pid), '/t', '/f'], { stdio: 'ignore', windowsHide: true });
|
|
2791
|
+
}
|
|
2792
|
+
else {
|
|
2793
|
+
// The child was spawned `detached`, so it leads its own process group:
|
|
2794
|
+
// -pid reaches everything it started (same reasoning as the graceful
|
|
2795
|
+
// POSIX kill).
|
|
2796
|
+
process.kill(-pid, 'SIGKILL');
|
|
2797
|
+
}
|
|
2798
|
+
}
|
|
2799
|
+
catch { /* already gone, or no permission left to try */ }
|
|
2493
2800
|
}
|
|
2494
|
-
|
|
2801
|
+
liveChildPids.clear();
|
|
2802
|
+
}
|
|
2803
|
+
/**
|
|
2804
|
+
* End the child's stdin, dispatch the platform kill, and wait until the process
|
|
2805
|
+
* is gone — bounded, and never rejecting.
|
|
2806
|
+
*
|
|
2807
|
+
* The escalation is deliberately platform-shaped: POSIX gets a process-GROUP
|
|
2808
|
+
* `SIGKILL` (an agent that ignores SIGTERM is exactly what a second signal is
|
|
2809
|
+
* for), Windows a second `taskkill /T /F` (the first may never have run at all:
|
|
2810
|
+
* a failed spawn of `taskkill` is swallowed by `killProcessTree`).
|
|
2811
|
+
*/
|
|
2812
|
+
async function disposeChild(child, budgetMs) {
|
|
2813
|
+
// ONE deadline for the whole disposal: the escalation wait comes out of the
|
|
2814
|
+
// same budget, so `budgetMs` can never turn into "grace period + grace
|
|
2815
|
+
// period" (see `KILL_ESCALATION_GRACE_MS`).
|
|
2816
|
+
const deadline = Date.now() + Math.max(0, budgetMs);
|
|
2817
|
+
try {
|
|
2818
|
+
child.stdin?.end();
|
|
2819
|
+
}
|
|
2820
|
+
catch { /* already gone */ }
|
|
2821
|
+
const pid = child.pid;
|
|
2822
|
+
if (pid === undefined)
|
|
2823
|
+
return;
|
|
2824
|
+
// A child that already exited may have had its pid reused: signalling it
|
|
2825
|
+
// would reach an unrelated process (group). Nothing left to kill.
|
|
2826
|
+
if (child.exitCode !== null || child.signalCode !== null)
|
|
2827
|
+
return;
|
|
2828
|
+
const windows = process.platform === 'win32';
|
|
2829
|
+
if (windows) {
|
|
2830
|
+
// Parity with the POSIX branch, which kills the whole process GROUP: on
|
|
2831
|
+
// Windows "the group" is the process tree, and it has to be killed while
|
|
2832
|
+
// the tree is intact — killing the direct child first (a `cmd.exe` shim
|
|
2833
|
+
// wrapper, see `windowsShimLaunch`) re-parents the agent out of
|
|
2834
|
+
// `taskkill /T`'s reach. `stdin.end()` above is the graceful half.
|
|
2835
|
+
killProcessTree(pid);
|
|
2836
|
+
}
|
|
2837
|
+
else {
|
|
2838
|
+
try {
|
|
2839
|
+
process.kill(-pid, 'SIGTERM');
|
|
2840
|
+
}
|
|
2841
|
+
catch {
|
|
2842
|
+
child.kill('SIGTERM');
|
|
2843
|
+
}
|
|
2844
|
+
}
|
|
2845
|
+
// Everything except the escalation slice: the second wait is only there to
|
|
2846
|
+
// confirm a SIGKILL / taskkill landed, and it must fit inside the same budget.
|
|
2847
|
+
if (await waitForChildExit(child, Math.max(0, budgetMs - KILL_ESCALATION_GRACE_MS)))
|
|
2848
|
+
return;
|
|
2849
|
+
if (windows) {
|
|
2850
|
+
killProcessTree(pid);
|
|
2851
|
+
}
|
|
2852
|
+
else {
|
|
2853
|
+
try {
|
|
2854
|
+
process.kill(-pid, 'SIGKILL');
|
|
2855
|
+
}
|
|
2856
|
+
catch { /* already gone */ }
|
|
2857
|
+
}
|
|
2858
|
+
// Whatever is left of the deadline, never a fresh grace period.
|
|
2859
|
+
await waitForChildExit(child, Math.max(0, deadline - Date.now()));
|
|
2860
|
+
}
|
|
2861
|
+
/** Resolve true once the child is gone, false when the budget runs out first. */
|
|
2862
|
+
function waitForChildExit(child, budgetMs) {
|
|
2863
|
+
if (child.exitCode !== null || child.signalCode !== null)
|
|
2864
|
+
return Promise.resolve(true);
|
|
2865
|
+
return new Promise((resolve) => {
|
|
2866
|
+
let timer;
|
|
2867
|
+
const settle = (gone) => {
|
|
2868
|
+
if (timer !== undefined)
|
|
2869
|
+
clearTimeout(timer);
|
|
2870
|
+
child.off('exit', onGone);
|
|
2871
|
+
child.off('close', onGone);
|
|
2872
|
+
resolve(gone);
|
|
2873
|
+
};
|
|
2874
|
+
const onGone = () => { settle(true); };
|
|
2875
|
+
timer = setTimeout(() => { settle(false); }, budgetMs);
|
|
2876
|
+
// The host may exit while this waits; an armed timer must not hold it open.
|
|
2877
|
+
timer.unref?.();
|
|
2878
|
+
child.once('exit', onGone);
|
|
2879
|
+
child.once('close', onGone);
|
|
2880
|
+
});
|
|
2495
2881
|
}
|
|
2496
2882
|
/**
|
|
2497
2883
|
* Globally install an agent's npm package. The package name reaches the
|
|
@@ -2910,9 +3296,11 @@ class TurnStreamController {
|
|
|
2910
3296
|
pendingBackgroundHold;
|
|
2911
3297
|
/**
|
|
2912
3298
|
* The hold's own windows, read once at construction like the ambient pair:
|
|
2913
|
-
* `taskMaxMs` is the worker phase's
|
|
2914
|
-
*
|
|
2915
|
-
*
|
|
3299
|
+
* `taskMaxMs` is the worker phase's silence window, `ceilingMs` the optional
|
|
3300
|
+
* whole-hold absolute bound (0 = none), `graceMs` the parked hold's bounded
|
|
3301
|
+
* wait for a driver step (see {@link BridgeTuning.backgroundTaskMaxMs} /
|
|
3302
|
+
* {@link BridgeTuning.backgroundTaskCeilingMs} /
|
|
3303
|
+
* {@link BridgeTuning.backgroundParkedGraceMs}).
|
|
2916
3304
|
*/
|
|
2917
3305
|
backgroundTuning;
|
|
2918
3306
|
/**
|
|
@@ -3157,11 +3545,13 @@ class TurnStreamController {
|
|
|
3157
3545
|
* - marks the segment background, so EVERY tool call for the rest of the
|
|
3158
3546
|
* hold — the workers', the drain's TaskOutput — stays transcript-only and
|
|
3159
3547
|
* the final message carries text with no card in front of it;
|
|
3160
|
-
* - waits in two phases: the worker phase
|
|
3161
|
-
* {@link BridgeTuning.backgroundTaskMaxMs}
|
|
3162
|
-
*
|
|
3163
|
-
*
|
|
3164
|
-
* windows.
|
|
3548
|
+
* - waits in two phases: the worker phase's clock is a SILENCE window
|
|
3549
|
+
* ({@link BridgeTuning.backgroundTaskMaxMs}) that every dispatch / worker
|
|
3550
|
+
* update pushes out, so a long task that keeps reporting is never cut at a
|
|
3551
|
+
* fixed total runtime. It ends when ALL known dispatches reported, after
|
|
3552
|
+
* which the drain phase uses the ambient windows. An optional absolute
|
|
3553
|
+
* ceiling ({@link BridgeTuning.backgroundTaskCeilingMs}, default none)
|
|
3554
|
+
* bounds a zombie that keeps talking forever.
|
|
3165
3555
|
*/
|
|
3166
3556
|
holdForBackgroundDispatch(stopReason, requestId) {
|
|
3167
3557
|
if (this.settled || this.disposed || this.closedTurn || this.stale)
|
|
@@ -3193,6 +3583,7 @@ class TurnStreamController {
|
|
|
3193
3583
|
this.endTurn();
|
|
3194
3584
|
hold.hardTimer = setTimeout(() => { this.onBackgroundHard(hold); }, this.backgroundTuning.taskMaxMs);
|
|
3195
3585
|
hold.hardTimer.unref?.();
|
|
3586
|
+
this.armBackgroundCeiling(hold);
|
|
3196
3587
|
const seg = this.segment;
|
|
3197
3588
|
if (seg !== undefined && !seg.finished) {
|
|
3198
3589
|
this.adoptBackgroundHold(seg, hold);
|
|
@@ -3291,10 +3682,32 @@ class TurnStreamController {
|
|
|
3291
3682
|
hold.quietTimer = setTimeout(() => { this.onBackgroundQuiet(hold); }, this.ambientTuning.quietMs);
|
|
3292
3683
|
hold.quietTimer.unref?.();
|
|
3293
3684
|
}
|
|
3685
|
+
/**
|
|
3686
|
+
* Evidence from a live dispatch / worker: the worker phase is SILENCE-based,
|
|
3687
|
+
* so each update pushes its deadline out. A long task that keeps reporting is
|
|
3688
|
+
* never cut at a fixed total runtime; only a genuinely quiet worker is.
|
|
3689
|
+
*/
|
|
3690
|
+
noteBackgroundWorkerActivity() {
|
|
3691
|
+
const hold = this.activeBackgroundHold();
|
|
3692
|
+
if (hold === undefined || hold.phase !== 'worker' || hold.retired)
|
|
3693
|
+
return;
|
|
3694
|
+
if (hold.hardTimer !== undefined)
|
|
3695
|
+
clearTimeout(hold.hardTimer);
|
|
3696
|
+
hold.hardTimer = setTimeout(() => { this.onBackgroundHard(hold); }, this.backgroundTuning.taskMaxMs);
|
|
3697
|
+
hold.hardTimer.unref?.();
|
|
3698
|
+
}
|
|
3699
|
+
/** Arm the hold's optional absolute ceiling (0 = unlimited). */
|
|
3700
|
+
armBackgroundCeiling(hold) {
|
|
3701
|
+
if (this.backgroundTuning.ceilingMs <= 0)
|
|
3702
|
+
return;
|
|
3703
|
+
hold.ceilingTimer = setTimeout(() => { this.onBackgroundHard(hold); }, this.backgroundTuning.ceilingMs);
|
|
3704
|
+
hold.ceilingTimer.unref?.();
|
|
3705
|
+
}
|
|
3294
3706
|
/**
|
|
3295
3707
|
* Any session update restarts the drain phase's quiet clock. Only an
|
|
3296
|
-
* ADOPTED hold has
|
|
3297
|
-
* the worker phase
|
|
3708
|
+
* ADOPTED hold has a quiet clock: a parked hold's wait belongs to its grace
|
|
3709
|
+
* timer, and the worker phase is pushed forward only by dispatch / worker
|
|
3710
|
+
* evidence (see `noteBackgroundWorkerActivity`).
|
|
3298
3711
|
*/
|
|
3299
3712
|
noteBackgroundHoldActivity() {
|
|
3300
3713
|
const hold = this.backgroundHold;
|
|
@@ -3308,7 +3721,7 @@ class TurnStreamController {
|
|
|
3308
3721
|
return;
|
|
3309
3722
|
this.closeBackgroundHold(hold, { hard: false });
|
|
3310
3723
|
}
|
|
3311
|
-
/** The hold
|
|
3724
|
+
/** The hold went silent past its window, or outlived the absolute ceiling. */
|
|
3312
3725
|
onBackgroundHard(hold) {
|
|
3313
3726
|
if (hold !== this.backgroundHold && hold !== this.pendingBackgroundHold)
|
|
3314
3727
|
return;
|
|
@@ -3378,10 +3791,13 @@ class TurnStreamController {
|
|
|
3378
3791
|
clearTimeout(hold.quietTimer);
|
|
3379
3792
|
if (hold.hardTimer !== undefined)
|
|
3380
3793
|
clearTimeout(hold.hardTimer);
|
|
3794
|
+
if (hold.ceilingTimer !== undefined)
|
|
3795
|
+
clearTimeout(hold.ceilingTimer);
|
|
3381
3796
|
if (hold.graceTimer !== undefined)
|
|
3382
3797
|
clearTimeout(hold.graceTimer);
|
|
3383
3798
|
hold.quietTimer = undefined;
|
|
3384
3799
|
hold.hardTimer = undefined;
|
|
3800
|
+
hold.ceilingTimer = undefined;
|
|
3385
3801
|
hold.graceTimer = undefined;
|
|
3386
3802
|
}
|
|
3387
3803
|
/**
|
|
@@ -3525,10 +3941,15 @@ class TurnStreamController {
|
|
|
3525
3941
|
const known = this.dispatches.get(toolCallId);
|
|
3526
3942
|
if (known !== undefined) {
|
|
3527
3943
|
// A known dispatch: the id is the whole evidence (see the doc above).
|
|
3944
|
+
this.noteBackgroundWorkerActivity();
|
|
3528
3945
|
if (status !== '')
|
|
3529
3946
|
known.status = status;
|
|
3530
|
-
if (
|
|
3947
|
+
if (!known.catalogReported)
|
|
3948
|
+
this.refreshSubagentDispatchLabel(known, update);
|
|
3949
|
+
if (meta?.isBackground === true) {
|
|
3531
3950
|
known.detached = true;
|
|
3951
|
+
this.reportSubagentDispatch(known);
|
|
3952
|
+
}
|
|
3532
3953
|
if (completedStatus && !known.completed) {
|
|
3533
3954
|
known.completed = true;
|
|
3534
3955
|
this.noteDispatchProgress();
|
|
@@ -3545,8 +3966,14 @@ class TurnStreamController {
|
|
|
3545
3966
|
completed: completedStatus,
|
|
3546
3967
|
detached: meta.isBackground === true,
|
|
3547
3968
|
sawWorkerUpdate: false,
|
|
3969
|
+
catalogLabel: subagentDispatchLabel(update),
|
|
3970
|
+
catalogLabelIdentified: subagentDispatchDeclaresIdentity(update),
|
|
3971
|
+
catalogReported: false,
|
|
3548
3972
|
};
|
|
3549
3973
|
this.dispatches.set(toolCallId, dispatch);
|
|
3974
|
+
this.noteBackgroundWorkerActivity();
|
|
3975
|
+
if (dispatch.detached)
|
|
3976
|
+
this.reportSubagentDispatch(dispatch);
|
|
3550
3977
|
if (!dispatch.completed) {
|
|
3551
3978
|
const held = this.activeBackgroundHold();
|
|
3552
3979
|
// A dispatch registered after the drain phase began means new work:
|
|
@@ -3559,16 +3986,56 @@ class TurnStreamController {
|
|
|
3559
3986
|
if (meta?.parentToolCallId !== undefined) {
|
|
3560
3987
|
const parent = this.dispatches.get(meta.parentToolCallId);
|
|
3561
3988
|
if (parent !== undefined) {
|
|
3989
|
+
this.noteBackgroundWorkerActivity();
|
|
3562
3990
|
parent.detached = true;
|
|
3563
3991
|
parent.sawWorkerUpdate = true;
|
|
3992
|
+
this.reportSubagentDispatch(parent);
|
|
3564
3993
|
}
|
|
3565
3994
|
}
|
|
3566
3995
|
if (meta?.unsolicitedTurn !== undefined) {
|
|
3567
3996
|
const held = this.activeBackgroundHold();
|
|
3568
|
-
if (held !== undefined)
|
|
3997
|
+
if (held !== undefined) {
|
|
3998
|
+
this.noteBackgroundWorkerActivity();
|
|
3569
3999
|
this.startBackgroundDrain(held, meta.unsolicitedTurn.requestId);
|
|
4000
|
+
}
|
|
4001
|
+
}
|
|
4002
|
+
}
|
|
4003
|
+
/**
|
|
4004
|
+
* Hand one DETACHED dispatch to the host's subagent catalog. One fact per
|
|
4005
|
+
* tool call, and only once the wire's structure proves the work runs
|
|
4006
|
+
* off-turn — a synchronous `Agent` / `Task` call is a tool call, not a
|
|
4007
|
+
* catalog child.
|
|
4008
|
+
*/
|
|
4009
|
+
reportSubagentDispatch(dispatch) {
|
|
4010
|
+
if (dispatch.catalogReported)
|
|
4011
|
+
return;
|
|
4012
|
+
// A bare "Agent" row says nothing; wait for the identity frame. A
|
|
4013
|
+
// completed dispatch reports with whatever it has — a row must not be
|
|
4014
|
+
// lost because the agent never declared more than the tool name.
|
|
4015
|
+
if (!dispatch.catalogLabelIdentified && !dispatch.completed)
|
|
4016
|
+
return;
|
|
4017
|
+
if (this.manager.reportSubagentCatalog(this.session, `acp:${dispatch.toolCallId}`, dispatch.catalogLabel)) {
|
|
4018
|
+
dispatch.catalogReported = true;
|
|
3570
4019
|
}
|
|
3571
4020
|
}
|
|
4021
|
+
/**
|
|
4022
|
+
* Take a better label from a later frame of the SAME dispatch call.
|
|
4023
|
+
*
|
|
4024
|
+
* CodeBuddy omits `rawInput` (and often the title) on the dispatch call's
|
|
4025
|
+
* first send, then restates the whole task identity in `_meta` on the result
|
|
4026
|
+
* frame. A frame with no identity and no title is a bare status update and
|
|
4027
|
+
* must not downgrade what is already known.
|
|
4028
|
+
*/
|
|
4029
|
+
refreshSubagentDispatchLabel(dispatch, update) {
|
|
4030
|
+
const identified = subagentDispatchDeclaresIdentity(update);
|
|
4031
|
+
if (!identified && dispatch.catalogLabelIdentified)
|
|
4032
|
+
return;
|
|
4033
|
+
const title = update.title;
|
|
4034
|
+
if (!identified && !(typeof title === 'string' && title !== ''))
|
|
4035
|
+
return;
|
|
4036
|
+
dispatch.catalogLabel = subagentDispatchLabel(update);
|
|
4037
|
+
dispatch.catalogLabelIdentified = identified;
|
|
4038
|
+
}
|
|
3572
4039
|
/**
|
|
3573
4040
|
* A dispatch just reported: once the LAST one has, the drain phase starts.
|
|
3574
4041
|
* Until then the turn stays in its worker phase, with no quiet clock.
|
|
@@ -3576,6 +4043,10 @@ class TurnStreamController {
|
|
|
3576
4043
|
noteDispatchProgress() {
|
|
3577
4044
|
if (!this.allDispatchesCompleted())
|
|
3578
4045
|
return;
|
|
4046
|
+
// Last chance for a row still waiting on identity: completion is a fact
|
|
4047
|
+
// even when the agent never said more than the tool name.
|
|
4048
|
+
for (const dispatch of this.dispatches.values())
|
|
4049
|
+
this.reportSubagentDispatch(dispatch);
|
|
3579
4050
|
const held = this.activeBackgroundHold();
|
|
3580
4051
|
if (held !== undefined)
|
|
3581
4052
|
this.startBackgroundDrain(held);
|
|
@@ -4074,7 +4545,8 @@ class TurnStreamController {
|
|
|
4074
4545
|
// background drain is still working, so the ambient quiet clock restarts.
|
|
4075
4546
|
this.noteAmbientActivity();
|
|
4076
4547
|
// A detached-worker hold's drain phase has the same rule; its worker
|
|
4077
|
-
// phase
|
|
4548
|
+
// phase is pushed out only by dispatch / worker evidence (see
|
|
4549
|
+
// `BridgeTuning.backgroundTaskMaxMs`).
|
|
4078
4550
|
this.noteBackgroundHoldActivity();
|
|
4079
4551
|
const update = typed.update;
|
|
4080
4552
|
// The dispatch / worker / announcement bookkeeping runs BEFORE the cursor
|
|
@@ -4669,14 +5141,17 @@ export class BridgeManager {
|
|
|
4669
5141
|
/**
|
|
4670
5142
|
* The detached-worker hold's windows, read once at controller construction
|
|
4671
5143
|
* for the same reason as {@link ambientTuning}: `taskMaxMs` is the worker
|
|
4672
|
-
* phase's
|
|
4673
|
-
* `
|
|
4674
|
-
*
|
|
4675
|
-
*
|
|
5144
|
+
* phase's SILENCE window, pushed out by every dispatch / worker update (see
|
|
5145
|
+
* {@link BridgeTuning.backgroundTaskMaxMs}), `ceilingMs` its optional
|
|
5146
|
+
* absolute bound (0 = none) and `graceMs` the bounded wait a PARKED hold
|
|
5147
|
+
* gives the host to open its continuation step (see
|
|
5148
|
+
* {@link BridgeTuning.backgroundParkedGraceMs}). The drain phase reuses
|
|
5149
|
+
* {@link ambientTuning}.
|
|
4676
5150
|
*/
|
|
4677
5151
|
get backgroundTuning() {
|
|
4678
5152
|
return {
|
|
4679
5153
|
taskMaxMs: this.tuning.backgroundTaskMaxMs,
|
|
5154
|
+
ceilingMs: this.tuning.backgroundTaskCeilingMs,
|
|
4680
5155
|
graceMs: this.tuning.backgroundParkedGraceMs,
|
|
4681
5156
|
};
|
|
4682
5157
|
}
|
|
@@ -4885,6 +5360,33 @@ export class BridgeManager {
|
|
|
4885
5360
|
setAcpModeResolver(resolver) {
|
|
4886
5361
|
this.acpModeResolver = resolver;
|
|
4887
5362
|
}
|
|
5363
|
+
/**
|
|
5364
|
+
* Optional host integration: report a detached ACP worker as an EXTERNAL
|
|
5365
|
+
* child in the parent chat session's subagent catalog. Display-only — the
|
|
5366
|
+
* host renders it as a read-only "external task" row with no child session,
|
|
5367
|
+
* activity or continuation. An absent reporter is a no-op.
|
|
5368
|
+
*/
|
|
5369
|
+
setSubagentCatalogReporter(reporter) {
|
|
5370
|
+
this.subagentCatalogReporter = reporter;
|
|
5371
|
+
}
|
|
5372
|
+
/**
|
|
5373
|
+
* Hand one detached worker to the host catalog. Returns whether a reporter
|
|
5374
|
+
* handled it; a missing chat id or reporter leaves the dispatch unclaimed so
|
|
5375
|
+
* a later wire update can retry.
|
|
5376
|
+
*/
|
|
5377
|
+
reportSubagentCatalog(session, childId, label) {
|
|
5378
|
+
const dshSessionId = session.dshSessionId;
|
|
5379
|
+
const reporter = this.subagentCatalogReporter;
|
|
5380
|
+
if (dshSessionId === undefined || reporter === undefined)
|
|
5381
|
+
return false;
|
|
5382
|
+
try {
|
|
5383
|
+
reporter(dshSessionId, childId, label);
|
|
5384
|
+
return true;
|
|
5385
|
+
}
|
|
5386
|
+
catch {
|
|
5387
|
+
return false;
|
|
5388
|
+
}
|
|
5389
|
+
}
|
|
4888
5390
|
/**
|
|
4889
5391
|
* Whether ONE DSH chat session runs the ACP agent preset, asked by chat
|
|
4890
5392
|
* session id. The browser's composer surfaces consume this: on hosts whose
|
|
@@ -6287,6 +6789,11 @@ export class BridgeManager {
|
|
|
6287
6789
|
const def = this.defsMap.get(agentId);
|
|
6288
6790
|
if (def === undefined || def.autoDiscoverModels === false || before === undefined)
|
|
6289
6791
|
return false;
|
|
6792
|
+
// A different model-slot identity is a corrected reading, not newly added
|
|
6793
|
+
// models. Diffing across it would expose an entire real catalog the first
|
|
6794
|
+
// time a parser fix replaces a mislabelled slot (Qoder CN).
|
|
6795
|
+
if (before.model !== undefined && next.model !== undefined && before.model.configId !== next.model.configId)
|
|
6796
|
+
return false;
|
|
6290
6797
|
const known = new Set((before.model?.choices ?? []).map(choice => choice.value));
|
|
6291
6798
|
const exposed = new Set(def.exposedModels ?? []);
|
|
6292
6799
|
const added = (next.model?.choices ?? [])
|
|
@@ -6686,6 +7193,12 @@ export class BridgeManager {
|
|
|
6686
7193
|
const wanted = modelChoice !== undefined && modelChoice !== '' ? modelChoice : undefined;
|
|
6687
7194
|
if (wanted === undefined)
|
|
6688
7195
|
return undefined;
|
|
7196
|
+
// `category` is a UX hint, not identity: an agent may publish several
|
|
7197
|
+
// `category: 'model'` options (Qoder CN ships `reasoning_effort` that way)
|
|
7198
|
+
// and no explicit `model` id. In that case the selector is a guess, so a
|
|
7199
|
+
// "not offered" verdict would refuse a perfectly valid pick — fail open.
|
|
7200
|
+
if (!hasUnambiguousModelSelector(session.configOptions))
|
|
7201
|
+
return undefined;
|
|
6689
7202
|
const model = parseCatalogOptions(session.configOptions).model;
|
|
6690
7203
|
if (model === undefined)
|
|
6691
7204
|
return undefined;
|
|
@@ -7113,14 +7626,31 @@ export class BridgeManager {
|
|
|
7113
7626
|
this.disposeTurnStreamsOf(controller => controller.session.agentId === agentId);
|
|
7114
7627
|
await this.bridges.get(agentId)?.stop('agent-stopped');
|
|
7115
7628
|
}
|
|
7629
|
+
/**
|
|
7630
|
+
* Host shutdown: park every conversation, then make sure no agent child
|
|
7631
|
+
* outlives the host. Bridges run CONCURRENTLY against one bounded budget, so a
|
|
7632
|
+
* single wedged agent cannot spend every other bridge's slice too.
|
|
7633
|
+
*
|
|
7634
|
+
* `/acp`'s "stop this agent" gesture is `stopAgent` → `stop`, which is a
|
|
7635
|
+
* different promise: it closes the sessions on purpose. See
|
|
7636
|
+
* `AgentBridge.shutdown` for why host exit must not.
|
|
7637
|
+
*/
|
|
7116
7638
|
async shutdownAll() {
|
|
7117
7639
|
if (this.stallTimer !== undefined) {
|
|
7118
7640
|
clearInterval(this.stallTimer);
|
|
7119
7641
|
this.stallTimer = undefined;
|
|
7120
7642
|
}
|
|
7121
|
-
this.dshSessionMap.clear();
|
|
7122
7643
|
this.disposeTurnStreamsOf(() => true);
|
|
7123
|
-
|
|
7644
|
+
// NOT cleared before the shutdown: `persistAgents` (called from
|
|
7645
|
+
// `parkSessions`) writes the parked records out of `dshSessionMap`, and
|
|
7646
|
+
// that write is what keeps the conversations resumable across the restart.
|
|
7647
|
+
//
|
|
7648
|
+
// `allSettled`, not `all`: one bridge's failure must neither skip the
|
|
7649
|
+
// others nor reject a disposer that never asked for an answer. And NOT
|
|
7650
|
+
// cleared afterwards either: a late `persistAgents` would then write
|
|
7651
|
+
// `chatSessions: []` and erase exactly what the shutdown just saved. These
|
|
7652
|
+
// records are terminal — nothing reads them again after disposal.
|
|
7653
|
+
await Promise.allSettled([...this.bridges.values()].map(b => b.shutdown(SHUTDOWN_BUDGET_MS)));
|
|
7124
7654
|
}
|
|
7125
7655
|
/** Host-side reader of the harness preset roster (`ctx.agentPresets`), for `presetStatus`. */
|
|
7126
7656
|
presetRosterReader;
|
|
@@ -7345,6 +7875,7 @@ export class BridgeManager {
|
|
|
7345
7875
|
}
|
|
7346
7876
|
sessionWorkspaceResolver;
|
|
7347
7877
|
acpModeResolver;
|
|
7878
|
+
subagentCatalogReporter;
|
|
7348
7879
|
attachmentResolver;
|
|
7349
7880
|
/** Cached `chatSessionAcpMode` answers, keyed by DSH chat session id. */
|
|
7350
7881
|
acpModeCache = new Map();
|
|
@@ -7352,8 +7883,8 @@ export class BridgeManager {
|
|
|
7352
7883
|
acpModeInflight = new Map();
|
|
7353
7884
|
/**
|
|
7354
7885
|
* The agent values the composer is showing for a conversation that has not
|
|
7355
|
-
* picked them — the catalog's current model, thought level and extra
|
|
7356
|
-
* values.
|
|
7886
|
+
* picked them — the catalog's current model, thought level, mode and extra
|
|
7887
|
+
* config values.
|
|
7357
7888
|
*
|
|
7358
7889
|
* Captured BEFORE `session/new`: materializing the session absorbs its own
|
|
7359
7890
|
* defaults into the catalog (`absorbCatalog`), which would overwrite exactly
|
|
@@ -7374,6 +7905,15 @@ export class BridgeManager {
|
|
|
7374
7905
|
const effort = this.reasoningMeta(agentId)?.defaultEffort;
|
|
7375
7906
|
if (effort !== undefined)
|
|
7376
7907
|
out.effort = effort;
|
|
7908
|
+
// The mode select's pre-first-message value is the catalog's last-known
|
|
7909
|
+
// mode (the agent's own answer from another session), and `session/new`
|
|
7910
|
+
// starts on whatever the agent itself defaults to. Without capturing it,
|
|
7911
|
+
// a conversation whose select reads "Bypass Permissions" would run its
|
|
7912
|
+
// first turn on "Always Ask" — the same display/run split the model and
|
|
7913
|
+
// thought level are seeded for.
|
|
7914
|
+
const mode = catalog.mode?.currentValue;
|
|
7915
|
+
if (mode !== null && mode !== undefined && mode !== '')
|
|
7916
|
+
out.mode = mode;
|
|
7377
7917
|
const extraConfigs = {};
|
|
7378
7918
|
for (const option of catalog.extras ?? []) {
|
|
7379
7919
|
const value = option.currentValue;
|
|
@@ -7396,14 +7936,15 @@ export class BridgeManager {
|
|
|
7396
7936
|
* first prompt runs.
|
|
7397
7937
|
*
|
|
7398
7938
|
* A conversation with no explicit pick displays the agent catalog's current
|
|
7399
|
-
* model / thought level / extras, while the ACP session `session/new`
|
|
7400
|
-
* created starts on whatever the agent itself defaults to. Without this
|
|
7401
|
-
* user is shown one model and the agent runs another — the defect
|
|
7402
|
-
* as "the displayed model was not used"
|
|
7403
|
-
*
|
|
7404
|
-
*
|
|
7405
|
-
*
|
|
7406
|
-
*
|
|
7939
|
+
* model / thought level / mode / extras, while the ACP session `session/new`
|
|
7940
|
+
* just created starts on whatever the agent itself defaults to. Without this
|
|
7941
|
+
* the user is shown one model and the agent runs another — the defect
|
|
7942
|
+
* reported as "the displayed model was not used", and the reported first
|
|
7943
|
+
* turn that ignored the mode select's value. Values the fresh session does
|
|
7944
|
+
* not offer are skipped (its `configOptions` are the authority, and the
|
|
7945
|
+
* catalog update that rode `session/new` will correct the display), never
|
|
7946
|
+
* turned into a failed first turn. Explicit picks always win: only missing
|
|
7947
|
+
* fields are filled.
|
|
7407
7948
|
*/
|
|
7408
7949
|
seedChatChoiceFromCatalog(dshSessionId, agentId, session, displayed) {
|
|
7409
7950
|
if (displayed === undefined)
|
|
@@ -7429,6 +7970,18 @@ export class BridgeManager {
|
|
|
7429
7970
|
changed = true;
|
|
7430
7971
|
}
|
|
7431
7972
|
}
|
|
7973
|
+
// The mode is the third axis of the same contract. The select's value
|
|
7974
|
+
// before the first message is the catalog's last-known mode; `session/new`
|
|
7975
|
+
// answers with the agent's own default. Seed the displayed one when the
|
|
7976
|
+
// fresh session still offers it — an agent that publishes no `modes`
|
|
7977
|
+
// field is applied best-effort, exactly like `applyChatChoice`.
|
|
7978
|
+
const displayedMode = displayed.mode;
|
|
7979
|
+
const offeredModes = session.modes?.availableModes ?? [];
|
|
7980
|
+
if (current?.mode === undefined && displayedMode !== undefined
|
|
7981
|
+
&& (offeredModes.length === 0 || offeredModes.some(entry => entry.id === displayedMode))) {
|
|
7982
|
+
patch.mode = displayedMode;
|
|
7983
|
+
changed = true;
|
|
7984
|
+
}
|
|
7432
7985
|
if (displayed.extraConfigs !== undefined) {
|
|
7433
7986
|
const extraConfigs = { ...(current?.agentId === agentId ? current.extraConfigs : undefined) };
|
|
7434
7987
|
for (const [configId, value] of Object.entries(displayed.extraConfigs)) {
|
|
@@ -7630,6 +8183,44 @@ export class BridgeManager {
|
|
|
7630
8183
|
});
|
|
7631
8184
|
return { ok: true, reset: true };
|
|
7632
8185
|
}
|
|
8186
|
+
/**
|
|
8187
|
+
* 聊天插话的槽位回收:宿主把插话作为同一会话在飞回合的下一步送达,
|
|
8188
|
+
* 拒绝会让整条 host turn 失败。ACP 不能向运行中的 prompt 注入消息,
|
|
8189
|
+
* 所以按插话语义中止当前回合,等 agent 真正结算后再让调用方重发。
|
|
8190
|
+
*
|
|
8191
|
+
* 只处理 `claimTurn` 的 `busy`(agent 仍持有本会话 prompt);`unsettled`
|
|
8192
|
+
* 说明我们这一侧已经释放过槽位(用户按过停止),保持原样拒绝。
|
|
8193
|
+
*
|
|
8194
|
+
* - 控制器已 settle(响应已到,延后收口还没释放闸门):什么都不取消,只等闸门;
|
|
8195
|
+
* - agent 真在跑:走用户取消同一条管线(session/cancel + 结算权限 + cancel fallback),
|
|
8196
|
+
* 并立即关闭宿主这一侧的 segment,让闸门在 agent 应答/兜底后打开。
|
|
8197
|
+
*/
|
|
8198
|
+
async reclaimChatPrompt(session, controller, signal) {
|
|
8199
|
+
if (session.closed || session.released)
|
|
8200
|
+
return 'ok';
|
|
8201
|
+
if (!controller.hasSettled && session.promptInFlight === true) {
|
|
8202
|
+
await this.bridge(session.agentId).cancel(session);
|
|
8203
|
+
controller.abortActiveSegment();
|
|
8204
|
+
}
|
|
8205
|
+
return this.waitForSettleGate(session, signal);
|
|
8206
|
+
}
|
|
8207
|
+
/** 等 agent 侧 prompt slot 真正释放;步骤被取消时立即返回 aborted。 */
|
|
8208
|
+
waitForSettleGate(session, signal) {
|
|
8209
|
+
const pending = session.settled;
|
|
8210
|
+
if (pending === undefined)
|
|
8211
|
+
return Promise.resolve('ok');
|
|
8212
|
+
if (signal?.aborted)
|
|
8213
|
+
return Promise.resolve('aborted');
|
|
8214
|
+
return new Promise(resolve => {
|
|
8215
|
+
const done = (value) => {
|
|
8216
|
+
signal?.removeEventListener('abort', onAbort);
|
|
8217
|
+
resolve(value);
|
|
8218
|
+
};
|
|
8219
|
+
const onAbort = () => { done('aborted'); };
|
|
8220
|
+
signal?.addEventListener('abort', onAbort, { once: true });
|
|
8221
|
+
void pending.then(() => { done('ok'); });
|
|
8222
|
+
});
|
|
8223
|
+
}
|
|
7633
8224
|
async *streamPrompt(options) {
|
|
7634
8225
|
// The plugin's own conversation↔agent mapping decides who serves the turn
|
|
7635
8226
|
// (see `bindChatAgent`): the driver may arrive here with the previous
|
|
@@ -7837,26 +8428,82 @@ export class BridgeManager {
|
|
|
7837
8428
|
}
|
|
7838
8429
|
// One prompt slot per session, and a turn the user aborted still occupies
|
|
7839
8430
|
// the AGENT's slot until it acknowledges the cancel (see `claimTurn`).
|
|
7840
|
-
|
|
8431
|
+
let claim = await bridge.claimTurn(session);
|
|
7841
8432
|
if (!claim.ok) {
|
|
7842
|
-
//
|
|
7843
|
-
//
|
|
7844
|
-
//
|
|
7845
|
-
//
|
|
7846
|
-
//
|
|
7847
|
-
|
|
7848
|
-
|
|
7849
|
-
|
|
7850
|
-
|
|
7851
|
-
|
|
7852
|
-
|
|
7853
|
-
|
|
7854
|
-
|
|
7855
|
-
|
|
8433
|
+
// A STEER ("插话"): the host delivers the user's mid-turn message as the
|
|
8434
|
+
// NEXT step of the SAME host turn, so the conversation's own ACP prompt
|
|
8435
|
+
// is still on air. Refusing would fail the whole host turn. ACP forbids
|
|
8436
|
+
// injecting into a running prompt, so the steer's real semantic — abort
|
|
8437
|
+
// the current turn, then send this one — is spelled cancel + wait +
|
|
8438
|
+
// re-claim here.
|
|
8439
|
+
//
|
|
8440
|
+
// Only `busy` can be a steer: it means the AGENT still holds OUR prompt
|
|
8441
|
+
// (`promptInFlight`). `unsettled` means our side already released the
|
|
8442
|
+
// slot (the person pressed stop, or an earlier steer cancelled): the
|
|
8443
|
+
// agent has not acknowledged yet, and quietly queueing the next message
|
|
8444
|
+
// behind a turn the user stopped is not this path's business — that
|
|
8445
|
+
// stays the honest `turn_in_flight` refusal below.
|
|
8446
|
+
//
|
|
8447
|
+
// Only the conversation's OWN in-flight turn qualifies (its live stream
|
|
8448
|
+
// controller, on this very session). A `/acp` RPC turn has no chat
|
|
8449
|
+
// controller, and another conversation never reaches this session, so
|
|
8450
|
+
// both keep the plain refusal: nothing may be cancelled behind a
|
|
8451
|
+
// caller's back.
|
|
8452
|
+
const chatController = claim.reason === 'busy' && options.sessionId !== undefined
|
|
8453
|
+
? this.turnStreamFor(options.sessionId)
|
|
8454
|
+
: undefined;
|
|
8455
|
+
if (options.sessionId !== undefined
|
|
8456
|
+
&& chatController !== undefined
|
|
8457
|
+
&& !chatController.stale
|
|
8458
|
+
&& chatController.session === session) {
|
|
8459
|
+
const reclaimed = await this.reclaimChatPrompt(session, chatController, options.signal);
|
|
8460
|
+
if (reclaimed === 'aborted') {
|
|
8461
|
+
// The person pressed stop while the previous turn was winding down:
|
|
8462
|
+
// the steer is theirs to re-send, nothing goes to the agent.
|
|
8463
|
+
yield {
|
|
8464
|
+
type: 'finish',
|
|
8465
|
+
reason: { kind: 'aborted', failure: { message: 'aborted', code: 'ABORTED' } },
|
|
8466
|
+
};
|
|
8467
|
+
return;
|
|
8468
|
+
}
|
|
8469
|
+
// A first turn that produced nothing rolled its provisional lock back
|
|
8470
|
+
// and CLOSED (and forgot) this session: the steer needs a fresh one.
|
|
8471
|
+
if (session.closed || session.released) {
|
|
8472
|
+
try {
|
|
8473
|
+
session = await this.getOrCreateDshSession(options.sessionId, agentId);
|
|
8474
|
+
}
|
|
8475
|
+
catch (err) {
|
|
8476
|
+
yield {
|
|
8477
|
+
type: 'finish',
|
|
8478
|
+
reason: { kind: 'error', failure: { message: describeError(err), code: 'session_init_failed' } },
|
|
8479
|
+
};
|
|
8480
|
+
return;
|
|
8481
|
+
}
|
|
8482
|
+
}
|
|
8483
|
+
// Same bridge: the session may have been re-created, but the
|
|
8484
|
+
// conversation is still served by this agent's process.
|
|
8485
|
+
claim = await bridge.claimTurn(session);
|
|
8486
|
+
}
|
|
8487
|
+
if (!claim.ok) {
|
|
8488
|
+
// Never roll the provisional lock back here: a failed claim means
|
|
8489
|
+
// ANOTHER prompt holds the slot (or the agent has not settled it), and
|
|
8490
|
+
// that turn owns the conversation's lock. Closing the session would
|
|
8491
|
+
// kill a turn that is actively streaming — the refusal already tells
|
|
8492
|
+
// the user to retry.
|
|
8493
|
+
yield {
|
|
8494
|
+
type: 'finish',
|
|
8495
|
+
reason: {
|
|
8496
|
+
kind: 'error',
|
|
8497
|
+
failure: {
|
|
8498
|
+
message: claim.reason === 'busy'
|
|
8499
|
+
? 'acp: this session already has a prompt in flight'
|
|
8500
|
+
: 'acp: the agent is still finishing the previous (cancelled) turn; send again in a moment',
|
|
8501
|
+
code: 'turn_in_flight',
|
|
8502
|
+
},
|
|
7856
8503
|
},
|
|
7857
|
-
}
|
|
7858
|
-
|
|
7859
|
-
|
|
8504
|
+
};
|
|
8505
|
+
return;
|
|
8506
|
+
}
|
|
7860
8507
|
}
|
|
7861
8508
|
// Same-agent model/effort switches ride the SAME ACP session (context
|
|
7862
8509
|
// stays continuous); switching to another agent lands in a separate one
|