feat(daemon): capture stdout+stderr to a log file, add daemon logs - #424
Conversation
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
📝 WalkthroughWalkthroughThe daemon now writes detached-process output to a session log. The CLI adds ChangesDaemon Logging
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
Possibly related PRs
🚥 Pre-merge checks | ✅ 5✅ Passed checks (5 passed)
✨ Finishing Touches📝 Generate docstrings
🧪 Generate unit tests (beta)
Comment |
There was a problem hiding this comment.
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
📒 Files selected for processing (3)
src/cli/daemon.rssrc/daemon.rssrc/ipc/process.rs
| // 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(); |
There was a problem hiding this comment.
🎯 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.rsRepository: 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",
})
PYRepository: 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,
})
PYRepository: 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,
})
PYRepository: 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.
| 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")) |
There was a problem hiding this comment.
🔒 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.tomlRepository: 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.
Agentflare-Agent: claude-code Agentflare-Branch: daemon-observability
Agentflare-Agent: claude-code Agentflare-Branch: daemon-observability
Summary
spawn_detachedsent the daemon's stdout+stderr to/dev/null, so every diagnosticeprintln!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-runningagentflare serve --_foreground-daemonin 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_detachednow takes an optional log path and redirects both streams to one shared file handle (two independentFile::creates on the same path would each get their own write cursor and clobber each other instead of interleaving — same reason a shell needs2>&1rather than two separate redirects).daemon_log_path()—$XDG_RUNTIME_DIR/agentflare/daemon.log, truncated fresh on everystart/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— cleancargo clippy/cargo fmt --checkclean on all three touched filesagentflare daemon logsshows live bridge activity that was previously only visible by manually running the daemon in the foregroundSummary by CodeRabbit
New Features
daemon logscommand to view the daemon’s current log output.--followor-f) that continuously displays new log entries.Bug Fixes