Skip to content

feat(daemon): capture stdout+stderr to a log file, add daemon logs - #424

Merged
getappz merged 7 commits into
masterfrom
daemon-observability
Aug 10, 2026
Merged

feat(daemon): capture stdout+stderr to a log file, add daemon logs#424
getappz merged 7 commits into
masterfrom
daemon-observability

Conversation

@getappz

@getappz getappz commented Aug 10, 2026

Copy link
Copy Markdown
Owner

Summary

spawn_detached sent the daemon's stdout+stderr to /dev/null, so every diagnostic eprintln! in the GitHub bridge — including the two added in #423 for the claimed-but-never-dispatched and blocked-claim cases — was invisible for the actual detached daemon. The only way to see them was manually re-running agentflare serve --_foreground-daemon in an attached terminal, which is literally how the silent GitHub-export gap earlier today got diagnosed (a bridge-claimed issue sat with no visible reason why nothing was happening).

  • spawn_detached now takes an optional log path and redirects both streams to one shared file handle (two independent File::creates on the same path would each get their own write cursor and clobber each other instead of interleaving — same reason a shell needs 2>&1 rather than two separate redirects).
  • daemon_log_path()$XDG_RUNTIME_DIR/agentflare/daemon.log, truncated fresh on every start/restart (same lifetime as the pid file next to it — this is "what is the current daemon session doing," not a rotated audit trail).
  • agentflare daemon logs [--follow] prints it.

Test plan

  • cargo build --bin agentflare — clean
  • cargo clippy / cargo fmt --check clean on all three touched files
  • Manual: restarted the real daemon, confirmed agentflare daemon logs shows live bridge activity that was previously only visible by manually running the daemon in the foreground

Summary by CodeRabbit

  • New Features

    • Added a daemon logs command to view the daemon’s current log output.
    • Added a follow mode (--follow or -f) that continuously displays new log entries.
    • Daemon output is now captured in a session log for later review.
  • Bug Fixes

    • Improved error reporting when daemon logs cannot be opened or process output cannot be captured.

spawn_detached sent both to /dev/null, so every diagnostic eprintln!
in the bridge (including the two just added for the claimed-but-
never-dispatched and blocked-claim cases) was invisible for the
actual detached daemon -- only visible by manually re-running
`agentflare serve --_foreground-daemon` in an attached terminal,
which is how the silent GitHub-export gap earlier this session
actually got diagnosed.

Redirects both streams to a single file (one shared handle, not two
independent opens -- two `File::create`s on the same path would each
get their own write cursor and clobber each other instead of
interleaving) at $XDG_RUNTIME_DIR/agentflare/daemon.log, truncated
fresh on every start/restart. `agentflare daemon logs [--follow]`
prints it.

Agentflare-Agent: claude-code
Agentflare-Branch: daemon-observability
@coderabbitai

coderabbitai Bot commented Aug 10, 2026

Copy link
Copy Markdown

Review Change Stack

📝 Walkthrough

Walkthrough

The daemon now writes detached-process output to a session log. The CLI adds daemon logs with optional follow mode. Log paths resolve under the runtime directory, with temporary storage as fallback.

Changes

Daemon Logging

Layer / File(s) Summary
Resolve and prepare the daemon log
src/daemon.rs
daemon_log_path() resolves daemon.log. start_daemon() creates the parent directory and passes the path to spawn_detached.
Capture detached daemon output
src/ipc/process.rs
spawn_detached optionally creates one shared log file for stdout and stderr. Windows and non-Windows launches use the selected streams.
Read and follow daemon logs
src/cli/daemon.rs
The Logs subcommand prints existing log content and polls for appended output when --follow or -f is enabled.

Estimated code review effort: 3 (Moderate) | ~20 minutes

Sequence Diagram(s)

sequenceDiagram
  participant daemon_cli
  participant start_daemon
  participant spawn_detached
  participant daemon_log
  participant detached_daemon

  daemon_cli->>start_daemon: start daemon
  start_daemon->>spawn_detached: pass daemon log path
  spawn_detached->>daemon_log: create or truncate log
  spawn_detached->>detached_daemon: assign stdout and stderr
  detached_daemon->>daemon_log: write process output
  daemon_cli->>daemon_log: read or follow log
