Skip to content

Heartbeat: Record outbound reply proofs#201

Open
jc wants to merge 1 commit intomodem-dev:mainfrom
jc:fix/heartbeat-reply-ledger
Open

Heartbeat: Record outbound reply proofs#201
jc wants to merge 1 commit intomodem-dev:mainfrom
jc:fix/heartbeat-reply-ledger

Conversation

@jc
Copy link

@jc jc commented Mar 7, 2026

Slack bridge appends to reply ledger.

@greptile-apps
Copy link

greptile-apps bot commented Mar 7, 2026

Greptile Summary

This PR makes the Slack bridge the authoritative writer of outbound-reply proofs: both bridge.mjs and broker-bridge.mjs now append a structured JSONL entry to ~/.pi/agent/slack-reply-log.jsonl after every successful /send (threaded) or /reply call. The heartbeat's hasRepliedToThread already consumed this file as its primary check (Path 1); the PR also expands the session-log fallback (Path 2) to scan all session subdirectories instead of only --home-baudbot_agent--, and bumps the per-directory file limit from 3 to 10. A targeted test confirms that entries carrying the new channel and route fields are still matched correctly.

Key points:

  • The ledger file (slack-reply-log.jsonl) is append-only with no pruning or rotation — it will grow unboundedly, and hasRepliedToThread reads the whole file on every heartbeat tick.
  • bridge.mjs uses err.message directly in the catch block, while broker-bridge.mjs correctly guards with err instanceof Error; minor but inconsistent.
  • The expanded Path-2 fallback still only matches curl /send commands, so pre-ledger /reply activity remains undetectable through that path.
  • Both bridge processes share the same ledger file path; concurrent appends are safe on Linux (O_APPEND is atomic for small writes) but worth noting if the setup ever moves to Windows or network filesystems.

