dsh-ntr 0.2.1 → 0.3.1
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/README.en.md +4 -4
- package/README.md +4 -4
- package/cordis.patch.yml +12 -0
- package/lib/bridge.js +646 -68
- package/lib/client/panel.js +162 -4
- package/lib/client/store.js +188 -4
- package/lib/client.js +431 -9
- package/lib/client.js.map +1 -1
- package/lib/impl.js +47 -0
- package/lib/index.js +15 -1
- package/lib/types/bridge.d.ts +206 -4
- package/lib/types/client/store.d.ts +87 -1
- package/lib/types/impl.d.ts +26 -1
- package/lib/types/protocol.d.ts +80 -0
- package/lib/types/update.d.ts +186 -0
- package/lib/update.js +696 -0
- package/package.json +1 -1
- package/src/bridge.ts +691 -68
- package/src/client/panel.tsx +288 -5
- package/src/client/store.ts +234 -8
- package/src/impl.ts +66 -1
- package/src/index.ts +16 -1
- package/src/protocol.ts +84 -0
- package/src/update.ts +712 -0
package/src/bridge.ts
CHANGED
|
@@ -69,17 +69,40 @@ import type {
|
|
|
69
69
|
RosterPresetRow,
|
|
70
70
|
} from './impl.ts'
|
|
71
71
|
import type {
|
|
72
|
-
AcpAgentCatalog, AcpAgentDef, AcpApprovalDeadlines, AcpConversationChoice, AcpEvent, AcpMcpServerDef, AcpPresetStatus, AcpTagHalo, AgentCatalogResult, AgentProbeModelsResult, AgentTestResult, AgentView, ChatSessionView, ChatTranscriptView, DetectedAgentInfo, GenerateOptions, LlmAdapterLike,
|
|
72
|
+
AcpAgentCatalog, AcpAgentDef, AcpApprovalDeadlines, AcpConversationChoice, AcpEvent, AcpMcpServerDef, AcpPresetStatus, AcpReleaseCause, AcpTagHalo, AgentCatalogResult, AgentProbeModelsResult, AgentTestResult, AgentView, ChatSessionView, ChatTranscriptView, DetectedAgentInfo, GenerateOptions, LlmAdapterLike,
|
|
73
73
|
LlmModelInfo, LlmModelReasoningInfo, LlmProviderInfo, LlmReasoningEffortInfo, LlmResolvedModelInfo,
|
|
74
|
-
ApprovalDeadlineKey, ApprovalDeadlinePatch, PermissionOutcome, PermissionOptionView, PermissionTimeoutMode, PreparedAdapterCall, SessionView, StreamChunk,
|
|
74
|
+
ApprovalDeadlineKey, ApprovalDeadlinePatch, PermissionOutcome, PermissionOptionView, PermissionTimeoutMode, PluginUpdateState, PreparedAdapterCall, SessionView, StreamChunk,
|
|
75
75
|
} from './protocol.ts'
|
|
76
76
|
import { DEFAULT_TAG_HALO } from './protocol.ts'
|
|
77
|
+
import { UpdateService } from './update.ts'
|
|
78
|
+
|
|
79
|
+
/**
|
|
80
|
+
* Version of this installed package, read from the shipped `package.json` —
|
|
81
|
+
* one level above both `src/` at test time and `lib/` at runtime. The settings
|
|
82
|
+
* badge and the ACP handshake's `clientInfo` both report it; a copied literal
|
|
83
|
+
* would go stale on every release.
|
|
84
|
+
*/
|
|
85
|
+
export const PLUGIN_VERSION: string = (() => {
|
|
86
|
+
try {
|
|
87
|
+
const manifest = JSON.parse(readFileSync(new URL('../package.json', import.meta.url), 'utf-8')) as { version?: unknown }
|
|
88
|
+
return typeof manifest.version === 'string' ? manifest.version : ''
|
|
89
|
+
} catch {
|
|
90
|
+
return ''
|
|
91
|
+
}
|
|
92
|
+
})()
|
|
77
93
|
|
|
78
94
|
const STDERR_TAIL_LINES = 40
|
|
79
95
|
/** Single fs/read_text_file answer cap — agents reading huge files must page via `line`/`limit`. */
|
|
80
96
|
const FS_MAX_READ_BYTES = 2 * 1024 * 1024
|
|
81
97
|
/** Single fs/write_text_file payload cap. */
|
|
82
98
|
const FS_MAX_WRITE_BYTES = 2 * 1024 * 1024
|
|
99
|
+
/**
|
|
100
|
+
* How long a stop waits for the agent to answer `session/close` before killing
|
|
101
|
+
* the child anyway. The capability is optional and the LOCAL teardown is what
|
|
102
|
+
* actually frees the session, so a wedged agent must not be able to hang a
|
|
103
|
+
* stop — and with it the stall reclaim that calls it — forever.
|
|
104
|
+
*/
|
|
105
|
+
const SESSION_CLOSE_TIMEOUT_MS = 5_000
|
|
83
106
|
|
|
84
107
|
/** What the composer showed for an agent before a conversation picked it. */
|
|
85
108
|
interface DisplayedChatDefaults {
|
|
@@ -115,6 +138,20 @@ interface LiveSession {
|
|
|
115
138
|
closed: boolean
|
|
116
139
|
/** The agent process died; the session awaits a `session/load` resume. */
|
|
117
140
|
released: boolean
|
|
141
|
+
/**
|
|
142
|
+
* When the agent last showed ANY sign of life on this session: an update, a
|
|
143
|
+
* permission ask (arrival or answer), or a turn start. The stall watchdog
|
|
144
|
+
* measures silence from here rather than from the prompt, which may
|
|
145
|
+
* legitimately be followed by a long tool run.
|
|
146
|
+
*/
|
|
147
|
+
lastAgentActivityAt: number
|
|
148
|
+
/**
|
|
149
|
+
* Tool calls the agent announced and has not finished, by ACP `toolCallId`.
|
|
150
|
+
* A SET, not a flag: parallel calls finish independently, and one sibling's
|
|
151
|
+
* `completed` must not clear the long tool window while another is still
|
|
152
|
+
* running. Non-empty = the agent is working on its own clock.
|
|
153
|
+
*/
|
|
154
|
+
activeToolCalls: Set<string>
|
|
118
155
|
/**
|
|
119
156
|
* Owning DSH chat session id, when the session was opened through the LLM
|
|
120
157
|
* adapter (`AcpLlmAdapter`). It is what the conversation's permission card
|
|
@@ -168,6 +205,36 @@ interface LiveSession {
|
|
|
168
205
|
* that answer means the message was silently dropped.
|
|
169
206
|
*/
|
|
170
207
|
cancelRequestedAtTurn?: number
|
|
208
|
+
/**
|
|
209
|
+
* The {@link turnSeq} of a turn the stall watchdog force-finalized while the
|
|
210
|
+
* agent still held its `session/prompt`. The agent is presumed wedged, so the
|
|
211
|
+
* NEXT prompt reclaims the process (`BridgeManager.prepareStalledSession`)
|
|
212
|
+
* instead of queueing behind a turn that may never answer. Cleared when the
|
|
213
|
+
* late response lands or when the reclaim replaces the process.
|
|
214
|
+
*/
|
|
215
|
+
orphanPromptTurnSeq?: number
|
|
216
|
+
/**
|
|
217
|
+
* The {@link turnSeq} of a turn a reclaim (`prepareStalledSession`) ended by
|
|
218
|
+
* closing the connection while the agent still held its `session/prompt`.
|
|
219
|
+
* The close rejects that request, and the caller's catch would broadcast a
|
|
220
|
+
* second `turn/end` for a turn already finalized: this marker tells the
|
|
221
|
+
* late `then`/`catch` to drop the answer instead. Set by the reclaim, cleared
|
|
222
|
+
* when that answer lands, at every new turn start, and by a resume.
|
|
223
|
+
*/
|
|
224
|
+
reclaimedTurnSeq?: number
|
|
225
|
+
/**
|
|
226
|
+
* The controller of the turn currently streaming through this session.
|
|
227
|
+
*
|
|
228
|
+
* Chat turns are ALSO indexed by chat id ({@link BridgeManager}'s
|
|
229
|
+
* `turnStreams`) because their continuation steps arrive as fresh
|
|
230
|
+
* `streamPrompt` calls that look the turn up by chat id. An EPHEMERAL turn
|
|
231
|
+
* (`options.sessionId === undefined`) has no chat id to index under, so this
|
|
232
|
+
* attachment is the only way the stall watchdog can find its controller —
|
|
233
|
+
* without it a silent ephemeral turn falls into the `/acp` branch, which
|
|
234
|
+
* leaves the driver's segment queue open instead of finishing it. Cleared by
|
|
235
|
+
* the controller's own `retire` when it still points at it.
|
|
236
|
+
*/
|
|
237
|
+
turnController?: TurnStreamController
|
|
171
238
|
transcript: Transcript
|
|
172
239
|
}
|
|
173
240
|
|
|
@@ -234,6 +301,23 @@ export interface BridgeTuning {
|
|
|
234
301
|
* racing a concurrent `session/prompt` into the agent.
|
|
235
302
|
*/
|
|
236
303
|
turnSettleMs?: number
|
|
304
|
+
/**
|
|
305
|
+
* How long a turn may stay SILENT — no update, no ask, no turn start — before
|
|
306
|
+
* the watchdog force-finalizes it as stalled. The turn is only watched while
|
|
307
|
+
* the agent is the one holding the floor (a segment on air, no pending ask),
|
|
308
|
+
* and the process survives: the next message reclaims it.
|
|
309
|
+
*
|
|
310
|
+
* `0` disables the watchdog.
|
|
311
|
+
*/
|
|
312
|
+
turnStallMs?: number
|
|
313
|
+
/**
|
|
314
|
+
* The longer silence window used while an ACP tool call is in progress: the
|
|
315
|
+
* agent runs the tool on its own clock, so the plain turn window would call
|
|
316
|
+
* a healthy long-running tool stalled. `0` falls back to `turnStallMs`.
|
|
317
|
+
*/
|
|
318
|
+
turnStallToolMs?: number
|
|
319
|
+
/** How often the watchdog scans for silent turns. */
|
|
320
|
+
turnStallTickMs?: number
|
|
237
321
|
/**
|
|
238
322
|
* Default idle-release window for agents without their own
|
|
239
323
|
* `AcpAgentDef.idleTimeoutMs`: a READY process that holds no live session
|
|
@@ -328,6 +412,9 @@ const DEFAULT_TUNING: Required<BridgeTuning> = {
|
|
|
328
412
|
staleCancelRetryDelayMs: 600,
|
|
329
413
|
staleCancelMaxRetries: 2,
|
|
330
414
|
turnSettleMs: 5_000,
|
|
415
|
+
turnStallMs: 600_000,
|
|
416
|
+
turnStallToolMs: 1_800_000,
|
|
417
|
+
turnStallTickMs: 30_000,
|
|
331
418
|
idleTimeoutMs: 1_800_000,
|
|
332
419
|
probeIdleTimeoutMs: 300_000,
|
|
333
420
|
}
|
|
@@ -374,8 +461,19 @@ export class AgentBridge {
|
|
|
374
461
|
private lastActivityAt = 0
|
|
375
462
|
/** Throwaway catalog probes in flight — they hold no live session of their own. */
|
|
376
463
|
private pendingProbes = 0
|
|
377
|
-
/**
|
|
378
|
-
|
|
464
|
+
/**
|
|
465
|
+
* Host→agent session commands in flight (mode / config); not turns. Counted
|
|
466
|
+
* PER SESSION: a command the wedged agent never answers must not pin the
|
|
467
|
+
* bridge against reclaiming THAT session (it is exactly the state the
|
|
468
|
+
* watchdog is recovering from), while another session's command still does.
|
|
469
|
+
*/
|
|
470
|
+
private readonly pendingCommands = new Map<string, number>()
|
|
471
|
+
/**
|
|
472
|
+
* `session/new` requests in flight. The session does not exist in `sessions`
|
|
473
|
+
* yet, so nothing else would tell a teardown (`releaseAll` / idle release)
|
|
474
|
+
* that a caller is waiting on the child it is about to kill.
|
|
475
|
+
*/
|
|
476
|
+
private pendingCreates = 0
|
|
379
477
|
/**
|
|
380
478
|
* Whether a REAL session has ever been registered on this bridge. Probes
|
|
381
479
|
* bypass session bookkeeping, so a bridge that has never hosted anything was
|
|
@@ -539,7 +637,7 @@ export class AgentBridge {
|
|
|
539
637
|
this.touch()
|
|
540
638
|
return
|
|
541
639
|
}
|
|
542
|
-
this.releaseAll(`agent "${this.def.id}" was released after ${String(Math.round(this.currentIdleTimeoutMs / 1000))}s idle
|
|
640
|
+
this.releaseAll(`agent "${this.def.id}" was released after ${String(Math.round(this.currentIdleTimeoutMs / 1000))}s idle`, 'idle')
|
|
543
641
|
}
|
|
544
642
|
|
|
545
643
|
/**
|
|
@@ -603,6 +701,18 @@ export class AgentBridge {
|
|
|
603
701
|
}
|
|
604
702
|
}
|
|
605
703
|
|
|
704
|
+
/**
|
|
705
|
+
* Open a session's settle gate without owning its turn stream: the turn's
|
|
706
|
+
* stream is gone (a resume, a release) but the next prompt still waits on
|
|
707
|
+
* this promise, and nothing is left that could ever resolve it.
|
|
708
|
+
*/
|
|
709
|
+
private openSettleGate(session: LiveSession): void {
|
|
710
|
+
const settle = session.settledResolve
|
|
711
|
+
session.settled = undefined
|
|
712
|
+
session.settledResolve = undefined
|
|
713
|
+
settle?.()
|
|
714
|
+
}
|
|
715
|
+
|
|
606
716
|
view(): AgentView {
|
|
607
717
|
return {
|
|
608
718
|
id: this.def.id,
|
|
@@ -633,7 +743,8 @@ export class AgentBridge {
|
|
|
633
743
|
*/
|
|
634
744
|
private hasActiveWork(): boolean {
|
|
635
745
|
if (this.pendingProbes > 0
|
|
636
|
-
|| this.
|
|
746
|
+
|| this.pendingCreates > 0
|
|
747
|
+
|| this.pendingCommands.size > 0
|
|
637
748
|
|| this.resuming.size > 0
|
|
638
749
|
|| this.permissions.sessionIds().length > 0
|
|
639
750
|
|| this.starting !== undefined) return true
|
|
@@ -643,6 +754,33 @@ export class AgentBridge {
|
|
|
643
754
|
return false
|
|
644
755
|
}
|
|
645
756
|
|
|
757
|
+
/**
|
|
758
|
+
* Active work on this bridge EXCEPT `session`'s own — what a restart-carrying
|
|
759
|
+
* reclaim (the stall watchdog's follow-up) would destroy. Killing this child
|
|
760
|
+
* restarts every ACP session on the connection, so a live conversation that
|
|
761
|
+
* is mid-turn, mid-resume, or waiting on a human pins the process: its
|
|
762
|
+
* context is the one thing a restart cannot give back.
|
|
763
|
+
*
|
|
764
|
+
* `session`'s OWN mode/config command is deliberately not counted: an agent
|
|
765
|
+
* that ignores `session/set_mode` would otherwise make its own stall
|
|
766
|
+
* unrecoverable — the command's counter is exactly the evidence of the wedge.
|
|
767
|
+
*/
|
|
768
|
+
hasOtherActiveWork(session: LiveSession): boolean {
|
|
769
|
+
if (this.pendingProbes > 0
|
|
770
|
+
|| this.pendingCreates > 0
|
|
771
|
+
|| this.resuming.size > 0
|
|
772
|
+
|| this.starting !== undefined) return true
|
|
773
|
+
for (const sessionId of this.pendingCommands.keys()) {
|
|
774
|
+
if (sessionId !== session.sessionId) return true
|
|
775
|
+
}
|
|
776
|
+
if (this.permissions.sessionIds().some(entry => entry.sessionId !== session.sessionId)) return true
|
|
777
|
+
for (const other of this.sessions.values()) {
|
|
778
|
+
if (other === session) continue
|
|
779
|
+
if (other.running || other.settled !== undefined) return true
|
|
780
|
+
}
|
|
781
|
+
return false
|
|
782
|
+
}
|
|
783
|
+
|
|
646
784
|
/**
|
|
647
785
|
* Nothing depends on this child's identity right now: no live session and no
|
|
648
786
|
* work. This is the WORKSPACE MOVE's predicate. It stays stricter than the
|
|
@@ -654,6 +792,19 @@ export class AgentBridge {
|
|
|
654
792
|
return this.sessions.size === 0 && !this.hasActiveWork()
|
|
655
793
|
}
|
|
656
794
|
|
|
795
|
+
/**
|
|
796
|
+
* A turn is in flight (or the agent has not settled its response yet) on any
|
|
797
|
+
* of this bridge's sessions. `settled` only stays non-undefined while the
|
|
798
|
+
* agent's answer is outstanding — the settler clears it on completion — so
|
|
799
|
+
* this is real work, not an idle session.
|
|
800
|
+
*/
|
|
801
|
+
hasActiveTurn(): boolean {
|
|
802
|
+
for (const session of this.sessions.values()) {
|
|
803
|
+
if (session.running || session.settled !== undefined) return true
|
|
804
|
+
}
|
|
805
|
+
return false
|
|
806
|
+
}
|
|
807
|
+
|
|
657
808
|
/**
|
|
658
809
|
* An idle teardown is safe when nothing is WORKING. Idle live sessions do not
|
|
659
810
|
* block it: `releaseAll` parks them as `released` (never closed), and the next
|
|
@@ -684,7 +835,7 @@ export class AgentBridge {
|
|
|
684
835
|
if (this.state === 'ready' && this.conn !== undefined && this.spawnCwd !== spawnCwd && this.isQuiet()) {
|
|
685
836
|
const reason = `agent "${this.def.id}" restarts in ${spawnCwd} (the workspace of the session using it)`
|
|
686
837
|
this.hub.broadcast({ type: 'agent/notice', agentId: this.def.id, level: 'info', text: `acp: ${reason}` })
|
|
687
|
-
this.releaseAll(reason)
|
|
838
|
+
this.releaseAll(reason, 'workspace')
|
|
688
839
|
}
|
|
689
840
|
}
|
|
690
841
|
if (this.state === 'ready' && this.conn !== undefined) return Promise.resolve()
|
|
@@ -788,7 +939,7 @@ export class AgentBridge {
|
|
|
788
939
|
terminal: false,
|
|
789
940
|
session: { configOptions: { boolean: {} } },
|
|
790
941
|
},
|
|
791
|
-
clientInfo: { name: 'dsh-ntr', title: 'DSH NTR', version:
|
|
942
|
+
clientInfo: { name: 'dsh-ntr', title: 'DSH NTR', version: PLUGIN_VERSION },
|
|
792
943
|
}),
|
|
793
944
|
'initialize',
|
|
794
945
|
)
|
|
@@ -837,7 +988,26 @@ export class AgentBridge {
|
|
|
837
988
|
* @param spawnCwd - where the agent PROCESS should run; see `ensureReady`.
|
|
838
989
|
*/
|
|
839
990
|
async createSession(cwd: string | undefined, extraRoots: readonly string[] = [], spawnCwd?: string): Promise<LiveSession> {
|
|
991
|
+
// Readiness (and its possible restart into `spawnCwd`, see `ensureReady`)
|
|
992
|
+
// comes FIRST: `isQuiet` must not count THIS call's in-flight `session/new`
|
|
993
|
+
// against that move, or a fresh conversation could never bring a quiet
|
|
994
|
+
// process into its workspace. The counter is bumped in the same microtask
|
|
995
|
+
// that resumes here, so no idle-release timer can slip into the gap.
|
|
840
996
|
await this.ensureReady(spawnCwd)
|
|
997
|
+
// Counted while the request is in flight: until `sessions.set` runs, the
|
|
998
|
+
// session is invisible to every teardown predicate, and a release that
|
|
999
|
+
// killed the child here would strand this promise on a dead connection.
|
|
1000
|
+
this.pendingCreates += 1
|
|
1001
|
+
try {
|
|
1002
|
+
return await this.openSession(cwd, extraRoots)
|
|
1003
|
+
} finally {
|
|
1004
|
+
this.pendingCreates -= 1
|
|
1005
|
+
this.touch()
|
|
1006
|
+
}
|
|
1007
|
+
}
|
|
1008
|
+
|
|
1009
|
+
/** The body of {@link createSession}; the caller keeps `pendingCreates` for it. */
|
|
1010
|
+
private async openSession(cwd: string | undefined, extraRoots: readonly string[]): Promise<LiveSession> {
|
|
841
1011
|
const conn = this.conn
|
|
842
1012
|
if (conn === undefined) throw new AcpError('agent_unreachable', 'acp: connection is not open')
|
|
843
1013
|
const resolved = resolveSessionCwd(this.def, cwd, p => realpathSync(p), extraRoots)
|
|
@@ -858,6 +1028,8 @@ export class AgentBridge {
|
|
|
858
1028
|
turnSeq: 0,
|
|
859
1029
|
closed: false,
|
|
860
1030
|
released: false,
|
|
1031
|
+
lastAgentActivityAt: Date.now(),
|
|
1032
|
+
activeToolCalls: new Set(),
|
|
861
1033
|
transcript: new Transcript(this.transcriptMax),
|
|
862
1034
|
...(result.modes !== undefined && result.modes !== null
|
|
863
1035
|
? {
|
|
@@ -992,26 +1164,42 @@ export class AgentBridge {
|
|
|
992
1164
|
if (this.cancelFallbackMs <= 0) return
|
|
993
1165
|
const turnGen = session.turnSeq
|
|
994
1166
|
const timer = setTimeout(() => {
|
|
995
|
-
|
|
996
|
-
|
|
997
|
-
|
|
998
|
-
|
|
999
|
-
|
|
1000
|
-
session.running = false
|
|
1001
|
-
const settle = session.settledResolve
|
|
1002
|
-
session.settled = undefined
|
|
1003
|
-
session.settledResolve = undefined
|
|
1004
|
-
settle?.()
|
|
1005
|
-
this.hub.broadcast({
|
|
1006
|
-
type: 'turn/end',
|
|
1007
|
-
sessionId: session.sessionId,
|
|
1008
|
-
turnId: '',
|
|
1009
|
-
error: `agent "${this.def.id}" did not respond to the cancel request within ${String(Math.round(this.cancelFallbackMs / 1000))}s; the turn was force-finalized`,
|
|
1010
|
-
})
|
|
1167
|
+
this.finalizeTurn(
|
|
1168
|
+
session,
|
|
1169
|
+
turnGen,
|
|
1170
|
+
`agent "${this.def.id}" did not respond to the cancel request within ${String(Math.round(this.cancelFallbackMs / 1000))}s; the turn was force-finalized`,
|
|
1171
|
+
)
|
|
1011
1172
|
}, this.cancelFallbackMs)
|
|
1012
1173
|
timer.unref?.()
|
|
1013
1174
|
}
|
|
1014
1175
|
|
|
1176
|
+
/**
|
|
1177
|
+
* Force the local end of one turn whose agent never acknowledged it: clear
|
|
1178
|
+
* `running`, open the settle gate, and broadcast the turn end with `turnId:
|
|
1179
|
+
* ''` (there is no agent response to attribute it to). Shared by the cancel
|
|
1180
|
+
* fallback and the stall watchdog.
|
|
1181
|
+
*
|
|
1182
|
+
* Generation-guarded: a turn that already ended — or that a later turn
|
|
1183
|
+
* superseded — is not this call's to finalize. Returns whether it acted; a
|
|
1184
|
+
* `false` means the caller was late and must leave the session alone.
|
|
1185
|
+
*/
|
|
1186
|
+
finalizeTurn(session: LiveSession, turnGen: number, message: string): boolean {
|
|
1187
|
+
if (session.closed || session.turnSeq !== turnGen) return false
|
|
1188
|
+
// Nothing is in flight anymore (the turn settled on its own): nothing to
|
|
1189
|
+
// finalize. A still-pending settle gate counts as in flight even though
|
|
1190
|
+
// the stream is gone — that gate is what the next prompt waits on.
|
|
1191
|
+
if (!session.running && session.settled === undefined) return false
|
|
1192
|
+
session.running = false
|
|
1193
|
+
this.openSettleGate(session)
|
|
1194
|
+
this.hub.broadcast({
|
|
1195
|
+
type: 'turn/end',
|
|
1196
|
+
sessionId: session.sessionId,
|
|
1197
|
+
turnId: '',
|
|
1198
|
+
error: message,
|
|
1199
|
+
})
|
|
1200
|
+
return true
|
|
1201
|
+
}
|
|
1202
|
+
|
|
1015
1203
|
/**
|
|
1016
1204
|
* Bring a released session back on a fresh agent process via
|
|
1017
1205
|
* `session/load` ("send to recover"). Concurrent callers share one inflight
|
|
@@ -1059,6 +1247,13 @@ export class AgentBridge {
|
|
|
1059
1247
|
session.running = false
|
|
1060
1248
|
session.closed = false
|
|
1061
1249
|
session.released = false
|
|
1250
|
+
// A resume is a fresh agent-side state: nothing from the dead process's
|
|
1251
|
+
// turn survives it, including the settle gate and the stall markers.
|
|
1252
|
+
session.orphanPromptTurnSeq = undefined
|
|
1253
|
+
session.reclaimedTurnSeq = undefined
|
|
1254
|
+
session.activeToolCalls.clear()
|
|
1255
|
+
session.lastAgentActivityAt = Date.now()
|
|
1256
|
+
this.openSettleGate(session)
|
|
1062
1257
|
// Route replay updates into the fresh transcript while the load request
|
|
1063
1258
|
// is in flight, and let the browser drop its blocks/watermark first.
|
|
1064
1259
|
this.acpIndex.set(session.acpSessionId, session)
|
|
@@ -1096,23 +1291,33 @@ export class AgentBridge {
|
|
|
1096
1291
|
this.sessions.delete(session.sessionId)
|
|
1097
1292
|
session.released = true
|
|
1098
1293
|
this.released.set(session.sessionId, session)
|
|
1099
|
-
this.hub.broadcast({ type: 'session/released', sessionId: session.sessionId, session: snapshot(session), reason: 'resume failed' })
|
|
1294
|
+
this.hub.broadcast({ type: 'session/released', sessionId: session.sessionId, session: snapshot(session), reason: 'resume failed', cause: 'resume-failed' })
|
|
1100
1295
|
throw error
|
|
1101
1296
|
}
|
|
1102
1297
|
}
|
|
1103
1298
|
|
|
1104
1299
|
/** Release all live sessions (process died or went idle): keep them resumable, settle permissions, drop the connection. */
|
|
1105
|
-
releaseAll(reason: string): void {
|
|
1300
|
+
releaseAll(reason: string, cause: AcpReleaseCause): void {
|
|
1106
1301
|
this.clearIdleTimer()
|
|
1107
1302
|
for (const session of [...this.sessions.values()]) {
|
|
1303
|
+
// A session already being closed is `closeSession`'s to delete. Marking
|
|
1304
|
+
// it released would park it as resumable, and `doResumeSession` flips
|
|
1305
|
+
// `closed` back to false — an idle release resurrecting a closed session.
|
|
1306
|
+
if (session.closed) continue
|
|
1108
1307
|
if (session.running) {
|
|
1109
1308
|
session.running = false
|
|
1110
1309
|
this.hub.broadcast({ type: 'turn/end', sessionId: session.sessionId, turnId: '', error: reason })
|
|
1111
1310
|
}
|
|
1311
|
+
// The process that owed the turn is gone: leaving its settle gate closed
|
|
1312
|
+
// would make the next prompt wait for an answer that can no longer come.
|
|
1313
|
+
this.openSettleGate(session)
|
|
1314
|
+
// No call from the dead process can still be running: drop the tool set
|
|
1315
|
+
// so a resumed turn starts on the plain window.
|
|
1316
|
+
session.activeToolCalls.clear()
|
|
1112
1317
|
session.released = true
|
|
1113
1318
|
this.settlePermissions(session)
|
|
1114
1319
|
this.released.set(session.sessionId, session)
|
|
1115
|
-
this.hub.broadcast({ type: 'session/released', sessionId: session.sessionId, session: snapshot(session), reason })
|
|
1320
|
+
this.hub.broadcast({ type: 'session/released', sessionId: session.sessionId, session: snapshot(session), reason, cause })
|
|
1116
1321
|
}
|
|
1117
1322
|
this.sessions.clear()
|
|
1118
1323
|
this.acpIndex.clear()
|
|
@@ -1150,13 +1355,15 @@ export class AgentBridge {
|
|
|
1150
1355
|
}
|
|
1151
1356
|
|
|
1152
1357
|
/** Run one host→agent command as work, so an idle teardown cannot cut it. */
|
|
1153
|
-
private async runCommand<T>(request: () => Promise<T>): Promise<T> {
|
|
1154
|
-
this.pendingCommands
|
|
1358
|
+
private async runCommand<T>(session: LiveSession, request: () => Promise<T>): Promise<T> {
|
|
1359
|
+
this.pendingCommands.set(session.sessionId, (this.pendingCommands.get(session.sessionId) ?? 0) + 1)
|
|
1155
1360
|
this.touch()
|
|
1156
1361
|
try {
|
|
1157
1362
|
return await request()
|
|
1158
1363
|
} finally {
|
|
1159
|
-
this.pendingCommands
|
|
1364
|
+
const remaining = (this.pendingCommands.get(session.sessionId) ?? 1) - 1
|
|
1365
|
+
if (remaining > 0) this.pendingCommands.set(session.sessionId, remaining)
|
|
1366
|
+
else this.pendingCommands.delete(session.sessionId)
|
|
1160
1367
|
this.touch()
|
|
1161
1368
|
}
|
|
1162
1369
|
}
|
|
@@ -1164,7 +1371,7 @@ export class AgentBridge {
|
|
|
1164
1371
|
async setMode(session: LiveSession, modeId: string): Promise<void> {
|
|
1165
1372
|
const conn = this.conn
|
|
1166
1373
|
if (conn === undefined) throw new AcpError('agent_unreachable', 'acp: connection is not open')
|
|
1167
|
-
await this.runCommand(() => conn.agent.request(methods.agent.session.setMode, { sessionId: session.acpSessionId, modeId }))
|
|
1374
|
+
await this.runCommand(session, () => conn.agent.request(methods.agent.session.setMode, { sessionId: session.acpSessionId, modeId }))
|
|
1168
1375
|
// Well-behaved agents push `current_mode_update`, but the spec does not
|
|
1169
1376
|
// require it — apply locally so the browser selector never goes stale.
|
|
1170
1377
|
if (session.modes !== undefined) session.modes = { ...session.modes, currentModeId: modeId }
|
|
@@ -1179,7 +1386,7 @@ export class AgentBridge {
|
|
|
1179
1386
|
const params = typeof value === 'boolean'
|
|
1180
1387
|
? { sessionId: session.acpSessionId, configId, value, type: 'boolean' as const }
|
|
1181
1388
|
: { sessionId: session.acpSessionId, configId, value }
|
|
1182
|
-
const result = await this.runCommand(() => conn.agent.request(methods.agent.session.setConfigOption, params)) as { configOptions?: unknown }
|
|
1389
|
+
const result = await this.runCommand(session, () => conn.agent.request(methods.agent.session.setConfigOption, params)) as { configOptions?: unknown }
|
|
1183
1390
|
// The response carries the full authoritative option set; some agents
|
|
1184
1391
|
// (pi-acp observed) never re-notify after a client-driven set. It feeds the
|
|
1185
1392
|
// SESSION snapshot first — `currentValue` in a per-session answer describes
|
|
@@ -1213,8 +1420,23 @@ export class AgentBridge {
|
|
|
1213
1420
|
this.settlePermissions(session)
|
|
1214
1421
|
const conn = this.conn
|
|
1215
1422
|
if (conn !== undefined && this.state === 'ready') {
|
|
1216
|
-
// `session/close` is an optional capability; the local teardown stands
|
|
1217
|
-
|
|
1423
|
+
// `session/close` is an optional capability; the local teardown stands
|
|
1424
|
+
// regardless. Bound the wait: a wedged agent that ignores the request
|
|
1425
|
+
// must not hang the caller (the stall reclaim stops through here), so
|
|
1426
|
+
// the request is raced against a timeout and left behind if it loses.
|
|
1427
|
+
const close = conn.agent.request(methods.agent.session.close, { sessionId: session.acpSessionId }).catch(() => { /* unsupported */ })
|
|
1428
|
+
let timer: ReturnType<typeof setTimeout> | undefined
|
|
1429
|
+
try {
|
|
1430
|
+
await Promise.race([
|
|
1431
|
+
close,
|
|
1432
|
+
new Promise<void>(resolve => {
|
|
1433
|
+
timer = setTimeout(resolve, SESSION_CLOSE_TIMEOUT_MS)
|
|
1434
|
+
timer.unref?.()
|
|
1435
|
+
}),
|
|
1436
|
+
])
|
|
1437
|
+
} finally {
|
|
1438
|
+
if (timer !== undefined) clearTimeout(timer)
|
|
1439
|
+
}
|
|
1218
1440
|
}
|
|
1219
1441
|
this.sessions.delete(session.sessionId)
|
|
1220
1442
|
this.acpIndex.delete(session.acpSessionId)
|
|
@@ -1245,6 +1467,36 @@ export class AgentBridge {
|
|
|
1245
1467
|
this.publishStatus()
|
|
1246
1468
|
}
|
|
1247
1469
|
|
|
1470
|
+
/**
|
|
1471
|
+
* Drop every live session record and kill the child WITHOUT asking the agent
|
|
1472
|
+
* to close them. `stop` sends `session/close` per session, and a wedged agent
|
|
1473
|
+
* that ignores it would hang the caller (the stall reclaim) — the very agent
|
|
1474
|
+
* this teardown exists to get rid of. The local teardown is what frees the
|
|
1475
|
+
* sessions, so it runs unconditionally and immediately.
|
|
1476
|
+
*
|
|
1477
|
+
* Deliberately does NOT clear `released`: those records are constraint 10's
|
|
1478
|
+
* — a released session survives a process death and is resumed by its next
|
|
1479
|
+
* message. This is a stop, not a discard.
|
|
1480
|
+
*/
|
|
1481
|
+
forceReset(reason: 'agent-stopped'): void {
|
|
1482
|
+
this.clearIdleTimer()
|
|
1483
|
+
for (const session of [...this.sessions.values()]) {
|
|
1484
|
+
session.closed = true
|
|
1485
|
+
this.settlePermissions(session)
|
|
1486
|
+
this.openSettleGate(session)
|
|
1487
|
+
session.activeToolCalls.clear()
|
|
1488
|
+
this.sessions.delete(session.sessionId)
|
|
1489
|
+
this.acpIndex.delete(session.acpSessionId)
|
|
1490
|
+
this.hub.broadcast({ type: 'session/closed', sessionId: session.sessionId, reason })
|
|
1491
|
+
}
|
|
1492
|
+
this.conn?.close()
|
|
1493
|
+
this.conn = undefined
|
|
1494
|
+
this.killChild()
|
|
1495
|
+
this.state = 'stopped'
|
|
1496
|
+
this.initResult = undefined
|
|
1497
|
+
this.publishStatus()
|
|
1498
|
+
}
|
|
1499
|
+
|
|
1248
1500
|
private killChild(): void {
|
|
1249
1501
|
const child = this.child
|
|
1250
1502
|
this.child = undefined
|
|
@@ -1298,6 +1550,9 @@ export class AgentBridge {
|
|
|
1298
1550
|
private async handlePermissionRequest(acpSessionId: string, toolCall: unknown, options: PermissionOption[], signal?: AbortSignal): Promise<RequestPermissionResponse> {
|
|
1299
1551
|
const session = this.acpIndex.get(acpSessionId)
|
|
1300
1552
|
if (session === undefined || session.closed) return { outcome: { outcome: 'cancelled' } }
|
|
1553
|
+
// An ask is the agent speaking, not silence: the stall watchdog measures
|
|
1554
|
+
// from here, and the pending ask exempts the session anyway.
|
|
1555
|
+
session.lastAgentActivityAt = Date.now()
|
|
1301
1556
|
// The deadline and the mode are read ONCE, here: an ask that started under
|
|
1302
1557
|
// one pair keeps it, even if the settings page rewrites them mid-flight.
|
|
1303
1558
|
const mode = this.permissionTimeoutMode
|
|
@@ -1331,6 +1586,9 @@ export class AgentBridge {
|
|
|
1331
1586
|
clearTimeout(timer)
|
|
1332
1587
|
detachSignal?.()
|
|
1333
1588
|
this.touch()
|
|
1589
|
+
// The answer releases the agent to continue; the turn's clock restarts
|
|
1590
|
+
// here, not at the ask that may have waited on a human for minutes.
|
|
1591
|
+
session.lastAgentActivityAt = Date.now()
|
|
1334
1592
|
this.hub.broadcast({
|
|
1335
1593
|
type: 'permission/resolved',
|
|
1336
1594
|
requestId,
|
|
@@ -1433,6 +1691,7 @@ export class AgentBridge {
|
|
|
1433
1691
|
options: PermissionOption[],
|
|
1434
1692
|
timeoutMs: number,
|
|
1435
1693
|
): RequestPermissionResponse {
|
|
1694
|
+
session.lastAgentActivityAt = Date.now()
|
|
1436
1695
|
const outcome = this.announceExpiredAsk(session, mode, toolCall, options, timeoutMs)
|
|
1437
1696
|
return outcome.outcome === 'selected'
|
|
1438
1697
|
? { outcome: { outcome: 'selected', optionId: outcome.optionId } }
|
|
@@ -1503,6 +1762,20 @@ export class AgentBridge {
|
|
|
1503
1762
|
private handleSessionUpdate(params: SessionNotification): void {
|
|
1504
1763
|
const session = this.acpIndex.get(params.sessionId)
|
|
1505
1764
|
if (session === undefined) return // update for a session already closed locally
|
|
1765
|
+
// Any update is the agent speaking: restart the stall clock here. A
|
|
1766
|
+
// tool-call status also says whether the agent is inside a long-running
|
|
1767
|
+
// tool, which earns the wider watchdog window.
|
|
1768
|
+
session.lastAgentActivityAt = Date.now()
|
|
1769
|
+
const kind = params.update.sessionUpdate
|
|
1770
|
+
if (kind === 'tool_call' || kind === 'tool_call_update') {
|
|
1771
|
+
const { toolCallId, status } = params.update as { toolCallId: string; status?: string | null }
|
|
1772
|
+
if (status === 'completed' || status === 'failed') session.activeToolCalls.delete(toolCallId)
|
|
1773
|
+
else if (status === 'pending' || status === 'in_progress') session.activeToolCalls.add(toolCallId)
|
|
1774
|
+
// A `tool_call` without a status is a call the agent just announced and
|
|
1775
|
+
// has not finished: count it as in flight. On an UPDATE the status is
|
|
1776
|
+
// optional, and an absent one carries no news either way.
|
|
1777
|
+
else if (kind === 'tool_call' && (status === undefined || status === null)) session.activeToolCalls.add(toolCallId)
|
|
1778
|
+
}
|
|
1506
1779
|
this.applyDerived(session, params.update)
|
|
1507
1780
|
const seq = session.transcript.append(params.update)
|
|
1508
1781
|
this.hub.broadcast({ type: 'session/update', sessionId: session.sessionId, seq, update: params.update })
|
|
@@ -1937,6 +2210,14 @@ export const AGENT_PRESETS: AgentPreset[] = [
|
|
|
1937
2210
|
defaultCommand: 'opencode',
|
|
1938
2211
|
defaultArgs: ['acp'],
|
|
1939
2212
|
},
|
|
2213
|
+
{
|
|
2214
|
+
id: 'cursor',
|
|
2215
|
+
name: 'Cursor CLI',
|
|
2216
|
+
description: 'Cursor CLI — run as `agent acp` over stdio (legacy alias `cursor-agent acp`)',
|
|
2217
|
+
executables: ['agent', 'cursor-agent'],
|
|
2218
|
+
defaultCommand: 'agent',
|
|
2219
|
+
defaultArgs: ['acp'],
|
|
2220
|
+
},
|
|
1940
2221
|
{
|
|
1941
2222
|
id: 'qwen',
|
|
1942
2223
|
name: 'Qwen Code',
|
|
@@ -2422,7 +2703,7 @@ type SegmentFinishReason =
|
|
|
2422
2703
|
type TurnSettle =
|
|
2423
2704
|
| { kind: 'stop'; stopReason: string }
|
|
2424
2705
|
| { kind: 'cancelled' }
|
|
2425
|
-
| { kind: 'error'; message: string }
|
|
2706
|
+
| { kind: 'error'; message: string; code?: string }
|
|
2426
2707
|
|
|
2427
2708
|
/**
|
|
2428
2709
|
* The state of ONE ACP prompt, shared across every driver step of the turn.
|
|
@@ -2472,6 +2753,11 @@ class TurnStreamController {
|
|
|
2472
2753
|
return this.settled
|
|
2473
2754
|
}
|
|
2474
2755
|
|
|
2756
|
+
/** Whether a segment is on air — the agent is expected to speak into a live driver step. */
|
|
2757
|
+
get hasOpenSegment(): boolean {
|
|
2758
|
+
return this.segment !== undefined && !this.segment.finished
|
|
2759
|
+
}
|
|
2760
|
+
|
|
2475
2761
|
/** Generation guard: a controller whose turn has been superseded must not touch the session. */
|
|
2476
2762
|
get stale(): boolean {
|
|
2477
2763
|
return this.disposed || this.session.turnSeq !== this.turnSeqAtStart
|
|
@@ -2486,6 +2772,13 @@ class TurnStreamController {
|
|
|
2486
2772
|
* consuming it, so the next segment replays it again.
|
|
2487
2773
|
*/
|
|
2488
2774
|
startSegment(): SegmentState {
|
|
2775
|
+
// A step opening is the HOST handing the turn back to the agent — this
|
|
2776
|
+
// runs for the first step and for each continuation after the host
|
|
2777
|
+
// executed a card. The watchdog measures the stretch the AGENT holds the
|
|
2778
|
+
// floor, so the bounded gap a host step owns must not count as agent
|
|
2779
|
+
// silence; resetting here cannot mask a later agent silence because the
|
|
2780
|
+
// reset itself is bounded by one driver step.
|
|
2781
|
+
this.session.lastAgentActivityAt = Date.now()
|
|
2489
2782
|
const seg: SegmentState = {
|
|
2490
2783
|
queue: new AsyncQueue<StreamChunk>(),
|
|
2491
2784
|
index: -1,
|
|
@@ -2522,49 +2815,57 @@ class TurnStreamController {
|
|
|
2522
2815
|
settle(outcome: TurnSettle): void {
|
|
2523
2816
|
if (this.settled) return
|
|
2524
2817
|
this.settled = true
|
|
2525
|
-
|
|
2526
|
-
|
|
2818
|
+
// A late answer to a SUPERSEDED turn still has to close this controller's
|
|
2819
|
+
// queue (a continuation step may be parked on it) and its settle gate —
|
|
2820
|
+
// but it must not touch the session the NEWER turn now owns, nor release
|
|
2821
|
+
// process-wide waiters that belong to that turn.
|
|
2822
|
+
const current = this.session.turnSeq === this.turnSeqAtStart
|
|
2823
|
+
const reason: SegmentFinishReason = outcome.kind === 'cancelled'
|
|
2824
|
+
? { kind: 'aborted', failure: { message: 'Turn was cancelled', code: 'CANCELLED' } }
|
|
2527
2825
|
: outcome.kind === 'error'
|
|
2528
|
-
? { kind: 'error'
|
|
2529
|
-
: { kind: 'stop'
|
|
2530
|
-
if (
|
|
2531
|
-
|
|
2532
|
-
|
|
2533
|
-
|
|
2534
|
-
|
|
2535
|
-
|
|
2536
|
-
|
|
2537
|
-
|
|
2538
|
-
|
|
2539
|
-
|
|
2540
|
-
this.session.provisionalLock
|
|
2541
|
-
|
|
2826
|
+
? { kind: 'error', failure: { message: outcome.message, code: outcome.code ?? 'INTERNAL' } }
|
|
2827
|
+
: { kind: 'stop' }
|
|
2828
|
+
if (current) {
|
|
2829
|
+
if (outcome.kind === 'cancelled') {
|
|
2830
|
+
this.hub.broadcast({ type: 'turn/end', sessionId: this.session.sessionId, turnId: this.turnId, stopReason: 'cancelled' })
|
|
2831
|
+
} else if (outcome.kind === 'error') {
|
|
2832
|
+
this.hub.broadcast({ type: 'turn/end', sessionId: this.session.sessionId, turnId: this.turnId, error: outcome.message })
|
|
2833
|
+
} else {
|
|
2834
|
+
this.hub.broadcast({ type: 'turn/end', sessionId: this.session.sessionId, turnId: this.turnId, stopReason: outcome.stopReason })
|
|
2835
|
+
}
|
|
2836
|
+
// The provisional lock settles BEFORE any synthesized warning below: the
|
|
2837
|
+
// warning is our text, not evidence that the first turn delivered anything.
|
|
2838
|
+
if (this.session.provisionalLock === true) {
|
|
2839
|
+
this.session.provisionalLock = false
|
|
2840
|
+
if (!this.sawVisibleBlock) this.rollbackFirstTurn(this.session)
|
|
2841
|
+
}
|
|
2542
2842
|
}
|
|
2543
2843
|
const seg = this.segment
|
|
2544
2844
|
if (seg !== undefined) {
|
|
2545
|
-
if (outcome.kind
|
|
2546
|
-
|
|
2547
|
-
|
|
2548
|
-
|
|
2549
|
-
|
|
2550
|
-
|
|
2551
|
-
// is specifically "protocol says success, the bubble is empty".
|
|
2552
|
-
const line = `\n[dsh-ntr] agent "${this.session.agentId}" ended the turn with no visible output (stop reason: ${outcome.stopReason}). Some agents fold upstream failures — auth, billing, rate limits — into a normal stop; try resending, and check the agent process logs if it repeats.\n`
|
|
2553
|
-
this.emitProse(seg, 'text', line)
|
|
2554
|
-
}
|
|
2555
|
-
this.finishSegment(seg, reason)
|
|
2845
|
+
if (current && outcome.kind === 'stop' && !this.sawVisibleBlock) {
|
|
2846
|
+
// The silent-failure report: a normal stop with nothing the chat
|
|
2847
|
+
// plane renders. Aborted or failing turns need no extra hint; this
|
|
2848
|
+
// is specifically "protocol says success, the bubble is empty".
|
|
2849
|
+
const line = `\n[dsh-ntr] agent "${this.session.agentId}" ended the turn with no visible output (stop reason: ${outcome.stopReason}). Some agents fold upstream failures — auth, billing, rate limits — into a normal stop; try resending, and check the agent process logs if it repeats.\n`
|
|
2850
|
+
this.emitProse(seg, 'text', line)
|
|
2556
2851
|
}
|
|
2852
|
+
this.finishSegment(seg, reason)
|
|
2557
2853
|
}
|
|
2558
2854
|
// When no segment is on air (the turn ended on a card) the FINAL
|
|
2559
2855
|
// continuation step collects the tail: `startSegment` sees `settled` and
|
|
2560
2856
|
// finishes it with the appropriate closing line.
|
|
2561
|
-
|
|
2857
|
+
if (current) {
|
|
2858
|
+
this.session.running = false
|
|
2859
|
+
// The turn is over: no call from it can still earn the long tool window.
|
|
2860
|
+
this.session.activeToolCalls.clear()
|
|
2861
|
+
releaseAllToolOutputWaiters()
|
|
2862
|
+
}
|
|
2562
2863
|
this.endTurn()
|
|
2563
|
-
releaseAllToolOutputWaiters()
|
|
2564
2864
|
// The controller stays indexed only while a continuation step can still
|
|
2565
2865
|
// come for its cards. A cancelled, failed, or card-less turn has none:
|
|
2566
2866
|
// retire it now; the next turn's `rememberTurnStream` would anyway.
|
|
2567
|
-
if (
|
|
2867
|
+
if (!current) this.retire()
|
|
2868
|
+
else if (outcome.kind !== 'stop' || this.seenCalls.size === 0) this.dispose()
|
|
2568
2869
|
}
|
|
2569
2870
|
|
|
2570
2871
|
/** Cancelled by the user: finish the on-air segment and release the turn's slots. */
|
|
@@ -2587,11 +2888,26 @@ class TurnStreamController {
|
|
|
2587
2888
|
|
|
2588
2889
|
/** Stop observing and release the manager's index. Idempotent. */
|
|
2589
2890
|
dispose(): void {
|
|
2891
|
+
if (this.disposed) return
|
|
2892
|
+
this.retire()
|
|
2893
|
+
releaseAllToolOutputWaiters()
|
|
2894
|
+
}
|
|
2895
|
+
|
|
2896
|
+
/**
|
|
2897
|
+
* Stop observing and release the manager's index WITHOUT touching the
|
|
2898
|
+
* process-wide tool-output waiters. A late settle of a superseded turn must
|
|
2899
|
+
* use this: those waiters now belong to the turn on air, and waking them
|
|
2900
|
+
* early would hand the guard a "no output" answer for a tool still running.
|
|
2901
|
+
*/
|
|
2902
|
+
retire(): void {
|
|
2590
2903
|
if (this.disposed) return
|
|
2591
2904
|
this.disposed = true
|
|
2592
2905
|
this.unsubscribe()
|
|
2906
|
+
// The session must not keep pointing at a retired turn: the stall watchdog
|
|
2907
|
+
// reads this slot as "what is streaming now". A newer controller may have
|
|
2908
|
+
// replaced it since, and that one is not ours to clear.
|
|
2909
|
+
if (this.session.turnController === this) this.session.turnController = undefined
|
|
2593
2910
|
this.manager.forgetTurnStream(this)
|
|
2594
|
-
releaseAllToolOutputWaiters()
|
|
2595
2911
|
}
|
|
2596
2912
|
|
|
2597
2913
|
// ---- internals ----
|
|
@@ -2972,6 +3288,100 @@ export class BridgeManager {
|
|
|
2972
3288
|
}
|
|
2973
3289
|
}
|
|
2974
3290
|
|
|
3291
|
+
/**
|
|
3292
|
+
* The stall watchdog's scan loop. Armed lazily at the first turn start and
|
|
3293
|
+
* DROPPED once nothing anywhere is running: a manager that never runs a turn
|
|
3294
|
+
* never holds a timer, and one that goes quiet does not keep the host awake.
|
|
3295
|
+
*/
|
|
3296
|
+
private stallTimer?: ReturnType<typeof setInterval>
|
|
3297
|
+
|
|
3298
|
+
private armStallTimer(): void {
|
|
3299
|
+
// `turnStallMs === 0` disables the WHOLE watchdog — the base window is the
|
|
3300
|
+
// switch. `turnStallToolMs` cannot re-enable it: that number only widens
|
|
3301
|
+
// the window for a turn that already has a tool in flight.
|
|
3302
|
+
if (this.tuning.turnStallMs <= 0 || this.stallTimer !== undefined) return
|
|
3303
|
+
const timer = setInterval(() => { this.reapStalledTurns() }, Math.max(1, this.tuning.turnStallTickMs))
|
|
3304
|
+
timer.unref?.()
|
|
3305
|
+
this.stallTimer = timer
|
|
3306
|
+
}
|
|
3307
|
+
|
|
3308
|
+
/**
|
|
3309
|
+
* Force-finalize turns the agent has gone silent in.
|
|
3310
|
+
*
|
|
3311
|
+
* "Silent" is scoped to the stretch where the AGENT owes the turn a word: a
|
|
3312
|
+
* running session with a segment on air and no pending ask. A turn waiting on
|
|
3313
|
+
* the host (tool cards executing between segments) or on a human (an
|
|
3314
|
+
* unanswered permission) is not stuck and is skipped.
|
|
3315
|
+
*
|
|
3316
|
+
* Firing does NOT kill the process (see `LiveSession.orphanPromptTurnSeq`):
|
|
3317
|
+
* the session, its transcript and its binding survive, and the next message
|
|
3318
|
+
* reclaims the process. The turn's streaming face ends through its own
|
|
3319
|
+
* controller when there is one, so the driver step on air closes with an
|
|
3320
|
+
* error instead of being torn down mid-stream.
|
|
3321
|
+
*/
|
|
3322
|
+
private reapStalledTurns(): void {
|
|
3323
|
+
const now = Date.now()
|
|
3324
|
+
let anyRunning = false
|
|
3325
|
+
for (const bridge of this.bridges.values()) {
|
|
3326
|
+
for (const session of bridge.sessionList) {
|
|
3327
|
+
if (session.running) anyRunning = true
|
|
3328
|
+
if (!session.running || session.closed || session.released) continue
|
|
3329
|
+
// A question is on the table: the agent is waiting on a human, not
|
|
3330
|
+
// stuck, and that wait is the permission deadline's business.
|
|
3331
|
+
if (bridge.permissions.hasPendingFor(session.sessionId)) continue
|
|
3332
|
+
// The controller this turn is streaming through. A chat turn is indexed
|
|
3333
|
+
// by chat id (its continuation steps look it up that way) AND attached
|
|
3334
|
+
// to the session; an EPHEMERAL turn has no chat id and therefore only
|
|
3335
|
+
// the attachment — treating it as an `/acp` turn would finalize the
|
|
3336
|
+
// session state without closing the driver's segment queue.
|
|
3337
|
+
const indexed = session.dshSessionId === undefined ? undefined : this.turnStreams.get(session.dshSessionId)
|
|
3338
|
+
// An index hit only speaks for THIS session: a concurrent first turn
|
|
3339
|
+
// can have mapped another LiveSession under the same chat id.
|
|
3340
|
+
const attached = session.turnController
|
|
3341
|
+
?? (indexed !== undefined && indexed.session === session ? indexed : undefined)
|
|
3342
|
+
// A controller from an EARLIER turn is stale the moment any newer turn
|
|
3343
|
+
// (e.g. an `/acp` prompt on the same chat) advances `turnSeq`: its
|
|
3344
|
+
// segment is already closed, `onAir` would read false, and the
|
|
3345
|
+
// watchdog would skip this turn forever. Treat it as absent.
|
|
3346
|
+
const live = attached !== undefined && !attached.stale ? attached : undefined
|
|
3347
|
+
// An `/acp`-RPC turn has no driver stream, so "on air" reads as true.
|
|
3348
|
+
const onAir = live === undefined ? true : live.hasOpenSegment
|
|
3349
|
+
if (!onAir) continue
|
|
3350
|
+
const toolWindow = this.tuning.turnStallToolMs > 0 ? this.tuning.turnStallToolMs : this.tuning.turnStallMs
|
|
3351
|
+
const threshold = session.activeToolCalls.size > 0 ? toolWindow : this.tuning.turnStallMs
|
|
3352
|
+
if (now - session.lastAgentActivityAt < threshold) continue
|
|
3353
|
+
void bridge.cancel(session)
|
|
3354
|
+
session.orphanPromptTurnSeq = session.turnSeq
|
|
3355
|
+
const turnGen = session.turnSeq
|
|
3356
|
+
const message = `acp: agent "${session.agentId}" sent no activity for ${String(Math.round(threshold / 1000))}s; the stalled turn was force-finalized`
|
|
3357
|
+
// A provisional first turn that showed nothing is about to be rolled
|
|
3358
|
+
// back by `settle` (lock released, session closed), so the notice must
|
|
3359
|
+
// not promise a resume that will not happen.
|
|
3360
|
+
const willRollback = live !== undefined && live.session.provisionalLock === true && !live.sawVisibleBlock
|
|
3361
|
+
if (live !== undefined) live.settle({ kind: 'error', message, code: 'INACTIVITY_TIMEOUT' })
|
|
3362
|
+
else bridge.finalizeTurn(session, turnGen, message)
|
|
3363
|
+
// What the next message can do depends on the capability the agent
|
|
3364
|
+
// advertised at connect time, and on whether anyone else is using it.
|
|
3365
|
+
const capabilities = bridge.view().agentCapabilities as { loadSession?: boolean } | undefined
|
|
3366
|
+
const next = willRollback
|
|
3367
|
+
? 'This was the conversation\'s first turn and it delivered nothing, so it was discarded; the next message starts a new ACP session.'
|
|
3368
|
+
: capabilities?.loadSession === true
|
|
3369
|
+
? 'The next message will restart the agent and resume the session.'
|
|
3370
|
+
: 'The next message will reset the ACP session; this agent does not support session resume.'
|
|
3371
|
+
this.hub.broadcast({
|
|
3372
|
+
type: 'agent/notice',
|
|
3373
|
+
agentId: session.agentId,
|
|
3374
|
+
level: 'error',
|
|
3375
|
+
text: `${message} ${next} If another conversation is still using the agent, the next send may be refused until that turn finishes.`,
|
|
3376
|
+
})
|
|
3377
|
+
}
|
|
3378
|
+
}
|
|
3379
|
+
if (!anyRunning && this.stallTimer !== undefined) {
|
|
3380
|
+
clearInterval(this.stallTimer)
|
|
3381
|
+
this.stallTimer = undefined
|
|
3382
|
+
}
|
|
3383
|
+
}
|
|
3384
|
+
|
|
2975
3385
|
constructor(
|
|
2976
3386
|
defs: readonly AcpAgentDef[],
|
|
2977
3387
|
readonly hub: EventHub,
|
|
@@ -3716,6 +4126,42 @@ export class BridgeManager {
|
|
|
3716
4126
|
return acpPresetStatusFromRoster(rows, ACP_PRESET_ID)
|
|
3717
4127
|
}
|
|
3718
4128
|
|
|
4129
|
+
/** The installed package's version (the settings page's badge). */
|
|
4130
|
+
pluginVersion(): string {
|
|
4131
|
+
return PLUGIN_VERSION
|
|
4132
|
+
}
|
|
4133
|
+
|
|
4134
|
+
/** Wire the built-in updater (host entry only; absent in pure tests). */
|
|
4135
|
+
setUpdateService(service?: UpdateService): void {
|
|
4136
|
+
this.updateService = service
|
|
4137
|
+
}
|
|
4138
|
+
|
|
4139
|
+
/** Registry check for the settings card; `unsupported` when no updater is wired. */
|
|
4140
|
+
async pluginUpdateCheck(): Promise<PluginUpdateState> {
|
|
4141
|
+
return this.updateService?.check() ?? { status: 'unsupported', current: PLUGIN_VERSION }
|
|
4142
|
+
}
|
|
4143
|
+
|
|
4144
|
+
/** Delegate one install to the host plugin manager; never touches files itself. */
|
|
4145
|
+
async pluginUpdateApply(version: string): Promise<PluginUpdateState> {
|
|
4146
|
+
return this.updateService?.apply(version) ?? { status: 'unsupported', current: PLUGIN_VERSION }
|
|
4147
|
+
}
|
|
4148
|
+
|
|
4149
|
+
/**
|
|
4150
|
+
* Whether any ACP work is in flight. An update install may restart the host,
|
|
4151
|
+
* so check/apply describe the host as busy while a turn is running.
|
|
4152
|
+
*
|
|
4153
|
+
* Segmented turn streams count even between steps (the host is still
|
|
4154
|
+
* continuing that turn); `running` / `settled` on a session covers `/acp`
|
|
4155
|
+
* turns and the window between a prompt and the agent's acknowledgement.
|
|
4156
|
+
*/
|
|
4157
|
+
hasActiveTurns(): boolean {
|
|
4158
|
+
if (this.turnStreams.size > 0) return true
|
|
4159
|
+
for (const bridge of this.bridges.values()) {
|
|
4160
|
+
if (bridge.hasActiveTurn()) return true
|
|
4161
|
+
}
|
|
4162
|
+
return false
|
|
4163
|
+
}
|
|
4164
|
+
|
|
3719
4165
|
private getStoragePath(): string | undefined {
|
|
3720
4166
|
if (this.configStoragePath !== undefined) return this.configStoragePath
|
|
3721
4167
|
if (process.env.DSH_ACP_CONFIG_FILE) return process.env.DSH_ACP_CONFIG_FILE
|
|
@@ -4573,7 +5019,7 @@ export class BridgeManager {
|
|
|
4573
5019
|
// here (their `runPrompt` promise also rejects into settle, but this also
|
|
4574
5020
|
// covers a promise the connection teardown leaves pending).
|
|
4575
5021
|
this.disposeTurnStreamsOf(controller => controller.session.agentId === bridge.def.id)
|
|
4576
|
-
bridge.releaseAll(reason)
|
|
5022
|
+
bridge.releaseAll(reason, 'died')
|
|
4577
5023
|
}
|
|
4578
5024
|
|
|
4579
5025
|
agentViews(): AgentView[] {
|
|
@@ -4731,18 +5177,64 @@ export class BridgeManager {
|
|
|
4731
5177
|
}
|
|
4732
5178
|
|
|
4733
5179
|
async prompt(sessionId: string, text: string): Promise<{ turnId: string }> {
|
|
4734
|
-
|
|
5180
|
+
let located = await this.locateOrResume(sessionId)
|
|
5181
|
+
// The watchdog abandoned a previous turn here and the agent may still be
|
|
5182
|
+
// holding its prompt slot on the old process: reclaim BEFORE sending, or
|
|
5183
|
+
// this message queues behind a turn that will never answer.
|
|
5184
|
+
if (located.session.orphanPromptTurnSeq !== undefined) {
|
|
5185
|
+
const prepared = await this.prepareStalledSession(located.session)
|
|
5186
|
+
if (!prepared.ok) throw new AcpError('agent_unresponsive', prepared.message)
|
|
5187
|
+
// Without `session/load` there is nothing to re-locate: the record was
|
|
5188
|
+
// dropped on purpose, and "/acp" has no conversation to recreate it from.
|
|
5189
|
+
// Re-locating anyway would surface a misleading `unknown_session`.
|
|
5190
|
+
if (prepared.reset === true) {
|
|
5191
|
+
throw new AcpError('session_reset', `acp: agent "${located.session.agentId}" does not support session resume; start a new session`)
|
|
5192
|
+
}
|
|
5193
|
+
// A resumable reclaim released the record: re-locate what is live now.
|
|
5194
|
+
located = await this.locateOrResume(sessionId)
|
|
5195
|
+
}
|
|
5196
|
+
const { bridge, session } = located
|
|
4735
5197
|
if (session.running) throw new AcpError('turn_in_flight', 'acp: this session already has a prompt in flight')
|
|
4736
5198
|
session.running = true
|
|
4737
5199
|
session.turnSeq += 1
|
|
5200
|
+
const turnGen = session.turnSeq
|
|
5201
|
+
session.lastAgentActivityAt = Date.now()
|
|
5202
|
+
session.activeToolCalls.clear()
|
|
5203
|
+
// A new turn owns the session from here: any reclaim marker named an
|
|
5204
|
+
// earlier generation and must not suppress THIS turn's settle.
|
|
5205
|
+
session.reclaimedTurnSeq = undefined
|
|
5206
|
+
this.armStallTimer()
|
|
4738
5207
|
const turnId = randomUUID()
|
|
4739
5208
|
this.hub.broadcast({ type: 'turn/start', sessionId, turnId, text })
|
|
4740
5209
|
void bridge.runPrompt(session, text, turnId)
|
|
4741
5210
|
.then(stopReason => {
|
|
5211
|
+
// The reclaim already ended this turn locally; the answer it caused is
|
|
5212
|
+
// that same turn reporting back, not a second turn end for the caller.
|
|
5213
|
+
if (session.reclaimedTurnSeq === turnGen) {
|
|
5214
|
+
session.reclaimedTurnSeq = undefined
|
|
5215
|
+
return
|
|
5216
|
+
}
|
|
5217
|
+
// A superseded turn's late answer must not end the turn now on air.
|
|
5218
|
+
if (session.turnSeq !== turnGen) return
|
|
5219
|
+
this.clearOrphanPrompt(session, turnGen)
|
|
4742
5220
|
session.running = false
|
|
4743
5221
|
this.hub.broadcast({ type: 'turn/end', sessionId, turnId, stopReason })
|
|
4744
5222
|
})
|
|
4745
5223
|
.catch((error: unknown) => {
|
|
5224
|
+
if (session.reclaimedTurnSeq === turnGen) {
|
|
5225
|
+
session.reclaimedTurnSeq = undefined
|
|
5226
|
+
return
|
|
5227
|
+
}
|
|
5228
|
+
if (session.turnSeq !== turnGen) return
|
|
5229
|
+
// The turn was ended by an EXTERNAL close (idle release, agent stop):
|
|
5230
|
+
// that teardown already broadcast the turn's end, and the session is
|
|
5231
|
+
// parked or closed. Reporting the close's rejection again would put a
|
|
5232
|
+
// second terminal frame on a turn the browser already saw finish.
|
|
5233
|
+
if (session.released || session.closed) {
|
|
5234
|
+
this.clearOrphanPrompt(session, turnGen)
|
|
5235
|
+
return
|
|
5236
|
+
}
|
|
5237
|
+
this.clearOrphanPrompt(session, turnGen)
|
|
4746
5238
|
session.running = false
|
|
4747
5239
|
this.hub.broadcast({
|
|
4748
5240
|
type: 'turn/end',
|
|
@@ -4754,6 +5246,11 @@ export class BridgeManager {
|
|
|
4754
5246
|
return { turnId }
|
|
4755
5247
|
}
|
|
4756
5248
|
|
|
5249
|
+
/** The late answer landed (or failed): the turn is no longer an orphan. */
|
|
5250
|
+
private clearOrphanPrompt(session: LiveSession, turnGen: number): void {
|
|
5251
|
+
if (session.orphanPromptTurnSeq === turnGen) session.orphanPromptTurnSeq = undefined
|
|
5252
|
+
}
|
|
5253
|
+
|
|
4757
5254
|
async cancel(sessionId: string): Promise<void> {
|
|
4758
5255
|
const live = this.tryLocate(sessionId)
|
|
4759
5256
|
if (live === undefined) return // released/stale session: nothing to cancel
|
|
@@ -4828,6 +5325,10 @@ export class BridgeManager {
|
|
|
4828
5325
|
}
|
|
4829
5326
|
|
|
4830
5327
|
async shutdownAll(): Promise<void> {
|
|
5328
|
+
if (this.stallTimer !== undefined) {
|
|
5329
|
+
clearInterval(this.stallTimer)
|
|
5330
|
+
this.stallTimer = undefined
|
|
5331
|
+
}
|
|
4831
5332
|
this.dshSessionMap.clear()
|
|
4832
5333
|
this.disposeTurnStreamsOf(() => true)
|
|
4833
5334
|
await Promise.all([...this.bridges.values()].map(b => b.stop('agent-stopped')))
|
|
@@ -4836,6 +5337,12 @@ export class BridgeManager {
|
|
|
4836
5337
|
/** Host-side reader of the harness preset roster (`ctx.agentPresets`), for `presetStatus`. */
|
|
4837
5338
|
private presetRosterReader?: () => Promise<readonly RosterPresetRow[] | undefined>
|
|
4838
5339
|
|
|
5340
|
+
/**
|
|
5341
|
+
* The built-in updater, wired by the host entry. Absent in pure bridge tests —
|
|
5342
|
+
* the update RPCs then answer `unsupported` instead of inventing a source.
|
|
5343
|
+
*/
|
|
5344
|
+
private updateService?: UpdateService
|
|
5345
|
+
|
|
4839
5346
|
/**
|
|
4840
5347
|
* The plugin's own default ACP agent (the panel's ★ gesture): which agent an
|
|
4841
5348
|
* ACP-mode conversation starts on. Host-side state in the agent registry
|
|
@@ -5183,6 +5690,76 @@ export class BridgeManager {
|
|
|
5183
5690
|
return session
|
|
5184
5691
|
}
|
|
5185
5692
|
|
|
5693
|
+
/**
|
|
5694
|
+
* Take the agent process back from a turn the stall watchdog abandoned.
|
|
5695
|
+
*
|
|
5696
|
+
* The wedged agent still holds its `session/prompt`, so the next message
|
|
5697
|
+
* cannot simply be sent: it would queue behind a turn that may never answer.
|
|
5698
|
+
* The process is restarted instead — unless another conversation on the same
|
|
5699
|
+
* connection is doing real work, because killing the child destroys THEIR
|
|
5700
|
+
* context too, and "retry after they finish" is the honest answer.
|
|
5701
|
+
*
|
|
5702
|
+
* A restart that can `session/load` costs the conversation nothing: the
|
|
5703
|
+
* session is parked `released` and the normal acquisition path resumes it, so
|
|
5704
|
+
* chat keeps its lock / `applyChatChoice` semantics and `/acp` keeps
|
|
5705
|
+
* `locateOrResume`. An agent without resume support is reset instead, and the
|
|
5706
|
+
* user is told the context is gone.
|
|
5707
|
+
*/
|
|
5708
|
+
private async prepareStalledSession(session: LiveSession): Promise<{ ok: true; reset?: boolean } | { ok: false; message: string }> {
|
|
5709
|
+
// A session whose `session/close` is still in flight must not be touched:
|
|
5710
|
+
// releasing it would park it as resumable and `doResumeSession` flips
|
|
5711
|
+
// `closed` back to false, resurrecting a closed session. Its owner deletes
|
|
5712
|
+
// the record; the caller's own acquisition creates a new session (chat) or
|
|
5713
|
+
// rejects `session_closed` (`/acp`).
|
|
5714
|
+
if (session.closed) return { ok: true }
|
|
5715
|
+
if (session.released) return { ok: true } // the acquisition resumes it
|
|
5716
|
+
const bridge = this.bridge(session.agentId)
|
|
5717
|
+
const blocked = `acp: agent "${session.agentId}" is unresponsive, but other conversations are still using it; retry after they finish`
|
|
5718
|
+
if (bridge.hasOtherActiveWork(session)) return { ok: false, message: blocked }
|
|
5719
|
+
// A sibling conversation the agent already settled is not "work" by
|
|
5720
|
+
// `hasOtherActiveWork` — but its HOST may still owe the turn a
|
|
5721
|
+
// continuation step (a card it is executing right now), and restarting the
|
|
5722
|
+
// process would park and resume that live turn. Its indexed controller is
|
|
5723
|
+
// the evidence. Scoped to THIS agent: the restart kills this agent's child
|
|
5724
|
+
// only, so another agent's pending step is not this bridge's business.
|
|
5725
|
+
// A closed or released session has no host behind it (a leftover
|
|
5726
|
+
// controller), so it does not pin the child either.
|
|
5727
|
+
for (const controller of this.turnStreams.values()) {
|
|
5728
|
+
if (controller.session === session) continue
|
|
5729
|
+
if (controller.session.agentId !== session.agentId) continue
|
|
5730
|
+
if (controller.session.closed || controller.session.released) continue
|
|
5731
|
+
if (!controller.stale) return { ok: false, message: blocked }
|
|
5732
|
+
}
|
|
5733
|
+
const capabilities = bridge.view().agentCapabilities as { loadSession?: boolean } | undefined
|
|
5734
|
+
if (capabilities?.loadSession === true) {
|
|
5735
|
+
// Whatever teardown runs, the agent's in-flight `session/prompt` is about
|
|
5736
|
+
// to be rejected by the closing connection. Mark the generation so that
|
|
5737
|
+
// rejection does not broadcast a second `turn/end` for a turn already
|
|
5738
|
+
// finalized by the watchdog (`LiveSession.reclaimedTurnSeq`).
|
|
5739
|
+
session.reclaimedTurnSeq = session.turnSeq
|
|
5740
|
+
bridge.releaseAll('agent did not acknowledge the stalled turn', 'stalled')
|
|
5741
|
+
session.orphanPromptTurnSeq = undefined
|
|
5742
|
+
return { ok: true }
|
|
5743
|
+
}
|
|
5744
|
+
// No resume: the restart IS a reset, so only this session's context is at
|
|
5745
|
+
// stake — and any other live session pins the process even while idle.
|
|
5746
|
+
if (bridge.sessionList.some(other => other !== session)) return { ok: false, message: blocked }
|
|
5747
|
+
const agentId = session.agentId
|
|
5748
|
+
// `stop` would ask a wedged agent to close its sessions and wait for an
|
|
5749
|
+
// answer that never comes; `forceReset` is the local half only.
|
|
5750
|
+
session.reclaimedTurnSeq = session.turnSeq
|
|
5751
|
+
bridge.forceReset('agent-stopped')
|
|
5752
|
+
this.forgetDshSession(session.sessionId)
|
|
5753
|
+
session.orphanPromptTurnSeq = undefined
|
|
5754
|
+
this.hub.broadcast({
|
|
5755
|
+
type: 'agent/notice',
|
|
5756
|
+
agentId,
|
|
5757
|
+
level: 'info',
|
|
5758
|
+
text: `acp: agent "${agentId}" does not support session resume; the ACP session was reset (context could not be restored)`,
|
|
5759
|
+
})
|
|
5760
|
+
return { ok: true, reset: true }
|
|
5761
|
+
}
|
|
5762
|
+
|
|
5186
5763
|
async *streamPrompt(options: GenerateOptions): AsyncIterable<StreamChunk> {
|
|
5187
5764
|
// The plugin's own conversation↔agent mapping decides who serves the turn
|
|
5188
5765
|
// (see `bindChatAgent`): the driver may arrive here with the previous
|
|
@@ -5293,6 +5870,20 @@ export class BridgeManager {
|
|
|
5293
5870
|
}
|
|
5294
5871
|
|
|
5295
5872
|
let session: LiveSession
|
|
5873
|
+
// Same key `getOrCreateDshSession` looks up: a session the watchdog left
|
|
5874
|
+
// orphaned must be reclaimed BEFORE the acquisition, so this message cannot
|
|
5875
|
+
// queue behind a prompt the agent will never answer.
|
|
5876
|
+
const stalled = this.dshSessionMap.get(`${options.sessionId ?? 'ephemeral'}:${agentId}`)
|
|
5877
|
+
if (stalled !== undefined && stalled.orphanPromptTurnSeq !== undefined) {
|
|
5878
|
+
const prepared = await this.prepareStalledSession(stalled)
|
|
5879
|
+
if (!prepared.ok) {
|
|
5880
|
+
yield {
|
|
5881
|
+
type: 'finish',
|
|
5882
|
+
reason: { kind: 'error', failure: { message: prepared.message, code: 'agent_unresponsive' } },
|
|
5883
|
+
}
|
|
5884
|
+
return
|
|
5885
|
+
}
|
|
5886
|
+
}
|
|
5296
5887
|
try {
|
|
5297
5888
|
session = await this.getOrCreateDshSession(options.sessionId, agentId)
|
|
5298
5889
|
} catch (err) {
|
|
@@ -5424,6 +6015,13 @@ export class BridgeManager {
|
|
|
5424
6015
|
|
|
5425
6016
|
session.running = true
|
|
5426
6017
|
session.turnSeq += 1
|
|
6018
|
+
const turnGen = session.turnSeq
|
|
6019
|
+
session.lastAgentActivityAt = Date.now()
|
|
6020
|
+
session.activeToolCalls.clear()
|
|
6021
|
+
// A new turn owns the session from here: any reclaim marker named an
|
|
6022
|
+
// earlier generation and must not suppress THIS turn's settle.
|
|
6023
|
+
session.reclaimedTurnSeq = undefined
|
|
6024
|
+
this.armStallTimer()
|
|
5427
6025
|
const turnId = randomUUID()
|
|
5428
6026
|
this.hub.broadcast({ type: 'turn/start', sessionId: session.sessionId, turnId, text: promptText })
|
|
5429
6027
|
|
|
@@ -5442,6 +6040,14 @@ export class BridgeManager {
|
|
|
5442
6040
|
victim => this.rollbackFirstTurn(victim),
|
|
5443
6041
|
)
|
|
5444
6042
|
if (session.dshSessionId !== undefined) this.rememberTurnStream(session.dshSessionId, controller)
|
|
6043
|
+
// The session itself carries the controller too: an EPHEMERAL turn
|
|
6044
|
+
// (`options.sessionId === undefined`) has no chat id to index under, and
|
|
6045
|
+
// the stall watchdog needs SOME route to the controller to close the
|
|
6046
|
+
// driver's segment queue. For chat turns this also disambiguates a chat id
|
|
6047
|
+
// two LiveSessions briefly share (a concurrent first turn). Assigned AFTER
|
|
6048
|
+
// `rememberTurnStream` so the controller it just retired cannot clear this
|
|
6049
|
+
// attachment.
|
|
6050
|
+
session.turnController = controller
|
|
5445
6051
|
const segment = controller.startSegment()
|
|
5446
6052
|
const onAbort = (): void => {
|
|
5447
6053
|
if (controller.stale) return
|
|
@@ -5460,13 +6066,30 @@ export class BridgeManager {
|
|
|
5460
6066
|
// or a continuation one.
|
|
5461
6067
|
bridge.runPrompt(session, promptText, turnId)
|
|
5462
6068
|
.then(stopReason => {
|
|
6069
|
+
// The reclaim already ended this turn locally, and the close it
|
|
6070
|
+
// performed rejected this very request: the late answer is that same
|
|
6071
|
+
// turn, not a second one to broadcast.
|
|
6072
|
+
if (session.reclaimedTurnSeq === turnGen) {
|
|
6073
|
+
session.reclaimedTurnSeq = undefined
|
|
6074
|
+
return
|
|
6075
|
+
}
|
|
5463
6076
|
controller.settle(stopReason === 'cancelled'
|
|
5464
6077
|
? { kind: 'cancelled' }
|
|
5465
6078
|
: { kind: 'stop', stopReason })
|
|
5466
6079
|
})
|
|
5467
6080
|
.catch((err: unknown) => {
|
|
6081
|
+
if (session.reclaimedTurnSeq === turnGen) {
|
|
6082
|
+
session.reclaimedTurnSeq = undefined
|
|
6083
|
+
return
|
|
6084
|
+
}
|
|
5468
6085
|
controller.settle({ kind: 'error', message: describeError(err) })
|
|
5469
6086
|
})
|
|
6087
|
+
// The agent's own answer closed the turn: whatever the watchdog marked
|
|
6088
|
+
// orphaned has just reported back and no longer needs a reclaim.
|
|
6089
|
+
.finally(() => {
|
|
6090
|
+
if (session.reclaimedTurnSeq === turnGen) session.reclaimedTurnSeq = undefined
|
|
6091
|
+
this.clearOrphanPrompt(session, turnGen)
|
|
6092
|
+
})
|
|
5470
6093
|
}
|
|
5471
6094
|
|
|
5472
6095
|
try {
|