Skip to content

Commit 734a1dc

Browse files
authored
Merge pull request #484 from dmoliveira/chore/enforcer-investigation-refresh2
Improve continuation enforcer observability
2 parents 31bc0fa + 40decd8 commit 734a1dc

File tree

12 files changed

+363
-51
lines changed

12 files changed

+363
-51
lines changed

.opencode/gateway-core.config.json

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -11,7 +11,7 @@
1111
"hookModes": {
1212
"auto-slash-command": "assist",
1313
"provider-error-classifier": "assist",
14-
"todo-continuation-enforcer": "shadow"
14+
"todo-continuation-enforcer": "assist"
1515
},
1616
"command": "opencode",
1717
"model": "openai/gpt-5.1-codex-mini",

README.md

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -174,7 +174,7 @@ Profiles:
174174
- `scripts/autoflow_command.py` - backend script for `/autoflow`
175175
- `scripts/init_deep_command.py` - backend script for `/init-deep`
176176
- `scripts/continuation_stop_command.py` - backend script for `/continuation-stop`
177-
- `scripts/opencode_session.sh` - optional wrapper to run digest on process exit
177+
- `scripts/opencode_session.sh` - optional wrapper to run digest on process exit and enable `MY_OPENCODE_GATEWAY_EVENT_AUDIT=1` by default with rotation; after a wrapped session, `/gateway continuation report` is the fastest check for recent `todo-continuation-enforcer` activity
178178
- `scripts/telemetry_command.py` - backend script for `/telemetry`
179179
- `scripts/post_session_command.py` - backend script for `/post-session`
180180
- `scripts/policy_command.py` - policy profile helper used by `/notify policy ...` and stack presets

docs/command-handbook.md

Lines changed: 11 additions & 4 deletions
Original file line numberDiff line numberDiff line change
@@ -126,7 +126,7 @@ Use these directly in OpenCode:
126126
- event: `events.<type>`
127127
- per-event channel: `channels.<type>.sound|visual`
128128

129-
`/notify inbox` reads the repo-local gateway event audit feed from `.opencode/gateway-events.jsonl` (or `MY_OPENCODE_GATEWAY_EVENT_AUDIT_PATH` when set). Enable gateway event auditing with `MY_OPENCODE_GATEWAY_EVENT_AUDIT=1` to populate inbox entries.
129+
`/notify inbox` reads the repo-local gateway event audit feed from `.opencode/gateway-events.jsonl` (or `MY_OPENCODE_GATEWAY_EVENT_AUDIT_PATH` when set). Enable gateway event auditing with `MY_OPENCODE_GATEWAY_EVENT_AUDIT=1` to populate inbox entries, but after a wrapped session use `/gateway continuation report` for the fastest `todo-continuation-enforcer` audit check.
130130

131131
## Session digest inside OpenCode 🧾
132132

@@ -153,6 +153,9 @@ Optional environment variables:
153153
- `MY_OPENCODE_DIGEST_PATH` custom output path
154154
- `MY_OPENCODE_DIGEST_HOOK` command to run after digest is written
155155
- `DIGEST_REASON_ON_EXIT` custom reason label (default `exit`)
156+
- `MY_OPENCODE_GATEWAY_EVENT_AUDIT` audit toggle for hook diagnostics (`opencode_session.sh` defaults to `1`; set `0` to disable)
157+
- `MY_OPENCODE_GATEWAY_EVENT_AUDIT_MAX_BYTES` max audit file size before rotation (`opencode_session.sh` defaults to `8388608`)
158+
- `MY_OPENCODE_GATEWAY_EVENT_AUDIT_MAX_BACKUPS` rotated audit backup count (`opencode_session.sh` defaults to `5`)
156159

157160
When `--run-post` is used, digest also evaluates `post_session` config and stores hook results in the digest JSON.
158161