Confidence Score: 4/5

  • Safe to merge — changes are additive and non-breaking with graceful error handling; the main long-term concern is unbounded ledger growth.
  • The logic is straightforward and well-tested. Ledger writes are fire-and-forget (errors are warned but don't fail the reply), and the heartbeat already handled a missing ledger gracefully. The only real risk is the unbounded file growth over time, which is a maintenance concern rather than an immediate correctness bug.
  • No files require special attention for merging, but gateway-bridge/bridge.mjs and pi/extensions/heartbeat.ts carry the minor issues flagged above.

Important Files Changed

Filename Overview
gateway-bridge/bridge.mjs Adds appendReplyLedgerEntry and calls it from both /send and /reply handlers; error serialisation uses bare err.message (vs the safer pattern in broker-bridge.mjs), and the ledger file has no pruning mechanism.
gateway-bridge/broker-bridge.mjs Mirror implementation of appendReplyLedgerEntry with correct err instanceof Error guard; same unbounded-growth concern applies but logic itself is sound.
pi/extensions/heartbeat.ts Session-scan fallback expanded to all session dirs with 10-file cap per dir; Path 1 (ledger) now trusted as primary source; fallback still only matches /send curl calls, and both paths lack file-size bounds.
pi/extensions/heartbeat.test.mjs Adds a test confirming that ledger entries with route and channel fields from both /send and /reply are correctly matched by hasReplyLogEntry; coverage looks adequate for the new schema.

Sequence Diagram

sequenceDiagram
    participant Agent
    participant Bridge as bridge.mjs / broker-bridge.mjs
    participant Slack
    participant Ledger as slack-reply-log.jsonl
    participant Heartbeat as heartbeat.ts

    Agent->>Bridge: POST /send (with thread_ts)
    Bridge->>Slack: chat.postMessage
    Slack-->>Bridge: ok
    Bridge->>Ledger: appendReplyLedgerEntry({channel, thread_ts, route:"/send"})
    Bridge->>Bridge: resolveAckReaction(channel, thread_ts)

    Agent->>Bridge: POST /reply (thread_id)
    Bridge->>Slack: chat.postMessage
    Slack-->>Bridge: ok
    Bridge->>Ledger: appendReplyLedgerEntry({channel, thread_ts, route:"/reply"})
    Bridge->>Bridge: resolveAckReaction(channel, thread_ts)

    Note over Heartbeat: Heartbeat cycle fires
    Heartbeat->>Ledger: readFileSync (Path 1 — primary)
    alt thread_ts found in ledger
        Ledger-->>Heartbeat: matched → hasReplied = true
    else ledger miss / file absent
        Heartbeat->>Heartbeat: scan SESSION_DIR subdirs (Path 2 — fallback)<br/>checks curl /send commands only
    end
Loading

Last reviewed commit: c41b4dd

mode: 0o600,
});
} catch (err) {
console.warn(`⚠️ failed to append reply ledger entry: ${err.message}`);
Copy link

Choose a reason for hiding this comment

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

Inconsistent error serialisation vs broker-bridge.mjs

broker-bridge.mjs uses err instanceof Error ? err.message : String(err) to safely handle non-Error thrown values, but bridge.mjs accesses err.message directly. If a non-Error is ever thrown (e.g. a plain string), err.message will be undefined and the warning will be useless.

Suggested change
console.warn(`⚠️ failed to append reply ledger entry: ${err.message}`);
console.warn(`⚠️ failed to append reply ledger entry: ${err instanceof Error ? err.message : String(err)}`);
Prompt To Fix With AI
This is a comment left during a code review.
Path: gateway-bridge/bridge.mjs
Line: 141

Comment:
**Inconsistent error serialisation vs `broker-bridge.mjs`**

`broker-bridge.mjs` uses `err instanceof Error ? err.message : String(err)` to safely handle non-`Error` thrown values, but `bridge.mjs` accesses `err.message` directly. If a non-Error is ever thrown (e.g. a plain string), `err.message` will be `undefined` and the warning will be useless.

```suggestion
    console.warn(`⚠️ failed to append reply ledger entry: ${err instanceof Error ? err.message : String(err)}`);
```

How can I resolve this? If you propose a fix, please make it concise.

const SOCKET_DIR = path.join(homedir(), ".pi", "session-control");
const AGENT_TIMEOUT_MS = 120_000;
const API_PORT = parseInt(process.env.BRIDGE_API_PORT || "7890", 10);
const REPLY_LEDGER_PATH = path.join(homedir(), ".pi", "agent", "slack-reply-log.jsonl");
Copy link

Choose a reason for hiding this comment

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

Unbounded ledger file growth — no pruning or TTL

slack-reply-log.jsonl is append-only. Every outbound Slack reply adds a new line and nothing ever removes old entries. The heartbeat check in hasRepliedToThread reads the entire file on every invocation, so on an active bot over weeks or months this will cause steadily increasing I/O on each heartbeat cycle.

Consider either:

  • Periodically rewriting the file keeping only entries from the last N days (e.g. 7 days), or
  • Reading the file in reverse / using a line limit so only recent entries are scanned.

This same concern applies to broker-bridge.mjs (same REPLY_LEDGER_PATH, same append-only strategy).

Prompt To Fix With AI
This is a comment left during a code review.
Path: gateway-bridge/bridge.mjs
Line: 50

Comment:
**Unbounded ledger file growth — no pruning or TTL**

`slack-reply-log.jsonl` is append-only. Every outbound Slack reply adds a new line and nothing ever removes old entries. The heartbeat check in `hasRepliedToThread` reads the **entire file** on every invocation, so on an active bot over weeks or months this will cause steadily increasing I/O on each heartbeat cycle.

Consider either:
- Periodically rewriting the file keeping only entries from the last N days (e.g. 7 days), or
- Reading the file in reverse / using a line limit so only recent entries are scanned.

This same concern applies to `broker-bridge.mjs` (same `REPLY_LEDGER_PATH`, same append-only strategy).

How can I resolve this? If you propose a fix, please make it concise.

Comment on lines +458 to 508
for (const sessionDir of sessionDirs) {
let sessionFiles: string[] = [];
try {
const content = readFileSync(join(controlAgentSessionDir, file), "utf-8");
const lines = content.split("\n");

for (const line of lines) {
const trimmed = line.trim();
if (!trimmed) continue;

let parsed: any;
try {
parsed = JSON.parse(trimmed);
} catch {
continue;
}
sessionFiles = readdirSync(sessionDir)
.filter((f) => f.endsWith(".jsonl"))
.sort()
.reverse()
.slice(0, 10);
} catch {
continue;
}

for (const file of sessionFiles) {
try {
const content = readFileSync(join(sessionDir, file), "utf-8");
const lines = content.split("\n");

if (parsed?.type !== "message") continue;
if (parsed?.message?.role !== "assistant") continue;
for (const line of lines) {
const trimmed = line.trim();
if (!trimmed) continue;

const items = parsed?.message?.content;
if (!Array.isArray(items)) continue;
let parsed: any;
try {
parsed = JSON.parse(trimmed);
} catch {
continue;
}

for (const item of items) {
if (item?.type !== "toolCall") continue;
if (item?.name !== "bash") continue;
if (parsed?.type !== "message") continue;
if (parsed?.message?.role !== "assistant") continue;

const command = typeof item?.arguments?.command === "string" ? item.arguments.command : "";
if (!command.includes("curl")) continue;
if (!command.includes("/send")) continue;
if (!threadTsPattern.test(command)) continue;
const items = parsed?.message?.content;
if (!Array.isArray(items)) continue;

return true;
for (const item of items) {
if (item?.type !== "toolCall") continue;
if (item?.name !== "bash") continue;

const command = typeof item?.arguments?.command === "string" ? item.arguments.command : "";
if (!command.includes("curl")) continue;
if (!command.includes("/send")) continue;
if (!threadTsPattern.test(command)) continue;

return true;
}
}
} catch {
// File read error - skip
}
} catch {
// File read error - skip
}
}
Copy link

Choose a reason for hiding this comment

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

Fallback session scan now covers all directories but still only matches /send

The PR expands the fallback (Path 2) from scanning a single --home-baudbot_agent-- directory to scanning every subdirectory of SESSION_DIR, and increases the per-directory file limit from 3 to 10. However, the pattern match still only looks for curl commands that include "/send" (line 498). Replies issued via /reply in older sessions that predate the ledger will remain undetected through this path.

Since the comment explicitly calls this a "fallback for older runs," this is a minor gap, but it's worth noting so future contributors understand the limitation: Path 2 cannot retroactively detect pre-ledger /reply calls.

The expanded scan also has a performance implication on the hot path: if Path 1 misses (e.g. ledger doesn't exist or was rotated), Path 2 now reads up to N_dirs × 10 JSONL session files in full on every heartbeat invocation.

Prompt To Fix With AI
This is a comment left during a code review.
Path: pi/extensions/heartbeat.ts
Line: 458-508

Comment:
**Fallback session scan now covers all directories but still only matches `/send`**

The PR expands the fallback (Path 2) from scanning a single `--home-baudbot_agent--` directory to scanning **every** subdirectory of `SESSION_DIR`, and increases the per-directory file limit from 3 to 10. However, the pattern match still only looks for curl commands that include `"/send"` (line 498). Replies issued via `/reply` in older sessions that predate the ledger will remain undetected through this path.

Since the comment explicitly calls this a "fallback for older runs," this is a minor gap, but it's worth noting so future contributors understand the limitation: Path 2 cannot retroactively detect pre-ledger `/reply` calls.

The expanded scan also has a performance implication on the hot path: if Path 1 misses (e.g. ledger doesn't exist or was rotated), Path 2 now reads up to `N_dirs × 10` JSONL session files in full on every heartbeat invocation.

How can I resolve this? If you propose a fix, please make it concise.

Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

1 participant