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:
can1357
2026-07-17 07:37:29 +02:00
23 changed files with 1719 additions and 154 deletions
@@ -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);