@@ -278,6 +281,7 @@ Use these directly in OpenCode:
278281
/gateway enable
279282
/gateway disable
280283
/gateway doctor
284+
/gateway continuation report --minutes 120 --limit 10 --json
281285
/gateway tune memory --json
282286
/gateway recover memory --apply --resume --compress --force-kill
283287
/gateway protection report --limit 20 --json
@@ -291,12 +295,15 @@ Notes:
291295
- `/gateway status` and `/gateway doctor` run orphan cleanup before reporting runtime loop state.
292296
- `/gateway status --json` now includes `mistake_ledger` so operators can see whether validation deferrals are accumulating in `.opencode/mistake-ledger.jsonl`.
293297
- `/gateway doctor --json` now includes `hook_diagnostics` and fails when gateway is enabled without a valid built hook surface.
298+
- `/gateway continuation report --json` summarizes recent `todo-continuation-enforcer` audit events so you can see reason codes, stages, and affected sessions quickly.
299+
- `/gateway continuation report --json` now also exposes `assistant_message_open_todo_events` so you can spot intermediate assistant replies that landed while todos were still open.
300+
- after a wrapped session, `/gateway continuation report` is the fastest check for recent `todo-continuation-enforcer` activity.
294301
- parity and naming differences vs upstream are tracked in `docs/upstream-divergence-registry.md`.
295-
- set `MY_OPENCODE_GATEWAY_EVENT_AUDIT=1` to write hook dispatch diagnostics to `.opencode/gateway-events.jsonl` (override path with `MY_OPENCODE_GATEWAY_EVENT_AUDIT_PATH`).
302+
- `scripts/opencode_session.sh` now enables `MY_OPENCODE_GATEWAY_EVENT_AUDIT=1` by default with rotation; set `MY_OPENCODE_GATEWAY_EVENT_AUDIT=0` to disable or override path with `MY_OPENCODE_GATEWAY_EVENT_AUDIT_PATH`.
296303
- set `MY_OPENCODE_GATEWAY_DISPATCH_SAMPLE_RATE=<n>` to reduce noisy dispatch audit events (`message.*`, `session.*`, transform dispatch); `1` logs every event, default is `20`.
297304

298305
Debug and troubleshooting guidance:
299-
- keep gateway event audit off by default during normal work; enable it for time-boxed diagnosis windows (for example, 30-120 minutes).
306+
- if you launch through `scripts/opencode_session.sh`, gateway event audit is on by default; launch plain `opencode` or set `MY_OPENCODE_GATEWAY_EVENT_AUDIT=0` when you want a quiet run.
300307
- with audit enabled, expect small extra CPU/file-I/O overhead and log growth; this is not a direct model token-cost increase by itself.
301308
- after diagnosis, disable audit again to reduce background noise and disk churn.
302309

@@ -404,7 +411,7 @@ This index is sourced from `opencode.json` and is used as the complete catalog r
404411
/devtools - Manage external productivity tools (status|doctor|install|hooks-install)
405412
/digest - Generate or show session digests (run|show)
406413
/doctor - Run diagnostics and reason-code registry export
407-
/gateway - Manage gateway runtime controls (status|enable|disable|doctor|tune memory|recover memory|protection)
414+
/gateway - Manage gateway runtime controls (status|enable|disable|doctor|continuation report|tune memory|recover memory|protection)
408415
/governance - Manage governance policy profiles and authorizations (status|profile|authorize|revoke|doctor)
409416
/health - Show repo health score and drift insights
410417
/hook-learning - Run hook learning loop controls (pre-command|post-command|route|metrics|doctor)

plugin/gateway-core/dist/hooks/long-turn-watchdog/index.d.ts

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -3,6 +3,7 @@ export declare function createLongTurnWatchdogHook(options: {
33
directory: string;
44
enabled: boolean;
55
warningThresholdMs: number;
6+
toolCallWarningThreshold: number;
67
reminderCooldownMs: number;
78
maxSessionStateEntries: number;
89
prefix: string;

plugin/gateway-core/dist/hooks/long-turn-watchdog/index.js

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -120,9 +120,9 @@ export function createLongTurnWatchdogHook(options) {
120120
reason_code: "below_threshold",
121121
session_id: sessionId,
122122
elapsed_ms: elapsedMs,
123+
warning_threshold_ms: options.warningThresholdMs,
123124
tool_calls_this_turn: state.toolCallsThisTurn,
124125
tool_call_warning_threshold: toolCallThreshold,
125-
warning_threshold_ms: options.warningThresholdMs,
126126
});
127127
return;
128128
}

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

