Merge: darkphilosophy/feat/advisor-per-agent-toggle
Brings the per-advisor toggle, status-line glyphs, quota display, and the
failing-advisor stall/abort fix (f4c8143) onto main's rewritten advisor
runtime. Conflict reconciliation kept main's architecture (fingerprint
prefix reconciliation, host-level onTurnError recovery + fallback chains,
terminal-failure classification) and ported the branch semantics onto it:
- #failing latch: waitForCatchup resolves immediately while an advisor is
mid-failure; parked waiters wake the moment a turn fails, before any
async hook or retry sleep.
- Turn-end render containment: a formatter bug restores the cursor/prefix/
dedup snapshot and never propagates into the primary's turn-end callback
(per-advisor try/catch boundary in AgentSession).
- Quota pause: when host recovery declines a usage-limit failure, the
runtime latches quotaExhausted, requeues the batch, and notifies —
cleared only by an explicit reset.
- Hard halt after a permanent rejection or three backlog-drop cycles.
- #recoverAdvisorTurn also marks usage limits for structural errors thrown
before any assistant turn is recorded.
This commit is contained in:
@@ -349,46 +349,119 @@ export class CommandController {
|
||||
this.ctx.present([new Spacer(1), new Text(info, 1, 0)]);
|
||||
}
|
||||
|
||||
static readonly #advisorStatusGlyph: Record<string, string> = {
|
||||
running: "●",
|
||||
paused: "○",
|
||||
no_model: "○",
|
||||
quota_exhausted: "✕",
|
||||
error: "✕",
|
||||
};
|
||||
|
||||
static readonly #advisorStatusLabel: Record<string, string> = {
|
||||
running: "running",
|
||||
paused: "off",
|
||||
no_model: "no model",
|
||||
quota_exhausted: "quota exhausted",
|
||||
error: "error",
|
||||
};
|
||||
|
||||
async handleAdvisorStatusCommand(): Promise<void> {
|
||||
const stats = this.ctx.session.getAdvisorStats();
|
||||
if (!stats.active) {
|
||||
this.ctx.present([
|
||||
new Spacer(1),
|
||||
new Text(
|
||||
stats.configured
|
||||
? "Advisor setting is enabled, but no model is assigned to the 'advisor' role."
|
||||
: "Advisor is disabled.",
|
||||
1,
|
||||
0,
|
||||
),
|
||||
]);
|
||||
if (!stats.configured) {
|
||||
this.ctx.present([new Spacer(1), new Text("Advisor is disabled.", 1, 0)]);
|
||||
return;
|
||||
}
|
||||
if (stats.advisors.length > 1) {
|
||||
// Fetch live quota data (cached 5 min by the auth-gateway) so we can show
|
||||
// real usage windows/reset timers per advisor provider. Non-fatal when absent.
|
||||
const usageProvider = this.ctx.session as { fetchUsageReports?: () => Promise<UsageReport[] | null> };
|
||||
let usageReports: UsageReport[] | null = null;
|
||||
if (usageProvider.fetchUsageReports) {
|
||||
try {
|
||||
usageReports = await usageProvider.fetchUsageReports();
|
||||
} catch {
|
||||
// Network/auth failure is non-fatal — just skip the quota line.
|
||||
}
|
||||
}
|
||||
// Resolve the active OAuth identity for each advisor's provider so quota
|
||||
// filtering matches the credential actually in use (not sibling accounts).
|
||||
const resolveActiveAdvisorAccount = (provider: string, sessionId?: string): OAuthAccountIdentity | undefined =>
|
||||
this.ctx.session.modelRegistry.authStorage.getOAuthAccountIdentity(
|
||||
provider,
|
||||
sessionId ?? this.ctx.session.sessionId,
|
||||
);
|
||||
const nowMs = Date.now();
|
||||
// Roster view: show every configured advisor with its status, even when
|
||||
// none are live (all paused/no-model). The old code returned a generic
|
||||
// message that hid the per-advisor state the user needs to act on.
|
||||
if (stats.advisors.length > 1 || (stats.configured && !stats.active)) {
|
||||
let info = `${theme.bold("Advisor Status")} (${stats.advisors.length} advisors)\n`;
|
||||
for (const a of stats.advisors) {
|
||||
const ctx =
|
||||
a.contextWindow > 0
|
||||
? `${a.contextTokens.toLocaleString()} / ${a.contextWindow.toLocaleString()} (${Math.round((a.contextTokens / a.contextWindow) * 100)}%)`
|
||||
: `${a.contextTokens.toLocaleString()}`;
|
||||
info += `\n${theme.bold(a.name)}\n`;
|
||||
info += `${theme.fg("dim", "Model:")} ${a.model.provider}/${a.model.id}\n`;
|
||||
info += `${theme.fg("dim", "Context:")} ${ctx}\n`;
|
||||
info += `${theme.fg("dim", "Messages:")} ${a.messages.total.toLocaleString()}\n`;
|
||||
info += `${theme.fg("dim", "Spend:")} ${a.tokens.input.toLocaleString()} in / ${a.tokens.output.toLocaleString()} out`;
|
||||
if (a.cost > 0) info += `, $${a.cost.toFixed(4)}`;
|
||||
info += "\n";
|
||||
const glyph = CommandController.#advisorStatusGlyph[a.status] ?? "?";
|
||||
const label = CommandController.#advisorStatusLabel[a.status] ?? a.status;
|
||||
const color =
|
||||
a.status === "running"
|
||||
? "success"
|
||||
: a.status === "quota_exhausted" || a.status === "error"
|
||||
? "error"
|
||||
: "dim";
|
||||
info += `\n${theme.fg(color, glyph)} ${theme.bold(a.name)} ${theme.fg("dim", `[${label}]`)}\n`;
|
||||
if (a.model) {
|
||||
info += `${theme.fg("dim", "Model:")} ${a.model.provider}/${a.model.id}\n`;
|
||||
}
|
||||
if (a.model && usageReports) {
|
||||
const quota = formatCompactQuota(
|
||||
a.model.provider,
|
||||
usageReports,
|
||||
nowMs,
|
||||
resolveActiveAdvisorAccount(a.model.provider, a.sessionId),
|
||||
);
|
||||
if (quota) info += `${theme.fg("dim", quota)}\n`;
|
||||
}
|
||||
if (a.status === "running" || a.status === "quota_exhausted") {
|
||||
const ctx =
|
||||
a.contextWindow > 0
|
||||
? `${a.contextTokens.toLocaleString()} / ${a.contextWindow.toLocaleString()} (${Math.round((a.contextTokens / a.contextWindow) * 100)}%)`
|
||||
: `${a.contextTokens.toLocaleString()}`;
|
||||
info += `${theme.fg("dim", "Context:")} ${ctx}\n`;
|
||||
info += `${theme.fg("dim", "Messages:")} ${a.messages.total.toLocaleString()}\n`;
|
||||
info += `${theme.fg("dim", "Spend:")} ${a.tokens.input.toLocaleString()} in / ${a.tokens.output.toLocaleString()} out`;
|
||||
if (a.cost > 0) info += `, $${a.cost.toFixed(4)}`;
|
||||
info += "\n";
|
||||
}
|
||||
}
|
||||
if (stats.active) {
|
||||
info += `\n${theme.bold("Totals")}\n`;
|
||||
info += `${theme.fg("dim", "Tokens:")} ${stats.tokens.total.toLocaleString()}\n`;
|
||||
if (stats.cost > 0) info += `${theme.fg("dim", "Cost:")} $${stats.cost.toFixed(4)}\n`;
|
||||
}
|
||||
info += `\n${theme.bold("Totals")}\n`;
|
||||
info += `${theme.fg("dim", "Tokens:")} ${stats.tokens.total.toLocaleString()}\n`;
|
||||
if (stats.cost > 0) info += `${theme.fg("dim", "Cost:")} $${stats.cost.toFixed(4)}\n`;
|
||||
this.ctx.present([new Spacer(1), new Text(info, 1, 0)]);
|
||||
return;
|
||||
}
|
||||
const model = stats.model!;
|
||||
// Single active advisor — detailed view.
|
||||
const model = stats.model;
|
||||
let info = `${theme.bold("Advisor Status")}\n\n`;
|
||||
info += `${theme.bold("Provider")}\n`;
|
||||
info += `${theme.fg("dim", "Model:")} ${model.provider}/${model.id}\n`;
|
||||
if (stats.advisors.length === 1) {
|
||||
const a = stats.advisors[0];
|
||||
const glyph = CommandController.#advisorStatusGlyph[a.status] ?? "?";
|
||||
const label = CommandController.#advisorStatusLabel[a.status] ?? a.status;
|
||||
info += `${theme.fg(a.status === "running" ? "success" : "error", glyph)} ${a.name} ${theme.fg("dim", `[${label}]`)}\n\n`;
|
||||
}
|
||||
if (model) {
|
||||
info += `${theme.bold("Provider")}\n`;
|
||||
info += `${theme.fg("dim", "Model:")} ${model.provider}/${model.id}\n`;
|
||||
}
|
||||
if (model && usageReports) {
|
||||
const quota = formatCompactQuota(
|
||||
model.provider,
|
||||
usageReports,
|
||||
nowMs,
|
||||
resolveActiveAdvisorAccount(model.provider, stats.advisors[0]?.sessionId),
|
||||
);
|
||||
if (quota) {
|
||||
info += `\n${theme.bold("Quota")}\n`;
|
||||
info += `${theme.fg("dim", quota)}\n`;
|
||||
}
|
||||
}
|
||||
info += `\n${theme.bold("Messages")}\n`;
|
||||
info += `${theme.fg("dim", "User:")} ${stats.messages.user.toLocaleString()}\n`;
|
||||
info += `${theme.fg("dim", "Assistant:")} ${stats.messages.assistant.toLocaleString()}\n`;
|
||||
@@ -406,14 +479,7 @@ export class CommandController {
|
||||
if (stats.tokens.cacheRead > 0) {
|
||||
info += `${theme.fg("dim", "Cache Read:")} ${stats.tokens.cacheRead.toLocaleString()}\n`;
|
||||
}
|
||||
if (stats.tokens.cacheWrite > 0) {
|
||||
info += `${theme.fg("dim", "Cache Write:")} ${stats.tokens.cacheWrite.toLocaleString()}\n`;
|
||||
}
|
||||
info += `${theme.fg("dim", "Total:")} ${stats.tokens.total.toLocaleString()}\n`;
|
||||
if (stats.cost > 0) {
|
||||
info += `\n${theme.bold("Cost")}\n`;
|
||||
info += `${theme.fg("dim", "Total:")} $${stats.cost.toFixed(4)}\n`;
|
||||
}
|
||||
if (stats.cost > 0) info += `${theme.fg("dim", "Cost:")} $${stats.cost.toFixed(4)}\n`;
|
||||
this.ctx.present([new Spacer(1), new Text(info, 1, 0)]);
|
||||
}
|
||||
|
||||
@@ -1542,6 +1608,57 @@ function resolveResetRange(limits: UsageLimit[], nowMs: number): string | null {
|
||||
}
|
||||
return `resets in ${formatDuration(minReset)}`;
|
||||
}
|
||||
/**
|
||||
* Compact one-line quota summary for a single advisor's provider.
|
||||
* Returns `null` when the provider has no usage data.
|
||||
* When `activeAccount` is provided, only limits matching that credential
|
||||
* are shown (mirrors `renderUsageReports`'s account-stickiness filtering).
|
||||
* Example output: `Quota: 7d window · 67% used · resets in 3.2d`
|
||||
*/
|
||||
export function formatCompactQuota(
|
||||
provider: string,
|
||||
reports: UsageReport[],
|
||||
nowMs: number,
|
||||
activeAccount?: OAuthAccountIdentity,
|
||||
): string | null {
|
||||
const providerReports = reports.filter(r => r.provider === provider);
|
||||
if (providerReports.length === 0) return null;
|
||||
// Group limits by window id so we show BOTH the 5-hour and 7-day windows
|
||||
// (or any other distinct windows the provider exposes). Within each window,
|
||||
// pick the highest used fraction across accounts — that's the most pressing.
|
||||
const byWindow = new Map<string, { limit: UsageLimit; fraction: number }>();
|
||||
for (const report of providerReports) {
|
||||
for (const limit of report.limits) {
|
||||
// Skip limits that belong to a different credential than the one
|
||||
// the advisor is actually using, so we don't alarm the user with
|
||||
// an exhausted account that isn't theirs.
|
||||
if (activeAccount && !limitMatchesActiveAccount(report, limit, activeAccount)) continue;
|
||||
const fraction = resolveUsedFraction(limit);
|
||||
if (fraction === undefined) continue;
|
||||
const key = limit.window?.id ?? limit.scope.windowId ?? "—";
|
||||
const existing = byWindow.get(key);
|
||||
if (!existing || fraction > existing.fraction) byWindow.set(key, { limit, fraction });
|
||||
}
|
||||
}
|
||||
if (byWindow.size === 0) return null;
|
||||
// Sort windows by urgency (highest fraction first) so the most pressing
|
||||
// quota is always the first thing the user sees.
|
||||
const entries = [...byWindow.values()].sort((a, b) => b.fraction - a.fraction);
|
||||
const lines: string[] = [];
|
||||
for (const { limit, fraction } of entries) {
|
||||
const pct = Math.round(fraction * 100);
|
||||
const windowLabel = limit.window?.label ?? limit.scope.windowId ?? "—";
|
||||
// Include the limit label (account/tier) when it carries identity beyond
|
||||
// the window name, so the user can tell which credential's quota is shown.
|
||||
const identity = limit.label.trim();
|
||||
const header = identity && identity !== windowLabel ? `${windowLabel} (${identity})` : windowLabel;
|
||||
const parts = [`${header}: ${pct}% used`];
|
||||
const reset = resolveResetRange([limit], nowMs);
|
||||
if (reset) parts.push(reset);
|
||||
lines.push(parts.join(" · "));
|
||||
}
|
||||
return `Quota: ${lines.join(" │ ")}`;
|
||||
}
|
||||
|
||||
function resolveStatusIcon(status: UsageLimit["status"], uiTheme: typeof theme): string {
|
||||
if (status === "exhausted") return uiTheme.fg("error", uiTheme.status.error);
|
||||
|
||||
Reference in New Issue
Block a user