Loading

Possibly related PRs

  • getappz/agentflare#271: Introduced the daemon CLI, lifecycle logging, and spawn_detached APIs extended by this PR.
🚥 Pre-merge checks | ✅ 5
✅ Passed checks (5 passed)
Check name Status Explanation
Title check ✅ Passed The title clearly summarizes the main changes: daemon log capture and the new daemon logs command.
Description check ✅ Passed The description provides a detailed summary and test plan; the optional reviewer notes section is omitted.
Docstring Coverage ✅ Passed No functions found in the changed files to evaluate docstring coverage. Skipping docstring coverage check.
Linked Issues check ✅ Passed Check skipped because no linked issues were found for this pull request.
Out of Scope Changes check ✅ Passed Check skipped because no linked issues were found for this pull request.
✨ Finishing Touches
📝 Generate docstrings
  • Create stacked PR
  • Commit on current branch
🧪 Generate unit tests (beta)
  • Create PR with unit tests
  • Commit unit tests in branch daemon-observability

Comment @coderabbitai help to get the list of available commands.

@coderabbitai coderabbitai Bot left a comment

Copy link
Copy Markdown

Choose a reason for hiding this comment

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

Actionable comments posted: 2

🤖 Prompt for all review comments with AI agents
Verify each finding against current code. Fix only still-valid issues, skip the
rest with a brief reason, keep changes minimal, and validate.

Inline comments:
In `@src/cli/daemon.rs`:
- Around line 98-108: Update the follow loop around the file read to detect
truncation before calling read_to_string: compare file.stream_position() with
file.metadata()?.len(), and seek to position zero when the current offset
exceeds the file length. Preserve the existing polling, chunk printing, and
stdout flushing behavior, while propagating or handling I/O errors consistently
with the surrounding daemon code.

In `@src/daemon.rs`:
- Around line 17-20: Update daemon_log_path to avoid the predictable shared
temporary fallback when dirs::runtime_dir() returns None. Use a per-user private
directory with restrictive permissions for the fallback, or ensure the log file
creation path uses symlink-safe semantics while preserving the existing
runtime-directory location.
🪄 Autofix

Fix all unresolved CodeRabbit comments on this PR:

  • Push a commit to this branch (recommended)
  • Create a new PR with the fixes

ℹ️ Review info
⚙️ Run configuration

Configuration used: Path: .coderabbit.yaml

Review profile: CHILL

Plan: Pro

Run ID: a10c0455-926c-4ec2-9572-5901e1ec1628

📥 Commits

Reviewing files that changed from the base of the PR and between 5a2fcab and 15ae0bd.

📒 Files selected for processing (3)
  • src/cli/daemon.rs
  • src/daemon.rs
  • src/ipc/process.rs

