Skip to content

fix(heartbeat): route exec and cron events back to originating channel#32297

Closed
mystic27x wants to merge 10 commits intoopenclaw:mainfrom
mystic27x:fix/event-followup-sessionkey
Closed

fix(heartbeat): route exec and cron events back to originating channel#32297
mystic27x wants to merge 10 commits intoopenclaw:mainfrom
mystic27x:fix/event-followup-sessionkey

Conversation

@mystic27x
Copy link
Copy Markdown

Summary

  • Exec approval/deny follow-up: relay exec completion results back to the originating channel (Telegram, Discord, etc.) after approval or denial, using the session's channel context and a target: last override so the result reaches the user who triggered it
  • Cron delivery routing: when the cron service calls runHeartbeatOnce with target: \"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 delivery
  • Subagent continuation: after delivering a step result to the user, the agent now checks PROJECT-PLAN.md in the project workspace — if the next step is gated ([GATE]), it stops and waits for approval; if not gated, it proceeds automatically

Test plan

  • Exec approval/deny: trigger an async exec from a Telegram/Discord session; confirm result is delivered back to that channel after approval or denial
  • Cron routing: fire a cron reminder with a main session that has no channel context but an active Telegram/Discord session; confirm delivery reaches the channel session (not silently dropped)
  • Subagent continuation: complete a subagent task in a project with PROJECT-PLAN.md; confirm the parent agent either gates or auto-continues based on [GATE] marker
  • Regression: existing heartbeat tests (60) all pass — pnpm test src/infra/heartbeat-runner

🤖 Generated with Claude Code

@openclaw-barnacle openclaw-barnacle bot added gateway Gateway runtime agents Agent runtime and tooling size: M labels Mar 2, 2026
Copy link
Copy Markdown

@chatgpt-codex-connector chatgpt-codex-connector bot left a comment

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

💡 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".

Comment on lines +647 to +650
const execEventSessionKey =
resolveHeartbeatReasonKind(opts.reason) === "exec-event" ? opts.sessionKey : undefined;
const queueLane = execEventSessionKey ?? CommandLane.Main;
const queueSize = (opts.deps?.getQueueSize ?? getQueueSize)(queueLane);
Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

P1 Badge 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 👍 / 👎.

Comment on lines +604 to +607
if (!e.lastChannel || e.lastChannel === "none") {
continue;
}
if (key === currentSessionKey || key.includes(":cron:")) {
Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

P1 Badge 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-apps
Copy link
Copy Markdown
Contributor

greptile-apps bot commented Mar 2, 2026

Greptile Summary

This 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 PROJECT-PLAN.md in subagent announcements.

Key issues found:

  • Critical logic gap — "exec-approval:resolve" not recognised as an exec-event kind: resolveHeartbeatReasonKind in heartbeat-reason.ts maps this new reason to "other" rather than "exec-event". This has three downstream consequences: (1) shouldBypassFileGates is false, so if HEARTBEAT.md exists and is effectively empty the approval/denial result is silently dropped; (2) execFallback is always false, so if heartbeat.target is "none" the result never routes back to the originating Telegram/Discord channel; (3) the queue-lane check uses CommandLane.Main rather than the specific session lane. Adding "exec-approval:resolve" (or a "exec-approval:" prefix) to resolveHeartbeatReasonKind would fix all three.
  • Inconsistent dedup guard in emitExecSystemEvent: server-node-events.ts was updated to gate requestHeartbeatNow on the boolean return value of enqueueSystemEvent, but bash-tools.exec-runtime.ts still calls requestHeartbeatNow unconditionally, waking the heartbeat even for duplicate events.
  • Redundant ?? undefined in exec-approval.ts line 289 (minor style).

Confidence Score: 2/5

  • Not safe to merge as-is — the "exec-approval:resolve" reason is not recognised as an exec-event, causing silent delivery failures when HEARTBEAT.md is empty and preventing channel fallback when target is "none".
  • The cron routing fallback and the new tests are solid. However the core exec-approval routing has a structural gap: the new reason string is never classified as "exec-event" in resolveHeartbeatReasonKind, so the file-gate bypass and the execFallback delivery override both fail silently for approval/denial events. This directly contradicts the PR's stated goal and could leave users with no delivery of exec results in common configurations.
  • src/infra/heartbeat-runner.ts and src/infra/heartbeat-reason.ts need attention — the "exec-approval:resolve" reason must be mapped to the "exec-event" kind to unlock the intended routing behaviour.

Last reviewed commit: 0bb060e

Copy link
Copy Markdown
Contributor

@greptile-apps greptile-apps bot left a comment

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

10 files reviewed, 3 comments

Edit Code Review Agent Settings | Greptile

Comment on lines +647 to +648
const execEventSessionKey =
resolveHeartbeatReasonKind(opts.reason) === "exec-event" ? opts.sessionKey : undefined;
Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

"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:

// 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.

Comment on lines 266 to +267
enqueueSystemEvent(text, { sessionKey, contextKey: opts.contextKey });
requestHeartbeatNow({ reason: "exec-event" });
requestHeartbeatNow({ reason: "exec-event", sessionKey });
Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

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,
Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

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.

Copy link
Copy Markdown

@chatgpt-codex-connector chatgpt-codex-connector bot left a comment

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

💡 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".

Comment on lines +607 to +608
if (key === currentSessionKey || key.includes(":cron:")) {
continue;
Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

P1 Badge 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 👍 / 👎.

@mystic27x mystic27x closed this by deleting the head repository Mar 6, 2026
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

agents Agent runtime and tooling gateway Gateway runtime size: M

Projects

None yet

Development

Successfully merging this pull request may close these issues.

1 participant