fix(heartbeat): route exec and cron events back to originating channel#32297
fix(heartbeat): route exec and cron events back to originating channel#32297mystic27x wants to merge 10 commits intoopenclaw:mainfrom
Conversation
…e on deduped enqueue
…a specific session
…n session has no channel
There was a problem hiding this comment.
💡 Codex Review
Here are some automated review suggestions for this pull request.
Reviewed commit: 0bb060e7a7
ℹ️ About Codex in GitHub
Codex has been enabled to automatically review pull requests in this repo. Reviews are triggered when you
- Open a pull request for review
- Mark a draft as ready
- Comment "@codex review".
If Codex has suggestions, it will comment; otherwise it will react with 👍.
When you sign up for Codex through ChatGPT, Codex can also answer questions or update the PR, like "@codex address that feedback".
| const execEventSessionKey = | ||
| resolveHeartbeatReasonKind(opts.reason) === "exec-event" ? opts.sessionKey : undefined; | ||
| const queueLane = execEventSessionKey ?? CommandLane.Main; | ||
| const queueSize = (opts.deps?.getQueueSize ?? getQueueSize)(queueLane); |
There was a problem hiding this comment.
Use normalized session lane when checking exec wake backlog
Switching the queue gate to opts.sessionKey here breaks the in-flight check for exec wakes, because reply runs are enqueued on session:${key} (see resolveSessionLane usage in src/agents/pi-embedded-runner/run.ts). With the raw key, getQueueSize usually returns 0 even while that session is still busy, so heartbeat can run concurrently and emit exec follow-ups out of order instead of returning requests-in-flight and retrying.
Useful? React with 👍 / 👎.
| if (!e.lastChannel || e.lastChannel === "none") { | ||
| continue; | ||
| } | ||
| if (key === currentSessionKey || key.includes(":cron:")) { |
There was a problem hiding this comment.
Skip non-deliverable sessions in cron fallback selection
This candidate filter accepts any non-empty lastChannel, so the fallback can select the newest internal/non-routable session (for example webchat) or an entry without a usable recipient and then resolve to channel:none. In that case cron delivery is still silently dropped even if an older Telegram/Discord session is available. The fallback should only consider entries whose route is actually deliverable (channel + target), not just lastChannel presence.
Useful? React with 👍 / 👎.
Greptile SummaryThis PR implements three heartbeat routing improvements: routing exec completion/denial results back to the originating channel session after approval or denial, falling back to the most recently active real-channel session for cron delivery when the main session has no channel context, and adding a post-delivery continuation check against Key issues found:
Confidence Score: 2/5
Last reviewed commit: 0bb060e |
| const execEventSessionKey = | ||
| resolveHeartbeatReasonKind(opts.reason) === "exec-event" ? opts.sessionKey : undefined; |
There was a problem hiding this comment.
"exec-approval:resolve" not classified as exec-event — bypasses exec fallback and file-gate bypass
resolveHeartbeatReasonKind("exec-approval:resolve") returns "other" because the function only matches the literal string "exec-event" or a prefix of "cron:" / "hook:". As a result:
shouldBypassFileGatesisfalsefor exec-approval:resolve reasons. If the agent workspace has an existingHEARTBEAT.mdthat is effectively empty,resolveHeartbeatPreflightreturns{ skipReason: "empty-heartbeat-file" }and the exec-approval result is silently dropped — the user never receives the outcome.execFallbackis alwaysfalsefor this reason (it requirespreflight.isExecEventReason), so ifheartbeat.target === "none", the delivery target is never overridden to"last"and the result does not route back to the originating Telegram/Discord channel.- Queue lane check uses
CommandLane.Mainrather than the specific session lane, potentially delaying delivery if the main lane has unrelated traffic.
The intent of requestHeartbeatNow({ reason: "exec-approval:resolve" }) is to trigger exec-completion delivery back to the originating channel — this should behave identically to an "exec-event" wake in terms of gate bypassing and delivery routing. Adding "exec-approval:resolve" (or a prefix match like "exec-approval:") to resolveHeartbeatReasonKind would fix all three issues:
// In heartbeat-reason.ts
if (trimmed === "exec-event" || trimmed.startsWith("exec-approval:")) {
return "exec-event";
}Prompt To Fix With AI
This is a comment left during a code review.
Path: src/infra/heartbeat-runner.ts
Line: 647-648
Comment:
**"exec-approval:resolve" not classified as exec-event — bypasses exec fallback and file-gate bypass**
`resolveHeartbeatReasonKind("exec-approval:resolve")` returns `"other"` because the function only matches the literal string `"exec-event"` or a prefix of `"cron:"` / `"hook:"`. As a result:
1. **`shouldBypassFileGates` is `false`** for exec-approval:resolve reasons. If the agent workspace has an existing `HEARTBEAT.md` that is effectively empty, `resolveHeartbeatPreflight` returns `{ skipReason: "empty-heartbeat-file" }` and the exec-approval result is **silently dropped** — the user never receives the outcome.
2. **`execFallback` is always `false`** for this reason (it requires `preflight.isExecEventReason`), so if `heartbeat.target === "none"`, the delivery target is never overridden to `"last"` and the result does not route back to the originating Telegram/Discord channel.
3. **Queue lane check uses `CommandLane.Main`** rather than the specific session lane, potentially delaying delivery if the main lane has unrelated traffic.
The intent of `requestHeartbeatNow({ reason: "exec-approval:resolve" })` is to trigger exec-completion delivery back to the originating channel — this should behave identically to an `"exec-event"` wake in terms of gate bypassing and delivery routing. Adding `"exec-approval:resolve"` (or a prefix match like `"exec-approval:"`) to `resolveHeartbeatReasonKind` would fix all three issues:
```typescript
// In heartbeat-reason.ts
if (trimmed === "exec-event" || trimmed.startsWith("exec-approval:")) {
return "exec-event";
}
```
How can I resolve this? If you propose a fix, please make it concise.| enqueueSystemEvent(text, { sessionKey, contextKey: opts.contextKey }); | ||
| requestHeartbeatNow({ reason: "exec-event" }); | ||
| requestHeartbeatNow({ reason: "exec-event", sessionKey }); |
There was a problem hiding this comment.
Heartbeat wake not guarded against deduplicated events
server-node-events.ts was updated in this PR to only call requestHeartbeatNow when enqueueSystemEvent returns true (i.e., the event was actually enqueued rather than deduplicated). emitExecSystemEvent here doesn't apply the same guard — if the event is a consecutive duplicate it will still wake the heartbeat unnecessarily. Consider checking the return value of enqueueSystemEvent and only calling requestHeartbeatNow when it returns true, to stay consistent with the pattern introduced in server-node-events.ts.
Prompt To Fix With AI
This is a comment left during a code review.
Path: src/agents/bash-tools.exec-runtime.ts
Line: 266-267
Comment:
**Heartbeat wake not guarded against deduplicated events**
`server-node-events.ts` was updated in this PR to only call `requestHeartbeatNow` when `enqueueSystemEvent` returns `true` (i.e., the event was actually enqueued rather than deduplicated). `emitExecSystemEvent` here doesn't apply the same guard — if the event is a consecutive duplicate it will still wake the heartbeat unnecessarily. Consider checking the return value of `enqueueSystemEvent` and only calling `requestHeartbeatNow` when it returns `true`, to stay consistent with the pattern introduced in `server-node-events.ts`.
How can I resolve this? If you propose a fix, please make it concise.| // Wake the session that's awaiting this approval decision. | ||
| requestHeartbeatNow({ | ||
| reason: "exec-approval:resolve", | ||
| sessionKey: snapshot?.request?.sessionKey ?? undefined, |
There was a problem hiding this comment.
Redundant nullish coalescing
Optional chaining (?.) already returns undefined when the chain short-circuits, so the ?? undefined at the end of snapshot?.request?.sessionKey ?? undefined has no effect. The field can be simplified to just use the optional chain directly.
Prompt To Fix With AI
This is a comment left during a code review.
Path: src/gateway/server-methods/exec-approval.ts
Line: 289
Comment:
**Redundant nullish coalescing**
Optional chaining (`?.`) already returns `undefined` when the chain short-circuits, so the `?? undefined` at the end of `snapshot?.request?.sessionKey ?? undefined` has no effect. The field can be simplified to just use the optional chain directly.
How can I resolve this? If you propose a fix, please make it concise.There was a problem hiding this comment.
💡 Codex Review
Here are some automated review suggestions for this pull request.
Reviewed commit: 72108b7b15
ℹ️ About Codex in GitHub
Codex has been enabled to automatically review pull requests in this repo. Reviews are triggered when you
- Open a pull request for review
- Mark a draft as ready
- Comment "@codex review".
If Codex has suggestions, it will comment; otherwise it will react with 👍.
When you sign up for Codex through ChatGPT, Codex can also answer questions or update the PR, like "@codex address that feedback".
| if (key === currentSessionKey || key.includes(":cron:")) { | ||
| continue; |
There was a problem hiding this comment.
Restrict cron fallback lookup to current agent
resolveBestCronDeliveryEntry now picks the newest deliverable entry from the entire loaded session store, but it only excludes the current key and :cron: keys. In deployments where session.store is shared across agents (for example a fixed path without {agentId}), this can select another agent’s recent channel session and send this agent’s cron reminder to the wrong recipient. Filter candidates to the current agent’s session namespace before comparing updatedAt.
Useful? React with 👍 / 👎.
Summary
target: lastoverride so the result reaches the user who triggered itrunHeartbeatOncewithtarget: \"last\"and the main session has no channel context, fall back to the most recently active real-channel session (e.g. Discord, Telegram) rather than silently dropping deliveryPROJECT-PLAN.mdin the project workspace — if the next step is gated ([GATE]), it stops and waits for approval; if not gated, it proceeds automaticallyTest plan
PROJECT-PLAN.md; confirm the parent agent either gates or auto-continues based on[GATE]markerpnpm test src/infra/heartbeat-runner🤖 Generated with Claude Code