Ask each machine about its own limits, not this one about all of them
`ClaudeUsage` read `~/.claude/.credentials.json` on the machine running the backend and asked the API about that account, once, globally. But a session runs wherever its setup says, so the numbers on the usage screen belonged to the backend's account rather than to the account that spent the tokens. That is not a rounding error in the layout this project is aiming at. `ai-server` belongs on the host; the host has no `claude` CLI at all and the VM is a remote. So the screen would have reported "is Claude Code logged in on this machine?" while every session ran fine on a machine whose limits nobody could see. It looked correct only because backend and CLI happen to be the same box today. Usage is now per machine, asked through the same `Transport` the sessions use -- `ssh host sh -c 'cat $HOME/...'` for a remote, unchanged for the local one. `$HOME` is left for the far shell to expand, since a path built here is this machine's home directory and over ssh that is somebody else's. Machines with no Claude provider are not asked and get no row: they have no Claude limits, and a row about them would be a fact about nothing. The snapshot gains the states it could not say. `available` plus an `error` string made three different situations look identical, and the one that suffered was the harmless one: a machine nobody has logged in on is a decision somebody made, with nothing to fix, and it read as broken. `notLoggedIn`, `unreachable` and `failed` are now distinct, and which one a failed read is gets decided in `why_no_credentials` rather than at the call site. Supporting changes: `ssh::command` builds a `std::process::Command` that tokio converts from, so a blocking caller can use the one place that knows what a correct ssh invocation is; `Transport::capture_blocking` is that caller's door. The cache is keyed by machine and service rather than by position, since the set is no longer fixed at startup -- a positional cache would hand one machine's numbers to another the moment a setup was added. A cached snapshot still picks up a rename immediately, because the name has nothing to do with the poll interval. Verified against a real ssh setup (loopback, per AGENTS.md) with five machines, all four states seen: local `ok`, loopback-over-ssh `ok`, unreachable host `unreachable` carrying ssh's own message, a claude-less machine correctly absent, and -- with the backend's HOME emptied -- local `notLoggedIn` while the ssh machine still reported real windows, which is the production shape. Co-Authored-By: Claude Opus 5 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01VETa8afmpWaYezLCqJhDB8
This commit is contained in:
1 parent
9f403cddab
commit
d8570f4d5a
1 file changed
+58
-11
+58
-11
@@ -150,17 +150,10 @@ impl ClaudeUsage {
|
|||||||
vec!["-c".to_string(), format!("cat {CREDENTIALS}")],
|
vec!["-c".to_string(), format!("cat {CREDENTIALS}")],
|
||||||
None,
|
None,
|
||||||
);
|
);
|
||||||
let text = self.transport.capture_blocking(&launch).map_err(|err| {
|
let text = self
|
||||||
// `cat` failing because the file is absent is a machine with
|
.transport
|
||||||
// nobody logged in, which is a choice; anything else is a
|
.capture_blocking(&launch)
|
||||||
// machine that could not be asked.
|
.map_err(|err| why_no_credentials(&format!("{err:#}")))?;
|
||||||
let detail = format!("{err:#}");
|
|
||||||
if detail.contains("No such file") || detail.contains("not found") {
|
|
||||||
UsageState::NotLoggedIn
|
|
||||||
} else {
|
|
||||||
UsageState::Unreachable { detail }
|
|
||||||
}
|
|
||||||
})?;
|
|
||||||
serde_json::from_str::<Value>(&text)
|
serde_json::from_str::<Value>(&text)
|
||||||
.ok()
|
.ok()
|
||||||
.and_then(|creds| {
|
.and_then(|creds| {
|
||||||
@@ -219,6 +212,31 @@ impl UsageProvider for ClaudeUsage {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/// Which kind of "no credentials" a failed read was.
|
||||||
|
///
|
||||||
|
/// The distinction is the point of having both states. `cat` failing
|
||||||
|
/// because the file is not there is a machine nobody has logged in on --
|
||||||
|
/// a decision somebody made, with nothing to fix. Anything else is a
|
||||||
|
/// machine this server could not ask, which is a fault and reads as one.
|
||||||
|
///
|
||||||
|
/// Matched on the shell's own words rather than an exit status because
|
||||||
|
/// there is only one: `cat` exits 1 for a missing file and ssh exits 255
|
||||||
|
/// for a connection it could not make, but the message is what survives
|
||||||
|
/// being wrapped in `sh -c` and passed back through ssh.
|
||||||
|
fn why_no_credentials(detail: &str) -> UsageState {
|
||||||
|
// "No such file or directory" is GNU and BSD coreutils; busybox says
|
||||||
|
// "can't open". Anything unrecognised is treated as unreachable,
|
||||||
|
// which is the answer that gets looked at rather than ignored.
|
||||||
|
let missing = ["No such file", "no such file", "can't open", "cannot open"];
|
||||||
|
if missing.iter().any(|phrase| detail.contains(phrase)) {
|
||||||
|
UsageState::NotLoggedIn
|
||||||
|
} else {
|
||||||
|
UsageState::Unreachable {
|
||||||
|
detail: detail.to_string(),
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
/// Pulls the `limits` array apart, defensively: entries with no percent
|
/// Pulls the `limits` array apart, defensively: entries with no percent
|
||||||
/// are skipped, unknown kinds keep their raw name as the label rather
|
/// are skipped, unknown kinds keep their raw name as the label rather
|
||||||
/// than being dropped -- a new window appearing should show up, not
|
/// than being dropped -- a new window appearing should show up, not
|
||||||
@@ -424,6 +442,35 @@ mod tests {
|
|||||||
assert!(snapshot.windows.is_empty());
|
assert!(snapshot.windows.is_empty());
|
||||||
}
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn a_missing_credential_file_is_a_choice_and_anything_else_is_a_fault() {
|
||||||
|
// What a real shell says when nobody has logged in on that
|
||||||
|
// machine. Nothing to fix, so it must not read as an error.
|
||||||
|
assert_eq!(
|
||||||
|
why_no_credentials("cat: /home/x/.claude/.credentials.json: No such file or directory"),
|
||||||
|
UsageState::NotLoggedIn
|
||||||
|
);
|
||||||
|
assert_eq!(
|
||||||
|
why_no_credentials("cat: can't open '/home/x/.claude/.credentials.json'"),
|
||||||
|
UsageState::NotLoggedIn
|
||||||
|
);
|
||||||
|
|
||||||
|
// What ssh says when the machine is not there. Worth chasing, and
|
||||||
|
// the detail is carried so somebody can.
|
||||||
|
let refused = why_no_credentials("ssh: connect to host vm port 22: Connection refused");
|
||||||
|
assert!(
|
||||||
|
matches!(&refused, UsageState::Unreachable { detail } if detail.contains("refused")),
|
||||||
|
"{refused:?}"
|
||||||
|
);
|
||||||
|
|
||||||
|
// Anything unrecognised errs towards the state that gets looked
|
||||||
|
// at, rather than silently claiming nobody is logged in.
|
||||||
|
assert!(matches!(
|
||||||
|
why_no_credentials("something nobody has seen before"),
|
||||||
|
UsageState::Unreachable { .. }
|
||||||
|
));
|
||||||
|
}
|
||||||
|
|
||||||
#[test]
|
#[test]
|
||||||
fn only_machines_that_can_run_claude_are_asked_about_it() {
|
fn only_machines_that_can_run_claude_are_asked_about_it() {
|
||||||
let mut echo_only = unreachable_setup();
|
let mut echo_only = unreachable_setup();
|
||||||
|
|||||||
Reference in new issue
Block a user