Comment thread src/cli/daemon.rs
Comment on lines +98 to +108
// Simplest portable tail -f: re-read from the current position on the
// same handle, which reflects append-mode writes made by a different
// process to the same file. Not robust to the daemon restarting mid-tail
// (a fresh log truncates the same path) -- good enough for `-f` used
// interactively, same tradeoff `agentflare work`'s own log tailing makes.
loop {
std::thread::sleep(std::time::Duration::from_millis(500));
let mut chunk = String::new();
if file.read_to_string(&mut chunk).is_ok() && !chunk.is_empty() {
print!("{chunk}");
let _ = std::io::stdout().flush();

Copy link
Copy Markdown

Choose a reason for hiding this comment

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

🎯 Functional Correctness | 🟠 Major | ⚡ Quick win

🧩 Analysis chain

🏁 Script executed:

#!/bin/bash
set -u
printf '%s\n' '--- available code-intelligence and memory tools ---'
command -v ctx_search || true
command -v ctx_callgraph || true
command -v ctx_compose || true
command -v ctx_read || true
command -v ctx_shell || true
command -v agentflare || true
if command -v agentflare >/dev/null 2>&1; then
  agentflare memory context 2>/dev/null || true
fi
printf '%s\n' '--- tracked candidate files ---'
git ls-files 'src/cli/daemon.rs' 'src/ipc/process.rs'
printf '%s\n' '--- relevant source ---'
sed -n '1,150p' src/cli/daemon.rs
sed -n '1,90p' src/ipc/process.rs

Repository: getappz/agentflare

Length of output: 7033


🏁 Script executed:

#!/bin/bash
set -u
printf '%s\n' '--- daemon log path and startup call sites ---'
rg -n -C 5 'daemon_log_path|spawn_detached|File::create|cmd_logs' src
printf '%s\n' '--- standalone cursor/truncation verifier ---'
python3 - <<'PY'
class FileModel:
    def __init__(self, data=b"old log\n"):
        self.data = bytearray(data)
        self.position = 0

    def read_to_end(self):
        chunk = bytes(self.data[self.position:])
        self.position = len(self.data)
        return chunk

    def truncate_from_other_handle(self):
        self.data.clear()

    def append_from_other_handle(self, data):
        self.data.extend(data)

    def reset_if_shrunk(self):
        if self.position > len(self.data):
            self.position = 0

f = FileModel()
initial = f.read_to_end()
old_position = f.position
f.truncate_from_other_handle()
without_reset = f.read_to_end()
f.append_from_other_handle(b"new log\n")
after_growth = f.read_to_end()

g = FileModel()
g.read_to_end()
g.truncate_from_other_handle()
g.reset_if_shrunk()
with_reset = g.read_to_end()
g.append_from_other_handle(b"new log\n")
with_reset_after_growth = g.read_to_end()

print({
    "initial": initial.decode(),
    "position_after_initial_read": old_position,
    "without_reset_after_truncate": without_reset.decode(),
    "without_reset_after_growth": after_growth.decode(),
    "with_reset_after_truncate": with_reset.decode(),
    "with_reset_after_growth": with_reset_after_growth.decode(),
    "skips_new_prefix_without_reset": after_growth != b"new log\n",
    "captures_new_log_with_reset": with_reset_after_growth == b"new log\n",
})
PY

Repository: getappz/agentflare

Length of output: 6023


🏁 Script executed:

#!/bin/bash
set -u
printf '%s\n' '--- daemon start/restart path ---'
sed -n '112,158p' src/daemon.rs
printf '%s\n' '--- corrected standalone cursor/truncation verifier ---'
python3 - <<'PY'
class Follower:
    def __init__(self, initial):
        self.data = bytearray(initial)
        self.position = 0

    def initial_read(self):
        out = bytes(self.data[self.position:])
        self.position = len(self.data)
        return out

    def restart_truncate(self):
        self.data.clear()

    def append(self, data):
        self.data.extend(data)

    def current_read(self):
        out = bytes(self.data[self.position:])
        self.position = len(self.data)
        return out

    def reset_if_shrunk(self):
        if self.position > len(self.data):
            self.position = 0

old_log = b"old daemon output\n" * 3  # 54 bytes
new_log = b"new daemon output\n"            # shorter than old_log

current = Follower(old_log)
current.initial_read()
old_position = current.position
current.restart_truncate()
first_new_write = new_log[:8]
current.append(first_new_write)
without_reset_first = current.current_read()
current.append(new_log[8:])
without_reset_second = current.current_read()

fixed = Follower(old_log)
fixed.initial_read()
fixed.restart_truncate()
fixed.reset_if_shrunk()
with_reset_first = fixed.current_read()
fixed.append(first_new_write)
with_reset_second = fixed.current_read()
fixed.append(new_log[8:])
with_reset_third = fixed.current_read()

print({
    "old_log_length": len(old_log),
    "old_cursor": old_position,
    "new_log_length": len(new_log),
    "current_first_read_after_restart": without_reset_first,
    "current_second_read_after_restart": without_reset_second,
    "fixed_read_before_new_write": with_reset_first,
    "fixed_first_new_chunk": with_reset_second,
    "fixed_second_new_chunk": with_reset_third,
    "current_misses_initial_new_bytes": without_reset_first == b"",
    "current_later_read_is_empty_until_file_exceeds_old_cursor":
        without_reset_second == b"",
    "fixed_captures_new_bytes": with_reset_second + with_reset_third == new_log,
})
PY

Repository: getappz/agentflare

Length of output: 2415


🏁 Script executed:

#!/bin/bash
set -u
python3 - <<'PY'
class Follower:
    def __init__(self, initial):
        self.data = bytearray(initial)
        self.position = 0

    def read_to_end(self):
        if self.position >= len(self.data):
            return b""
        out = bytes(self.data[self.position:])
        self.position += len(out)
        return out

    def restart_truncate(self):
        self.data.clear()

    def append(self, data):
        self.data.extend(data)

    def reset_if_shrunk(self):
        if self.position > len(self.data):
            self.position = 0

old_log = b"old daemon output\n" * 3
new_log = b"new daemon output\n"

current = Follower(old_log)
initial = current.read_to_end()
old_cursor = current.position
current.restart_truncate()
current.append(new_log[:8])
current_first = current.read_to_end()
current.append(new_log[8:])
current_second = current.read_to_end()
current.append(b"x" * (old_cursor - len(current.data) + 1))
current_third = current.read_to_end()

fixed = Follower(old_log)
fixed.read_to_end()
fixed.restart_truncate()
fixed.reset_if_shrunk()
fixed_before_write = fixed.read_to_end()
fixed.append(new_log[:8])
fixed_first = fixed.read_to_end()
fixed.append(new_log[8:])
fixed_second = fixed.read_to_end()

print({
    "initial_length": len(initial),
    "old_cursor": old_cursor,
    "new_length_before_crossing_old_cursor": len(new_log),
    "current_reads_before_crossing_old_cursor": [
        current_first, current_second
    ],
    "current_read_after_crossing_old_cursor": current_third,
    "fixed_reads": [fixed_before_write, fixed_first, fixed_second],
    "current_misses_new_log_prefix": current_first + current_second == b"",
    "current_reads_only_after_old_cursor": current_third != b"",
    "fixed_captures_new_log": fixed_first + fixed_second == new_log,
})
PY

Repository: getappz/agentflare

Length of output: 513


Reset the cursor after log truncation.

A daemon restart truncates daemon.log through File::create. The open follower handle keeps its previous offset. If the new log is shorter than that offset, --follow misses new output until the file grows beyond the old offset.

Before each follow read, compare file.stream_position() with file.metadata()?.len(). If the file shrank, seek to offset zero before reading.

🤖 Prompt for AI Agents
Verify each finding against current code. Fix only still-valid issues, skip the
rest with a brief reason, keep changes minimal, and validate.

In `@src/cli/daemon.rs` around lines 98 - 108, Update the follow loop around the
file read to detect truncation before calling read_to_string: compare
file.stream_position() with file.metadata()?.len(), and seek to position zero
when the current offset exceeds the file length. Preserve the existing polling,
chunk printing, and stdout flushing behavior, while propagating or handling I/O
errors consistently with the surrounding daemon code.

Comment thread src/daemon.rs
Comment on lines +17 to +20
pub fn daemon_log_path() -> PathBuf {
dirs::runtime_dir()
.map(|d| d.join("agentflare").join("daemon.log"))
.unwrap_or_else(|| std::env::temp_dir().join("agentflare-daemon.log"))

Copy link
Copy Markdown

Choose a reason for hiding this comment

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

🔒 Security & Privacy | 🟠 Major | 🏗️ Heavy lift

🧩 Analysis chain

🏁 Script executed:

#!/bin/bash
set -eu

printf '%s\n' '--- candidate files ---'
git ls-files 'src/daemon.rs' 'src/ipc/process.rs' 'Cargo.toml' 'src/*' | sed -n '1,120p'

printf '%s\n' '--- daemon.rs outline ---'
if command -v ast-grep >/dev/null 2>&1; then
  ast-grep outline src/daemon.rs
else
  echo 'ast-grep unavailable'
fi

printf '%s\n' '--- daemon.rs relevant lines ---'
nl -ba src/daemon.rs | sed -n '1,100p'

printf '%s\n' '--- process.rs relevant symbols and lines ---'
if command -v ast-grep >/dev/null 2>&1; then
  ast-grep outline src/ipc/process.rs
else
  echo 'ast-grep unavailable'
fi
nl -ba src/ipc/process.rs | sed -n '1,180p'

printf '%s\n' '--- log-path and file-creation references ---'
rg -n -C 4 'daemon_log_path|File::create|daemon\.log|agentflare-daemon\.log|cmd_logs|runtime_dir|temp_dir' src Cargo.toml

Repository: getappz/agentflare

Length of output: 36248


🏁 Script executed:

#!/bin/bash
set -eu

python3 - <<'PY'
from pathlib import Path

for name in ("src/daemon.rs", "src/ipc/process.rs"):
    path = Path(name)
    print(f"--- {name}: {path.stat().st_size} bytes ---")
    text = path.read_text()
    for i, line in enumerate(text.splitlines(), 1):
        if any(token in line for token in (
            "daemon_log_path", "File::create", "OpenOptions", "cmd_logs",
            "Command", "runtime_dir", "temp_dir", "logs",
        )):
            start = max(1, i - 8)
            end = min(len(text.splitlines()), i + 12)
            lines = text.splitlines()
            print(f"[lines {start}-{end}]")
            for j in range(start, end + 1):
                print(f"{j:4}: {lines[j-1]}")
PY

printf '%s\n' '--- callers of daemon_log_path ---'
rg -n -C 6 'daemon_log_path\s*\(' .

Repository: getappz/agentflare

Length of output: 24592


🏁 Script executed:

#!/bin/bash
set -eu

printf '%s\n' '--- all relevant source references ---'
rg -n -C 8 'daemon_log_path|File::create|OpenOptions|create_new|symlink|cmd_logs|runtime_dir|temp_dir' --glob '*.rs' .

printf '%s\n' '--- daemon and logs command definitions ---'
rg -n -C 12 'fn (daemon|logs|cmd_logs)|struct .*Args|enum .*Command|logs' src --glob '*.rs'

printf '%s\n' '--- dependency and platform context ---'
rg -n -C 3 '^\[dependencies\]|^dirs\s*=|^tempfile\s*=|^rustix\s*=|^nix\s*=|^target|cfg\(unix\)|cfg\(windows\)' Cargo.toml Cargo.lock src --glob '*.rs' --glob '*.toml'

Repository: getappz/agentflare

Length of output: 50375


Use a private fallback log location.

When dirs::runtime_dir() returns None, daemon_log_path() uses a predictable file in the shared temporary directory. File::create follows a pre-created symlink and truncates its target.

Use a per-user directory with restrictive permissions, or create the log file with symlink-safe semantics.

🤖 Prompt for AI Agents
Verify each finding against current code. Fix only still-valid issues, skip the
rest with a brief reason, keep changes minimal, and validate.

In `@src/daemon.rs` around lines 17 - 20, Update daemon_log_path to avoid the
predictable shared temporary fallback when dirs::runtime_dir() returns None. Use
a per-user private directory with restrictive permissions for the fallback, or
ensure the log file creation path uses symlink-safe semantics while preserving
the existing runtime-directory location.

shiva added 2 commits August 10, 2026 15:12
Agentflare-Agent: claude-code
Agentflare-Branch: daemon-observability
Agentflare-Agent: claude-code
Agentflare-Branch: daemon-observability
@getappz
getappz enabled auto-merge (squash) August 10, 2026 09:50
@getappz
getappz merged commit 2fc436d into master Aug 10, 2026
16 checks passed
@getappz
getappz deleted the daemon-observability branch August 10, 2026 11:13
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