Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
19 changes: 18 additions & 1 deletion .github/workflows/ci.yml
Original file line number Diff line number Diff line change
Expand Up @@ -37,6 +37,20 @@ jobs:
- name: Build
run: npm run build

# devkitd powers the daemon-client integration tests; without a
# binary they silently skip and the daemon path gets no CI coverage
# (that's how #371 shipped).
- name: Setup Rust toolchain
uses: dtolnay/rust-toolchain@stable

- name: Cache cargo artifacts
uses: Swatinem/rust-cache@v2
with:
workspaces: rust

- name: Build devkitd for daemon-client integration tests
run: cargo build -p devkitd --manifest-path rust/Cargo.toml

- name: Test
run: npm run test

Expand Down Expand Up @@ -65,4 +79,7 @@ jobs:
run: cargo fmt --all -- --check

- name: Clippy
run: cargo clippy --workspace --all-targets -- -D warnings
run: cargo clippy --workspace --all-targets -- -D warnings

- name: Test
run: cargo test --workspace
26 changes: 21 additions & 5 deletions packages/agent-manager/src/AgentManager.ts
Original file line number Diff line number Diff line change
Expand Up @@ -61,10 +61,12 @@ export interface AgentManagerOptions {
onRuntimeDiscoveryError?: (error: unknown) => void;
/**
* Daemon-side enriched agents ({@link DaemonClient.listAgents} wire
* shape: `AgentInfo` with `lastActive` as an ISO string). A non-null
* shape: `AgentInfo` with `lastActive` as an ISO string). A non-empty
* result is authoritative for every harness type — all local adapters
* are skipped. Returning null or throwing forces the local path;
* omitting it disables daemon consumption.
* are skipped. An empty result is cross-checked against local detection
* (local wins on disagreement, with a warning). Returning null or
* throwing forces the local path; omitting it disables daemon
* consumption.
*/
fetchEnrichedAgents?: () => Promise<Array<
Omit<AgentInfo, "lastActive" | "type" | "status"> & {
Expand Down Expand Up @@ -190,9 +192,14 @@ export class AgentManager {
const allAgents: AgentInfo[] = [];
const errors: Array<{ type: string; error: Error }> = [];

// A non-null daemon answer is authoritative for every harness type, so
// local adapters only run when the daemon is absent.
// A non-empty daemon answer is authoritative for every harness type, so
// local adapters only run when the daemon is absent — or when it
// returns an empty list. An empty answer must not be authoritative
// (issue #371): a daemon-side discovery bug would otherwise mask every
// running agent forever. When local adapters disagree with the empty
// answer the local result wins, with a warning.
let daemonAgents: AgentInfo[] | null = null;
let daemonAnsweredEmpty = false;
if (this.options.fetchEnrichedAgents) {
try {
const enriched = await this.options.fetchEnrichedAgents();
Expand All @@ -203,6 +210,8 @@ export class AgentManager {
status: a.status as AgentStatus,
lastActive: new Date(a.lastActive),
}));
daemonAnsweredEmpty = daemonAgents.length === 0;
if (daemonAnsweredEmpty) daemonAgents = null;
}
} catch {
daemonAgents = null;
Expand Down Expand Up @@ -264,6 +273,13 @@ export class AgentManager {
}
}

if (daemonAnsweredEmpty && allAgents.length > 0) {
console.error(
`Warning: devkitd reported no agents but local detection found ${allAgents.length} — ` +
"the daemon's process sweep may be broken; showing local results.",
);
}

// Log errors if any (but don't throw - partial results are useful)
if (errors.length > 0) {
console.error(`Warning: ${errors.length} adapter(s) failed:`);
Expand Down
33 changes: 29 additions & 4 deletions packages/agent-manager/src/__tests__/AgentManager.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -548,17 +548,42 @@ describe("AgentManager", () => {
expect(agents.map((a) => a.name).sort()).toEqual(["daemon-claude", "daemon-codex"]);
});

it("treats an empty daemon answer as authoritative", async () => {
it("falls back to local adapters when the daemon returns an empty list and local finds agents", async () => {
const local = new MockAdapter("claude", [createMockAgent({ name: "local-claude" })]);
const detectSpy = vi.spyOn(local, "detectAgents");
const warnSpy = vi.spyOn(console, "error").mockImplementation(() => {});
const m = new AgentManager(new AgentRegistry(path.join(tmpDir, "agents.json")), undefined, {
fetchEnrichedAgents: async () => [],
});
m.registerAdapter(local);

const agents = await m.listAgents();
expect(detectSpy).not.toHaveBeenCalled();
expect(agents).toEqual([]);
try {
const agents = await m.listAgents();
expect(detectSpy).toHaveBeenCalled();
expect(agents.map((a) => a.name)).toEqual(["local-claude"]);
expect(warnSpy).toHaveBeenCalledWith(expect.stringContaining("daemon"));
} finally {
warnSpy.mockRestore();
}
});

it("returns empty without a warning when the daemon and local adapters are both empty", async () => {
const local = new MockAdapter("claude");
const detectSpy = vi.spyOn(local, "detectAgents");
const warnSpy = vi.spyOn(console, "error").mockImplementation(() => {});
const m = new AgentManager(new AgentRegistry(path.join(tmpDir, "agents.json")), undefined, {
fetchEnrichedAgents: async () => [],
});
m.registerAdapter(local);

try {
const agents = await m.listAgents();
expect(detectSpy).toHaveBeenCalled();
expect(agents).toEqual([]);
expect(warnSpy).not.toHaveBeenCalled();
} finally {
warnSpy.mockRestore();
}
});
});

Expand Down
81 changes: 81 additions & 0 deletions packages/daemon-client/src/__tests__/daemon-sweep.test.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,81 @@
import { describe, it, expect, beforeAll, afterAll } from "vitest";
import { spawn, type ChildProcess } from "node:child_process";
import * as fs from "node:fs";
import * as os from "node:os";
import * as path from "node:path";
import { DaemonClient } from "../client.js";

const repoRoot = path.resolve(path.dirname(new URL(import.meta.url).pathname), "../../../..");
const devBin = [
path.join(repoRoot, "rust/target/release/devkitd"),
path.join(repoRoot, "rust/target/debug/devkitd"),
].find((p) => fs.existsSync(p));
const bin = process.env.DEVKITD_BIN ?? process.env.AI_DEVKITD_BIN ?? devBin;

/**
* Issue #371 regression coverage at the daemon boundary: a stub `ps` on
* PATH feeds devkitd synthetic procps output with PADDED columns — the
* exact shape that used to make every row unparseable, leaving
* `agent.list` permanently empty. Skipped when no devkitd binary exists
* (CI builds it in the test job).
*/
describe.runIf(bin != null && process.platform !== "win32")("devkitd sweep with stub ps", () => {
let home: string;
let stubDir: string;
let sock: string;
let daemon: ChildProcess | null = null;

const PADDED_PS = " 424242 1 pts/1 claude\n 424243 424242 pts/2 gemini\n";

beforeAll(async () => {
home = fs.mkdtempSync(path.join(os.tmpdir(), "devkitd-ps-e2e-"));
stubDir = path.join(home, "stub-bin");
fs.mkdirSync(stubDir);
const stub = path.join(stubDir, "ps");
fs.writeFileSync(stub, `#!/bin/sh\nprintf '%s' '${PADDED_PS}'\n`);
fs.chmodSync(stub, 0o755);

sock = path.join(home, ".ai-devkit", "daemon.sock");
daemon = spawn(bin!, ["serve"], {
env: {
...process.env,
HOME: home,
PATH: `${stubDir}${path.delimiter}${process.env.PATH}`,
RUST_LOG: "warn",
},
stdio: "ignore",
});
daemon.unref();

const deadline = Date.now() + 10_000;
while (!fs.existsSync(sock)) {
if (daemon.exitCode !== null) throw new Error("devkitd exited before binding its socket");
if (Date.now() > deadline) throw new Error("timed out waiting for daemon socket");
await new Promise((r) => setTimeout(r, 50));
}
}, 15_000);

afterAll(async () => {
if (daemon) {
const c = await DaemonClient.tryConnect(sock).catch(() => null);
if (c) {
await c.request("shutdown").catch(() => {});
c.close();
}
daemon.kill("SIGKILL");
}
fs.rmSync(home, { recursive: true, force: true });
});

it("agent.list reports agents from padded procps rows", async () => {
const client = await DaemonClient.connect(sock);
try {
const agents = await client.listAgents();
const pids = agents.map((a) => a.pid);
expect(pids).toContain(424242);
expect(agents.find((a) => a.pid === 424242)?.type).toBe("claude");
} finally {
client.close();
}
}, 15_000);
});
1 change: 1 addition & 0 deletions rust/Cargo.lock

Some generated files are not rendered by default. Learn more about how customized files appear on GitHub.

1 change: 1 addition & 0 deletions rust/crates/devkit-core/Cargo.toml
Original file line number Diff line number Diff line change
Expand Up @@ -10,4 +10,5 @@ serde_json.workspace = true
rusqlite.workspace = true
anyhow.workspace = true
libc.workspace = true
tracing.workspace = true
ts-rs = "12"
57 changes: 52 additions & 5 deletions rust/crates/devkit-core/src/discover.rs
Original file line number Diff line number Diff line change
Expand Up @@ -102,6 +102,12 @@ pub fn sweep() -> Option<Vec<AgentProc>> {
}
let text = String::from_utf8_lossy(&out.stdout);
let mut procs = parse_ps_output(&text, true);
if procs.is_empty() && !text.trim().is_empty() {
// A non-empty process table that yields zero rows means the parser
// regressed against this platform's ps format (see #371) — surface
// it instead of silently reporting an empty agent list forever.
tracing::warn!("sweep parsed 0 processes from non-empty ps output");
}
procs.sort_by_key(|p| p.pid);
Some(procs)
}
Expand All @@ -112,11 +118,11 @@ pub fn sweep() -> Option<Vec<AgentProc>> {
fn parse_ps_output(text: &str, resolve_cwd: bool) -> Vec<AgentProc> {
text.lines()
.filter_map(|line| {
let mut it = line.trim().splitn(4, char::is_whitespace);
let pid: i64 = it.next()?.parse().ok()?;
let ppid: i64 = it.next()?.parse().ok()?;
let tty = it.next().map(|s| s.to_string());
let command = it.next().unwrap_or("").trim().to_string();
let (pid, ppid, tty, command) = split_ps_row(line)?;
let pid: i64 = pid.parse().ok()?;
let ppid: i64 = ppid.parse().ok()?;
let tty = Some(tty.to_string());
let command = command.trim().to_string();
if !is_agent_command(&command) {
return None;
}
Expand All @@ -133,6 +139,22 @@ fn parse_ps_output(text: &str, resolve_cwd: bool) -> Vec<AgentProc> {
.collect()
}

/// Split a `ps` row into pid/ppid/tty plus the raw command remainder.
/// procps pads columns with runs of spaces, so fields must be split on
/// whitespace RUNS — a per-char split yields empty fields that fail to
/// parse and drops the row (issue #371). The command keeps its original
/// inner spacing.
fn split_ps_row(line: &str) -> Option<(&str, &str, &str, &str)> {
let mut rest = line.trim_start();
let mut fields = ["", "", ""];
for f in &mut fields {
let end = rest.find(char::is_whitespace)?;
*f = &rest[..end];
rest = rest[end..].trim_start();
}
Some((fields[0], fields[1], fields[2], rest))
}

fn cwd_of(pid: i64) -> Option<String> {
// /proc readlink — one syscall, no lsof/pwdx spawn.
std::fs::read_link(format!("/proc/{pid}/cwd"))
Expand Down Expand Up @@ -306,6 +328,31 @@ mod tests {
// Bare name (pi) and full path (claude) both match via basename.
}

#[test]
fn issue_371_padded_column_rows_all_parse() {
// The exact matrix from codeaholicguy/ai-devkit#371: Linux procps
// right-aligns pid/ppid and pads columns with runs of spaces, so a
// per-char whitespace split produces empty fields and drops the row.
for line in [
"61524 60968 pts/1 claude",
" 61524 60968 pts/1 claude",
"61524 60968 pts/1 claude",
"61524 60968 pts/1 claude",
" 61524 60968 pts/1 claude",
] {
assert_eq!(parse_ps_output(line, false).len(), 1, "row {line:?}");
}
}

#[test]
fn command_field_keeps_inner_spacing() {
let procs = parse_ps_output(" 61524 60968 pts/1 claude --resume abc123", false);
assert_eq!(
procs[0].command.as_deref(),
Some("claude --resume abc123")
);
}

#[test]
fn malformed_lines_are_skipped() {
let procs = parse_ps_output("garbage\n abc x ? y\n", false);
Expand Down
Loading