Repository navigation
fix(opencode): route session_end through the session.deleted lifecycle event - #66
Conversation
…e event resolveEvents dropped canonical session_end for OpenCode (null mapping), so team-levelup never fired at session teardown. Map it to the documented native session.deleted event and emit fire-and-forget dispatcher side-effect runners (V1 generic event handler + V2 ctx.event.subscribe loop), each type-guarded so file.edited traffic never mixes in. Tests: RED watched (session_end resolved to nothing, no plugin emitted), then GREEN — full suite 329/329.
kanfil
left a comment
There was a problem hiding this comment.
factory-review: review of 4cfb1d3: not merge-ready (1 Important, 3 Nit)
Reviewed revision: head 4cfb1d36fba9e44ea47236e25f9abcd5fb62e513 · base db2389610a712c979ffe67486f6e5c99e40f0287 · isolated detached checkout of exact head. CI on this head: 10/10 green.
🔴 Important
1. V1 server() object emits two "event" keys — the session.deleted handler is dead when file_edited is also configured (bugs: silently dropped handler)
buildOpenCodeHooks pushes one "event": async ... entry for sessionDeletedRuns (src/events.mjs:548) and a second for fileEditedRuns (:555), joined into a single object literal (:572, spliced into server() at :331). In JS, duplicate keys collapse — the second wins. Verified empirically on this head: a manifest with both session_end and file_edited generates keys ["event","event","experimental.chat.messages.transform"], and at runtime the first handler is lost. Since the session.deleted entry is pushed first, the file.edited handler overwrites it: the PR's flagship behavior (team-levelup at session end) silently never fires in exactly the configuration the README documents (per-agent delivery tables list both events). The new test session_end and file_edited share the V1 event handler namespace... only counts string occurrences of "event": async (passes 2/2) and cannot see the runtime overwrite.
Fix (preserve-only, one entry): emit a single "event" handler whose body dispatches on event.type — if (type === "session.deleted") {...} ; if (type === "file.edited") {...} — so both streams coexist. The existing per-block guards already have the right shape; they just need to live in one object entry.
💬 Nit (advisory)
-
Two concurrent V2
ctx.event.subscribe()loops; broadcast semantics unverified. The diff adds a secondfor await (const event of ctx.event.subscribe())loop next to the file_edited one (src/events.mjs:422,431). If opencode'ssubscribe()is a broadcast, both loops see all events (fine); if it is a shared queue, the two loops split the stream and each misses the other's events. The pre-PR code had exactly one loop, so this question is new. Suggested: confirm against the pluginindex.d.tsevent.subscribecontract, or merge into one loop with a type-switch — mirroring the single-entry fix for finding 1. -
runEventis synchronous (execFileSync+ per-skill timeout), so "fire-and-forget" blocks the event loop. The V2 session.deleted body callsrunEvent(...)un-awaited insidefor await, butrunEvent(src/events.mjs:263) blocks up totimeout+5s per skill. On teardown the host may SIGKILL the plugin mid-run; a genuinely async spawn (or documenting the blocking bound) would match the comment. Cosmetic relative to finding 1 — no behavior change proposed beyond the comment or aspawn-based runner. -
Scope note (informational, not a finding): the PR body correctly states
team-levelup's interactive extraction still needs a/team-leveluprun — the teardown path only guarantees the script side-effect. README'ssession_endenvelope row ("no | yes") vs the new "scripts only, no context injection" delivery note is consistent with this. No action.
Checked
- security: skill/canonical names reach generated TS only via
JSON.stringify(no injection); dispatcher invoked viaexecFileSyncargv array; event payload travels on stdin; no payload is logged (session.deleted output discarded by design). Nothing found. - spec-compliance:
stop: nulluntouched (onlysession_endremapped); other agents'session_endmappings unchanged (SessionEnd/sessionEnd); docs (README.md,docs/events-schema.md) match the code's scripts-only semantics. - test-integrity: 3 new regression tests assert resolution mapping + both V1/V2 handler presence + guard separation;
tests/events.test.mjs74/74 green locally on this head; full CI green. No weakened assertions. - ci-integrity: no workflow files changed.
Not checked
- Whether
session.deletedis the exact native type name in the installed opencode plugin API version (index.d.tsnot vendored in this repo) — taken from the PR's claim; aplugin distgrep on the consumer side would close it. - Windows/macOS runtime behavior of the second subscribe loop (static review only; CI is green).
This verdict is advisory only. It does not approve or merge; a human code-owner approves. Any push makes it stale.
Self-heal status (run
|
…s dropped session.deleted V1 server() object emitted two "event" keys when session_end+file_edited both configured; JS duplicate-key semantics silently discarded the session.deleted handler. Merge both runs into one entry guarded on event.type. Update regression tests to pin single-entry semantics. Reads-from: review F1 (run pr-66-selfheal-0 iter 0)
Self-heal fix (run
|
kanfil
left a comment
There was a problem hiding this comment.
factory-review re-review of b9382cc: merge-ready (0 Important, 3 Nit)
Reviewed revision: head b9382cc3ddc171bb3486f6c0542b598083cc856a · base db2389610a712c979ffe67486f6e5c99e40f0287 · isolated detached checkout. Delta vs 4cfb1d3: F1 fix only (src/events.mjs + 3 test assertions) + nothing else. CI on this head: 10/10 green.
Previous findings disposition
- F1 (Important) RESOLVED: V1 emits exactly one
"event"entry (grep -c= 1); bothsessionDeletedRunsandfileEditedRunsdispatch inside it underevent.type ===guards. Verified at runtime: keys["event","experimental.chat.messages.transform"], both guards + both skill runs present. Regression tests pin single-entry===semantics (RED-verified: fail 3 on old code, 74/74 on fixed). - Nits 2–4 (advisory) carried forward unchanged: V2 dual
subscribe()loops (Nit 2), syncrunEventunder "fire-and-forget" label (Nit 3), team-levelup scope note (Nit 4).
Passes on new head
- bugs — pass. No duplicate keys; standalone transform drainer preserved for file_edited-without-session_start; handler ordering (session.deleted block first) irrelevant — both guarded.
- security — pass. No new exec/network/secret surface; guards are string literals.
- spec-compliance — pass. Docs unchanged and still accurate; mapping table untouched.
- test-integrity — pass. 3 assertion updates match the new guard text exactly; no assertions weakened (single-entry count went 2→1 to match the fix).
- ci-integrity — pass. No workflow changes.
This verdict is advisory only. It does not approve or merge; a human code-owner approves.
Self-heal converge (run
|
Summary
resolveEvents()dropped canonicalsession_endfor OpenCode (registry mapped it tonull), soteam-levelupnever fired at session teardown.session_endnow maps to the documented nativesession.deletedevent, with fire-and-forget dispatcher side-effect runners in both plugin entrypoints:eventhandler guardsevent.type === \"session.deleted\"ctx.event.subscribe()loop guards the same native type in a dedicated subscriptionsession.deletedandfile.editedshare theeventnamespace but each block type-guards its own native type, so the two streams never mix. Session-end output has no context-injection target (the session is over), so dispatcher output is discarded — scripts only.Tests
session_endregression tests; watched 2 fail correctly before the fix (resolution dropped the event, no plugin emitted).node --test tests/events.test.mjs: 74/74 pass.node --test: 329/329 pass.Docs
docs/events-schema.mdandREADME.mdupdated for the OpenCodesession.deleteddelivery and scripts-only semantics.Scope note
This makes the session-end hook actually fire and run the skill script path.
team-levelup's full interactive CDR extraction/review/publish flow still needs an interactive/team-leveluprun; the teardown hook guarantees the deterministic script side-effect runs instead of being silently dropped.