Lines changed: 11 additions & 5 deletions
Original file line numberDiff line numberDiff line change
@@ -1,6 +1,7 @@
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+
// Returns true when event error resembles recoverable transient session failure.
45
function isRecoverableError(error) {
56
const candidate = error && typeof error === "object" && "message" in error
67
? String(error.message ?? "")
@@ -12,8 +13,13 @@ function isRecoverableError(error) {
1213
message.includes("network") ||
1314
message.includes("timeout"));
1415
}
16+
// Resolves session id from error event payload.
1517
function resolveSessionId(payload) {
16-
const candidates = [payload.properties?.sessionID, payload.properties?.sessionId, payload.properties?.info?.id];
18+
const candidates = [
19+
payload.properties?.sessionID,
20+
payload.properties?.sessionId,
21+
payload.properties?.info?.id,
22+
];
1723
for (const value of candidates) {
1824
if (typeof value === "string" && value.trim()) {
1925
return value.trim();
@@ -30,12 +36,11 @@ function looksLikeDelegatedTaskAbort(output) {
3036
const state = nested?.state && typeof nested.state === "object" ? nested.state : null;
3137
const metadata = state?.metadata && typeof state.metadata === "object" ? state.metadata : null;
3238
const status = String(state?.status ?? "").trim().toLowerCase();
33-
const error = `${String(state?.error ?? "")}
34-
${String(nested?.error ?? "")}
35-
${text}`.toLowerCase();
39+
const error = `${String(state?.error ?? "")}\n${String(nested?.error ?? "")}\n${text}`.toLowerCase();
3640
const childSessionId = String(metadata?.sessionId ?? metadata?.sessionID ?? "").trim();
3741
return {
38-
aborted: status === "error" && error.includes("tool execution aborted"),
42+
aborted: status === "error" &&
43+
error.includes("tool execution aborted"),
3944
childSessionId,
4045
};
4146
}
@@ -77,6 +82,7 @@ async function injectRecoveryMessage(args) {
7782
});
7883
return true;
7984
}
85+
// Creates session recovery hook that attempts one auto-resume per active error session.
8086
export function createSessionRecoveryHook(options) {
8187
const recoveringSessions = new Set();
8288
return {

plugin/gateway-core/dist/hooks/subagent-telemetry-timeline/index.js

Lines changed: 0 additions & 6 deletions
Original file line numberDiff line numberDiff line change
@@ -44,8 +44,6 @@ export function createSubagentTelemetryTimelineHook(options) {
4444
reasonCode: "subagent_telemetry_child_idle_reconciled",
4545
endedAt: Date.now(),
4646
childRunId: link.childRunId || undefined,
47-
traceId: link.traceId || undefined,
48-
subagentType: link.subagentType || undefined,
4947
}, options.maxTimelineEntries);
5048
if (!record) {
5149
return;
@@ -89,8 +87,6 @@ export function createSubagentTelemetryTimelineHook(options) {
8987
: "subagent_telemetry_child_message_completed_reconciled",
9088
endedAt: Date.now(),
9189
childRunId: link.childRunId || undefined,
92-
traceId: link.traceId || undefined,
93-
subagentType: link.subagentType || undefined,
9490
}, options.maxTimelineEntries);
9591
if (!record) {
9692
return;
@@ -124,8 +120,6 @@ export function createSubagentTelemetryTimelineHook(options) {
124120
reasonCode: "subagent_telemetry_child_deleted_reconciled",
125121
endedAt: Date.now(),
126122
childRunId: childLink.childRunId || undefined,
127-
traceId: childLink.traceId || undefined,
128-
subagentType: childLink.subagentType || undefined,
129123
}, options.maxTimelineEntries);
130124
if (record) {
131125
writeGatewayEventAudit(options.directory, {

plugin/gateway-core/dist/hooks/todo-continuation-enforcer/index.js

Lines changed: 64 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -437,6 +437,69 @@ export function createTodoContinuationEnforcerHook(options) {
437437
state.pendingSource = state.pendingContinuation ? "task_output" : undefined;
438438
return;
439439
}
440+
if (type === "message.updated") {
441+
const eventPayload = (payload ?? {});
442+
const sessionId = resolveSessionId(eventPayload);
443+
if (!sessionId) {
444+
return;
445+
}
446+
const info = eventPayload.properties?.info;
447+
if (String(info?.role ?? "").toLowerCase().trim() !== "assistant") {
448+
return;
449+
}
450+
const completed = Number.isFinite(Number(info?.time?.completed ?? Number.NaN));
451+
const failed = info?.error !== undefined && info?.error !== null;
452+
if (!completed && !failed) {
453+
return;
454+
}
455+
const state = getSessionState(sessionState, sessionId);
456+
state.lastTraceId = resolveTraceId(eventPayload);
457+
if (state.pendingTodoCount <= 0) {
458+
return;
459+
}
460+
const text = assistantText({
461+
info: { role: "assistant" },
462+
parts: eventPayload.output?.parts ?? eventPayload.properties?.parts,
463+
});
464+
if (!text) {
465+
return;
466+
}
467+
const shouldContinue = await resolvePendingContinuationDecision({
468+
text,
469+
continueIntentArmed: state.continueIntentArmed,
470+
source: "assistant_message",
471+
sessionId,
472+
directory: resolveDirectory(eventPayload, options.directory),
473+
traceId: state.lastTraceId,
474+
decisionRuntime: options.decisionRuntime,
475+
});
476+
if (!shouldContinue) {
477+
state.pendingContinuation = false;
478+
state.pendingSource = undefined;
479+
state.pendingTodoCount = 0;
480+
state.markerProbeAttempted = false;
481+
writeGatewayEventAudit(resolveDirectory(eventPayload, options.directory), {
482+
hook: "todo-continuation-enforcer",
483+
stage: "state",
484+
reason_code: "todo_continuation_assistant_message_no_pending",
485+
session_id: sessionId,
486+
trace_id: state.lastTraceId,
487+
});
488+
return;
489+
}
490+
state.pendingContinuation = true;
491+
state.pendingSource = "assistant_message";
492+
state.markerProbeAttempted = false;
493+
writeGatewayEventAudit(resolveDirectory(eventPayload, options.directory), {
494+
hook: "todo-continuation-enforcer",
495+
stage: "state",
496+
reason_code: "todo_continuation_assistant_message_with_open_todos",
497+
session_id: sessionId,
498+
trace_id: state.lastTraceId,
499+
open_todo_count: state.pendingTodoCount,
500+
});
501+
return;
502+
}
440503
if (type !== "session.idle") {
441504
return;
442505
}
@@ -575,6 +638,7 @@ export function createTodoContinuationEnforcerHook(options) {
575638
if (injected) {
576639
state.consecutiveFailures = 0;
577640
state.pendingContinuation = false;
641+
state.pendingTodoCount = 0;
578642
state.pendingSource = undefined;
579643
state.continueIntentArmed = false;
580644
state.markerProbeAttempted = false;

plugin/gateway-core/src/hooks/todo-continuation-enforcer/index.ts

Lines changed: 89 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -74,9 +74,31 @@ interface ChatPayload {
7474
}
7575
}
7676

77+
interface MessageUpdatedPayload {
78+
directory?: string
79+
properties?: {
80+
sessionID?: string
81+
sessionId?: string
82+
trace_id?: string
83+
traceId?: string
84+
info?: {
85+
id?: string
86+
role?: string
87+
sessionID?: string
88+
sessionId?: string
89+
error?: unknown
90+
time?: { completed?: number }
91+
}
92+
parts?: Array<{ type?: string; text?: string; synthetic?: boolean }>
93+
}
94+
output?: {
95+
parts?: Array<{ type?: string; text?: string; synthetic?: boolean }>
96+
}
97+
}
98+
7799
interface SessionState {
78100
pendingContinuation: boolean
79-
pendingSource?: "task_output" | "message_probe"
101+
pendingSource?: "assistant_message" | "task_output" | "message_probe"
80102
pendingTodoCount: number
81103
lastInjectedAt: number
82104
consecutiveFailures: number
@@ -308,7 +330,7 @@ function hasPendingCueText(text: string, continueIntentArmed: boolean): boolean
308330
async function resolvePendingContinuationDecision(options: {
309331
text: string
310332
continueIntentArmed: boolean
311-
source: "task_output" | "message_probe"
333+
source: "assistant_message" | "task_output" | "message_probe"
312334
sessionId: string
313335
directory: string
314336
traceId?: string
@@ -587,6 +609,70 @@ export function createTodoContinuationEnforcerHook(options: {
587609
return
588610
}
589611

612+
if (type === "message.updated") {
613+
const eventPayload = (payload ?? {}) as MessageUpdatedPayload
614+
const sessionId = resolveSessionId(eventPayload)
615+
if (!sessionId) {
616+
return
617+
}
618+
const info = eventPayload.properties?.info
619+
if (String(info?.role ?? "").toLowerCase().trim() !== "assistant") {
620+
return
621+
}
622+
const completed = Number.isFinite(Number(info?.time?.completed ?? Number.NaN))
623+
const failed = info?.error !== undefined && info?.error !== null
624+
if (!completed && !failed) {
625+
return
626+
}
627+
const state = getSessionState(sessionState, sessionId)
628+
state.lastTraceId = resolveTraceId(eventPayload)
629+
if (state.pendingTodoCount <= 0) {
630+
return
631+
}
632+
const text = assistantText({
633+
info: { role: "assistant" },
634+
parts: eventPayload.output?.parts ?? eventPayload.properties?.parts,
635+
})
636+
if (!text) {
637+
return
638+
}
639+
const shouldContinue = await resolvePendingContinuationDecision({
640+
text,
641+
continueIntentArmed: state.continueIntentArmed,
642+
source: "assistant_message",
643+
sessionId,
644+
directory: resolveDirectory(eventPayload, options.directory),
645+
traceId: state.lastTraceId,
646+
decisionRuntime: options.decisionRuntime,
647+
})
648+
if (!shouldContinue) {
649+
state.pendingContinuation = false
650+
state.pendingSource = undefined
651+
state.pendingTodoCount = 0
652+
state.markerProbeAttempted = false
653+
writeGatewayEventAudit(resolveDirectory(eventPayload, options.directory), {
654+
hook: "todo-continuation-enforcer",
655+
stage: "state",
656+
reason_code: "todo_continuation_assistant_message_no_pending",
657+
session_id: sessionId,
658+
trace_id: state.lastTraceId,
659+
})
660+
return
661+
}
662+
state.pendingContinuation = true
663+
state.pendingSource = "assistant_message"
664+
state.markerProbeAttempted = false
665+
writeGatewayEventAudit(resolveDirectory(eventPayload, options.directory), {
666+
hook: "todo-continuation-enforcer",
667+
stage: "state",
668+
reason_code: "todo_continuation_assistant_message_with_open_todos",
669+
session_id: sessionId,
670+
trace_id: state.lastTraceId,
671+
open_todo_count: state.pendingTodoCount,
672+
})
673+
return
674+
}
675+
590676
if (type !== "session.idle") {
591677
return
592678
}
@@ -734,6 +820,7 @@ export function createTodoContinuationEnforcerHook(options: {
734820
if (injected) {
735821
state.consecutiveFailures = 0
736822
state.pendingContinuation = false
823+
state.pendingTodoCount = 0
737824
state.pendingSource = undefined
738825
state.continueIntentArmed = false
739826
state.markerProbeAttempted = false

0 commit comments

Comments
 (0)