Skip to content

Commit 119ce66

Browse files
authored
Merge pull request #494 from dmoliveira/improve/runtime-hardening-20260315
Improve stale session forensics and question recovery
2 parents 115b1f4 + 89f5165 commit 119ce66

File tree

6 files changed

+511
-4
lines changed

6 files changed

+511
-4
lines changed

.gitignore

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -3,6 +3,7 @@ __pycache__/
33
.DS_Store
44
.beads/
55
plugin/gateway-core/node_modules/
6+
plugin/gateway-core/*.tgz
67
plugin/gateway-core@latest
78
runtime/
89
bg/

plugin/gateway-core/dist/hooks/session-recovery/index.js

Lines changed: 90 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -1,6 +1,23 @@
11
import { writeGatewayEventAudit } from "../../audit/event-audit.js";
22
import { injectHookMessage, inspectHookMessageSafety } from "../hook-message-injector/index.js";
33
import { readCombinedToolAfterOutputText } from "../shared/tool-after-output.js";
4+
const STALE_QUESTION_PREVENTION_MS = 60_000;
5+
function nowMs() {
6+
return Date.now();
7+
}
8+
function resolveToolSessionId(payload) {
9+
return String(payload.input?.sessionID ?? payload.input?.sessionId ?? "").trim();
10+
}
11+
function resolveMessageSessionId(payload) {
12+
return String(payload.properties?.info?.sessionID ?? payload.properties?.info?.sessionId ?? "").trim();
13+
}
14+
function normalizeToolName(raw) {
15+
return String(raw ?? "").trim().toLowerCase();
16+
}
17+
function isQuestionTool(raw) {
18+
const tool = normalizeToolName(raw);
19+
return tool === "question" || tool === "askuserquestion";
20+
}
421
// Returns true when event error resembles recoverable transient session failure.
522
function isRecoverableError(error) {
623
const candidate = error && typeof error === "object" && "message" in error
@@ -161,6 +178,7 @@ async function injectRecoveryMessage(args) {
161178
// Creates session recovery hook that attempts one auto-resume per active error session.
162179
export function createSessionRecoveryHook(options) {
163180
const recoveringSessions = new Set();
181+
const pendingQuestions = new Map();
164182
return {
165183
id: "session-recovery",
166184
priority: 280,
@@ -173,7 +191,61 @@ export function createSessionRecoveryHook(options) {
173191
const sessionId = resolveSessionId(eventPayload);
174192
if (sessionId) {
175193
recoveringSessions.delete(sessionId);
194+
pendingQuestions.delete(sessionId);
195+
}
196+
return;
197+
}
198+
if (type === "message.updated") {
199+
const messagePayload = (payload ?? {});
200+
const sessionId = resolveMessageSessionId(messagePayload);
201+
if (!sessionId) {
202+
return;
203+
}
204+
const info = messagePayload.properties?.info;
205+
const role = String(info?.role ?? "").trim().toLowerCase();
206+
if (role === "user") {
207+
pendingQuestions.delete(sessionId);
208+
return;
209+
}
210+
if (role !== "assistant") {
211+
return;
212+
}
213+
const completed = Number.isFinite(Number(info?.time?.completed ?? Number.NaN));
214+
const errored = info?.error !== undefined && info?.error !== null;
215+
if (completed || errored) {
216+
pendingQuestions.delete(sessionId);
217+
return;
218+
}
219+
const existing = pendingQuestions.get(sessionId);
220+
if (!existing) {
221+
return;
222+
}
223+
pendingQuestions.set(sessionId, {
224+
...existing,
225+
lastUpdatedAt: nowMs(),
226+
});
227+
return;
228+
}
229+
if (type === "tool.execute.before") {
230+
const toolPayload = (payload ?? {});
231+
const sessionId = resolveToolSessionId(toolPayload);
232+
if (!sessionId || !isQuestionTool(toolPayload.input?.tool)) {
233+
return;
234+
}
235+
pendingQuestions.set(sessionId, {
236+
tool: normalizeToolName(toolPayload.input?.tool),
237+
startedAt: nowMs(),
238+
lastUpdatedAt: nowMs(),
239+
});
240+
return;
241+
}
242+
if (type === "tool.execute.before.error") {
243+
const toolPayload = (payload ?? {});
244+
const sessionId = resolveToolSessionId(toolPayload);
245+
if (!sessionId || !isQuestionTool(toolPayload.input?.tool)) {
246+
return;
176247
}
248+
pendingQuestions.delete(sessionId);
177249
return;
178250
}
179251
if (type === "session.idle") {
@@ -188,6 +260,19 @@ export function createSessionRecoveryHook(options) {
188260
if (!client || typeof client.messages !== "function") {
189261
return;
190262
}
263+
const pendingQuestion = pendingQuestions.get(sessionId);
264+
if (pendingQuestion) {
265+
const ageMs = Math.max(0, nowMs() - Math.max(pendingQuestion.startedAt, pendingQuestion.lastUpdatedAt));
266+
if (ageMs < STALE_QUESTION_PREVENTION_MS) {
267+
writeGatewayEventAudit(directory, {
268+
hook: "session-recovery",
269+
stage: "skip",
270+
reason_code: "stale_question_tool_prevention_not_stale",
271+
session_id: sessionId,
272+
});
273+
return;
274+
}
275+
}
191276
try {
192277
const response = await client.messages({
193278
path: { id: sessionId },
@@ -233,6 +318,7 @@ export function createSessionRecoveryHook(options) {
233318
}
234319
finally {
235320
recoveringSessions.delete(sessionId);
321+
pendingQuestions.delete(sessionId);
236322
}
237323
}
238324
catch {
@@ -248,6 +334,10 @@ export function createSessionRecoveryHook(options) {
248334
if (type === "tool.execute.after") {
249335
const toolPayload = (payload ?? {});
250336
const sessionId = String(toolPayload.input?.sessionID ?? toolPayload.input?.sessionId ?? "").trim();
337+
if (sessionId && isQuestionTool(toolPayload.input?.tool)) {
338+
pendingQuestions.delete(sessionId);
339+
return;
340+
}
251341
const directory = typeof toolPayload.directory === "string" && toolPayload.directory.trim()
252342
? toolPayload.directory
253343
: options.directory;

plugin/gateway-core/src/hooks/session-recovery/index.ts

Lines changed: 128 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -71,6 +71,61 @@ interface ToolAfterPayload {
7171
}
7272
}
7373

74+
interface ToolBeforePayload {
75+
directory?: string
76+
input?: {
77+
tool?: string
78+
sessionID?: string
79+
sessionId?: string
80+
}
81+
}
82+
83+
interface MessageUpdatedPayload {
84+
directory?: string
85+
properties?: {
86+
info?: {
87+
role?: string
88+
sessionID?: string
89+
sessionId?: string
90+
error?: unknown
91+
time?: { completed?: number }
92+
}
93+
}
94+
}
95+
96+
interface PendingQuestionState {
97+
tool: string
98+
startedAt: number
99+
lastUpdatedAt: number
100+
}
101+
102+
const STALE_QUESTION_PREVENTION_MS = 60_000
103+
104+
function nowMs(): number {
105+
return Date.now()
106+
}
107+
108+
function resolveToolSessionId(payload: {
109+
input?: { sessionID?: string; sessionId?: string }
110+
}): string {
111+
return String(payload.input?.sessionID ?? payload.input?.sessionId ?? "").trim()
112+
}
113+
114+
function resolveMessageSessionId(payload: MessageUpdatedPayload): string {
115+
return String(
116+
payload.properties?.info?.sessionID ?? payload.properties?.info?.sessionId ?? "",
117+
).trim()
118+
}
119+
120+
function normalizeToolName(raw: unknown): string {
121+
return String(raw ?? "").trim().toLowerCase()
122+
}
123+
124+
function isQuestionTool(raw: unknown): boolean {
125+
const tool = normalizeToolName(raw)
126+
return tool === "question" || tool === "askuserquestion"
127+
}
128+
74129
// Returns true when event error resembles recoverable transient session failure.
75130
function isRecoverableError(error: unknown): boolean {
76131
const candidate =
@@ -287,6 +342,7 @@ export function createSessionRecoveryHook(options: {
287342
autoResume: boolean
288343
}): GatewayHook {
289344
const recoveringSessions = new Set<string>()
345+
const pendingQuestions = new Map<string, PendingQuestionState>()
290346
return {
291347
id: "session-recovery",
292348
priority: 280,
@@ -299,9 +355,63 @@ export function createSessionRecoveryHook(options: {
299355
const sessionId = resolveSessionId(eventPayload)
300356
if (sessionId) {
301357
recoveringSessions.delete(sessionId)
358+
pendingQuestions.delete(sessionId)
302359
}
303360
return
304361
}
362+
if (type === "message.updated") {
363+
const messagePayload = (payload ?? {}) as MessageUpdatedPayload
364+
const sessionId = resolveMessageSessionId(messagePayload)
365+
if (!sessionId) {
366+
return
367+
}
368+
const info = messagePayload.properties?.info
369+
const role = String(info?.role ?? "").trim().toLowerCase()
370+
if (role === "user") {
371+
pendingQuestions.delete(sessionId)
372+
return
373+
}
374+
if (role !== "assistant") {
375+
return
376+
}
377+
const completed = Number.isFinite(Number(info?.time?.completed ?? Number.NaN))
378+
const errored = info?.error !== undefined && info?.error !== null
379+
if (completed || errored) {
380+
pendingQuestions.delete(sessionId)
381+
return
382+
}
383+
const existing = pendingQuestions.get(sessionId)
384+
if (!existing) {
385+
return
386+
}
387+
pendingQuestions.set(sessionId, {
388+
...existing,
389+
lastUpdatedAt: nowMs(),
390+
})
391+
return
392+
}
393+
if (type === "tool.execute.before") {
394+
const toolPayload = (payload ?? {}) as ToolBeforePayload
395+
const sessionId = resolveToolSessionId(toolPayload)
396+
if (!sessionId || !isQuestionTool(toolPayload.input?.tool)) {
397+
return
398+
}
399+
pendingQuestions.set(sessionId, {
400+
tool: normalizeToolName(toolPayload.input?.tool),
401+
startedAt: nowMs(),
402+
lastUpdatedAt: nowMs(),
403+
})
404+
return
405+
}
406+
if (type === "tool.execute.before.error") {
407+
const toolPayload = (payload ?? {}) as ToolBeforePayload
408+
const sessionId = resolveToolSessionId(toolPayload)
409+
if (!sessionId || !isQuestionTool(toolPayload.input?.tool)) {
410+
return
411+
}
412+
pendingQuestions.delete(sessionId)
413+
return
414+
}
305415
if (type === "session.idle") {
306416
const directory =
307417
typeof eventPayload.directory === "string" && eventPayload.directory.trim()
@@ -315,6 +425,19 @@ export function createSessionRecoveryHook(options: {
315425
if (!client || typeof client.messages !== "function") {
316426
return
317427
}
428+
const pendingQuestion = pendingQuestions.get(sessionId)
429+
if (pendingQuestion) {
430+
const ageMs = Math.max(0, nowMs() - Math.max(pendingQuestion.startedAt, pendingQuestion.lastUpdatedAt))
431+
if (ageMs < STALE_QUESTION_PREVENTION_MS) {
432+
writeGatewayEventAudit(directory, {
433+
hook: "session-recovery",
434+
stage: "skip",
435+
reason_code: "stale_question_tool_prevention_not_stale",
436+
session_id: sessionId,
437+
})
438+
return
439+
}
440+
}
318441
try {
319442
const response = await client.messages({
320443
path: { id: sessionId },
@@ -359,6 +482,7 @@ export function createSessionRecoveryHook(options: {
359482
})
360483
} finally {
361484
recoveringSessions.delete(sessionId)
485+
pendingQuestions.delete(sessionId)
362486
}
363487
} catch {
364488
writeGatewayEventAudit(directory, {
@@ -373,6 +497,10 @@ export function createSessionRecoveryHook(options: {
373497
if (type === "tool.execute.after") {
374498
const toolPayload = (payload ?? {}) as ToolAfterPayload
375499
const sessionId = String(toolPayload.input?.sessionID ?? toolPayload.input?.sessionId ?? "").trim()
500+
if (sessionId && isQuestionTool(toolPayload.input?.tool)) {
501+
pendingQuestions.delete(sessionId)
502+
return
503+
}
376504
const directory =
377505
typeof toolPayload.directory === "string" && toolPayload.directory.trim()
378506
? toolPayload.directory

0 commit comments

Comments
 (0)