Skip to content
Open
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
13 changes: 13 additions & 0 deletions pm-stack/aggregator/src/aa.ts
Original file line number Diff line number Diff line change
Expand Up @@ -607,18 +607,31 @@ export interface AaPlanFact {
/** List-rate dollars of usage per $1 paid, when the plan meters so. */
multiplier: number | null;
note: string;
/** Typed cancellation source data (#1174): the plan is cancelled and
* its paid period ends on this ISO date. The quota-action derivation
* (buildQuotaActions) reads THESE fields — never the note prose —
* so update them with the registry when a plan changes. */
cancelled?: boolean;
periodEndsAt?: string;
/** Concrete next step while the cancelled period still has time left. */
renewalAction?: string;
}

export const AA_PLAN_FACTS: Record<string, AaPlanFact> = {
"opencode-go.personal": {
plan: "OpenCode Go — Personal",
multiplier: 6,
note: "$10/mo buys a $60/mo list-rate meter (6×); Flash promo cap $80 (≈8×). Cancelled — period ends 2026-11-02.",
cancelled: true,
periodEndsAt: "2026-11-02",
renewalAction: "Burn before Lab per rotation order",
},
"opencode-go.lab": {
plan: "OpenCode Go — Lab",
multiplier: 6,
note: "$10/mo buys a $60/mo list-rate meter (6×); Flash promo cap $80 (≈8×). Cancelled — period ends 2026-11-06.",
cancelled: true,
periodEndsAt: "2026-11-06",
},
"opencode-go": {
plan: "OpenCode Go",
Expand Down
154 changes: 153 additions & 1 deletion pm-stack/aggregator/src/derive.ts
Original file line number Diff line number Diff line change
Expand Up @@ -823,7 +823,7 @@ export function deriveToday(
// PAYG over $5 (unbilled accrual only — Q3 default), surfaces worst-first.
// ---------------------------------------------------------------------------

import { countedEstimate, type AttentionFlag, type QuotaView, type SnapshotRecord, type UsageView } from "./model.ts";
import { countedEstimate, type AttentionFlag, type Horizon, type QuotaAction, type QuotaActionKind, type QuotaView, type SnapshotRecord, type UsageView } from "./model.ts";

export const BUBBLE_PCT = 80;
export const BUBBLE_USD = 5;
Expand Down Expand Up @@ -896,6 +896,158 @@ export function buildAttentionFlags(
return flags.sort((a, b) => (a.kind === b.kind ? b.severity - a.severity : a.kind === "over-limit" ? -1 : 1));
}

// --- Quota actions (#1174) --------------------------------------------------
// One derivation for the expiring/cancelled, walled, and high-usage
// quota actions surfaced on /quota's strip, /today, and /attention's
// orchestrator tier. The cancellation source data is the plan-facts
// registry (AaPlanFact.cancelled/periodEndsAt, updated with the
// registry when a plan changes) — never scraped from display prose.
// Walled and high-usage state comes from the pools themselves. An
// entry exists only while its condition holds (deadline in the
// future and inside the window, pool still walled, window still hot
// with a distant reset), so entries expire and clear on their own and
// no surface can render a duplicate or stale copy.

/** Structural plan-fact input; AA_PLAN_FACTS satisfies this. */
export interface QuotaPlanFact {
cancelled?: boolean;
periodEndsAt?: string | null;
renewalAction?: string | null;
}

/** A cancelled period counts as expiring inside this many days. */
export const QUOTA_ACTION_WINDOW_DAYS = 14;
/** High-usage bar: strictly above this percent of limit. */
export const QUOTA_HIGH_USAGE_PCT = 85;
/** High-usage counts only when the reset is further out than this. */
export const QUOTA_HIGH_USAGE_RESET_MS = 24 * 3_600_000;

function planFactKeyForPool(poolId: string): string {
return poolId.replace(":", ".");
}

/**
* Every eligible quota action across the pools, worst first: expiring
* periods by nearest deadline, then walled pools, then high-usage
* windows by percent. Pure — same quota in, same actions out — so the
* three surfaces render the identical entries.
*/
export function buildQuotaActions(
quota: QuotaView,
plans: Readonly<Record<string, QuotaPlanFact>> = {},
now = Date.now(),
): QuotaAction[] {
const actions: QuotaAction[] = [];
for (const pool of quota.pools) {
const key = planFactKeyForPool(pool.id);
const fact = Object.hasOwn(plans, key) ? plans[key] : undefined; // #3056 own-key guard
const periodEnd = fact?.cancelled && fact.periodEndsAt ? Date.parse(fact.periodEndsAt) : NaN;
if (Number.isFinite(periodEnd)) {
const left = (periodEnd as number) - now;
if (left >= 0 && left <= QUOTA_ACTION_WINDOW_DAYS * 86_400_000) {
const day = String(fact?.periodEndsAt).slice(0, 10);
actions.push({
id: `${pool.id}:expiry`,
poolId: pool.id,
provider: pool.provider,
plan: pool.plan,
kind: "expiry",
title: `${pool.provider} — ${pool.plan} ends ${day}`,
detail: `Cancelled — period ends ${day}${fact?.renewalAction ? ` · ${fact.renewalAction}` : ""}`,
deadline: new Date(periodEnd as number).toISOString(),
href: "/quota",
});
}
}
if (pool.state === "walled") {
actions.push({
id: `${pool.id}:walled`,
poolId: pool.id,
provider: pool.provider,
plan: pool.plan,
kind: "walled",
title: `${pool.provider} — ${pool.plan} is walled`,
detail: pool.wallLiftsAt
? `Walled — lifts ${pool.wallLiftsAt.slice(0, 10)} · hold new sessions until then`
: "Walled with no lift date recorded — check the pool before starting sessions",
deadline: pool.wallLiftsAt ?? null,
href: "/quota",
});
}
let worst: QuotaWindow | null = null;
let worstPct = 0;
for (const w of pool.windows) {
const pct = windowPercent(w);
if (pct > worstPct) {
worstPct = pct;
worst = w;
Comment on lines +979 to +983

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

P2 Badge Check reset eligibility before selecting the highest-usage window

For a pool with multiple windows, a higher-percentage short window can mask a qualifying longer window. For example, a 5-hour window at 99% resetting in two hours and a weekly window at 92% resetting in four days produce no action: the loop selects the 5-hour window, then rejects its reset without considering the weekly window. The same happens when the highest window has no reset recorded, a shape produced by extractClinePass in quota-extract.ts. Filter for valid, distant resets before selecting the highest percentage so qualifying warnings remain visible.

Useful? React with 👍 / 👎.

}
}
if (worst && worstPct > QUOTA_HIGH_USAGE_PCT && worst.resetsAt) {
const reset = Date.parse(worst.resetsAt);
if (Number.isFinite(reset) && reset - now > QUOTA_HIGH_USAGE_RESET_MS) {
actions.push({
id: `${pool.id}:high-usage`,
poolId: pool.id,
provider: pool.provider,
plan: pool.plan,
kind: "high-usage",
title: `${pool.provider} — ${pool.plan} at ${Math.round(worstPct)}%`,
detail: `${worst.name} window ${Math.round(worstPct)}% used · resets ${worst.resetsAt.slice(0, 10)} — burn it down or hold new sessions`,
deadline: worst.resetsAt,
href: "/quota",
});
}
}
}
const rank: Record<QuotaActionKind, number> = { expiry: 0, walled: 1, "high-usage": 2 };
return actions.sort(
(a, b) =>
rank[a.kind] - rank[b.kind] ||
(a.deadline ?? "").localeCompare(b.deadline ?? "") ||
a.id.localeCompare(b.id),
);
}

/** The /quota "Needs attention" strip rows for already-derived actions. */
export function quotaActionFlags(actions: QuotaAction[], now = Date.now()): AttentionFlag[] {
return actions.map((a) => ({
kind: "quota-action" as const,
label: a.title,
detail: a.detail,
href: a.href,
severity:
a.kind === "expiry" && a.deadline
? 1000 + (QUOTA_ACTION_WINDOW_DAYS - (Date.parse(a.deadline) - now) / 86_400_000)
: a.kind === "walled"
? 500
: 100,
}));
}

/** The /attention orchestrator-tier items for already-derived actions. */
export function quotaActionTierItems(actions: QuotaAction[], now = Date.now()): TierItem[] {
const at = new Date(now).toISOString();
return actions.map((a) => {
const left = a.deadline ? Date.parse(a.deadline) - now : NaN;
return {
id: `quota-action:${a.id}`,
kind: "task" as const,
title: a.title,
// Aggregator-synthesized like the #1148 watchdog items: derived
// live, never published by the orchestrator.
source: "watchdog" as const,
reason: a.detail,
since: at,
lastSeenAt: at,
horizon: (
Number.isFinite(left) ? (left <= 86_400_000 ? "today" : left <= 3 * 86_400_000 ? "next-3-days" : "longer") : "longer"
) as Horizon,
recommendedAction: a.detail,
};
});
}

export interface Freshness {
stale: boolean;
/** Whole days since the freshest read; null when never measured. */
Expand Down
33 changes: 32 additions & 1 deletion pm-stack/aggregator/src/model.ts
Original file line number Diff line number Diff line change
Expand Up @@ -1274,16 +1274,47 @@ export interface ChargeRow {
/**
* One tripped bubble rule (§1.3): a metric over 80% of its limit, or
* projected unbilled PAYG over $5 (unbilled accrual only, Q3 default).
* "quota-action" entries are the eligible quota actions from
* buildQuotaActions (derive.ts, #1174) — expiry/cancellation, walled,
* and high-usage — rendered on the same strip, never a second list.
*/
export interface AttentionFlag {
kind: "over-limit" | "payg-unbilled" | "unmatched-charge";
kind: "over-limit" | "payg-unbilled" | "unmatched-charge" | "quota-action";
label: string;
detail: string;
href: string;
/** Percent (over-limit) or counted dollars (payg-unbilled); worst first. */
severity: number;
}

/**
* One actionable quota entry (#1174): an expiring/cancelled plan
* period, a walled pool, or a high-usage window whose reset is far
* enough away to act on. Derived once by buildQuotaActions (derive.ts)
* from the pools' typed source data — never by scraping display
* prose — and rendered identically on /quota, /today, and /attention.
* An entry exists only while its source condition holds: past
* deadlines and cleared states produce no entry, so nothing goes
* stale and nothing renders twice.
*/
export type QuotaActionKind = "expiry" | "walled" | "high-usage";

export interface QuotaAction {
/** Stable per pool + kind, so surfaces dedupe by identity. */
id: string;
poolId: string;
provider: string;
plan: string;
kind: QuotaActionKind;
/** Actionable one-liner, e.g. "OpenCode Go — Personal ends 2026-11-02". */
title: string;
/** Reason + deadline + concrete next step, when the source names one. */
detail: string;
/** ISO deadline when the source carries one (expiry date, wall lift, reset). */
deadline: string | null;
href: string;
}

/**
* The quota plane's latest-state contract (Stage 2, #1151): one entry
* per provider credential the gatherer read this tick, each window
Expand Down
26 changes: 21 additions & 5 deletions pm-stack/aggregator/src/render.ts
Original file line number Diff line number Diff line change
Expand Up @@ -6,6 +6,7 @@ import {
type RoadmapGrain,
type TodayView,
allRefs,
buildQuotaActions,
commentStream,
executionStrip,
freshness,
Expand Down Expand Up @@ -37,7 +38,7 @@ import {
} from "./tier-items.ts";
import { commentKindCounts, githubCommentUrl, queuedComments, type CommentKindCounts } from "./comments.ts";
import { serviceHomeLinks, type ItemServiceLink } from "./service-links.ts";
import { aaCaiPoints, aaPoints, summarizeAa } from "./aa.ts";
import { AA_PLAN_FACTS, aaCaiPoints, aaPoints, summarizeAa } from "./aa.ts";
import { summarizeDns } from "./dns.ts";
import type { Incident, IncidentEvent, PushSubscriptionRow } from "./alerts.ts";
import { ageMsText, feedAgeText } from "./feed.ts";
Expand Down Expand Up @@ -3340,16 +3341,25 @@ export function renderTodayBody(
quotaUnavailable = false,
): string {
const walled = quota.pools.filter((p) => p.state === "walled");
const quotaActions = buildQuotaActions(quota, AA_PLAN_FACTS);
const alerts: string[] = [];
const drewTotal = attention.drew.length + attention.drewExtra.length;
if (drewTotal) {
alerts.push(
`<div class="banner drew"><strong>${drewTotal} item${drewTotal === 1 ? "" : "s"} in the user queue</strong> — <a href="/attention#drew">review them</a></div>`,
);
}
if (walled.length) {
// #1174: the same eligible quota actions as /quota's strip and
// /attention's orchestrator tier, derived once above — never a
// second list. Walled pools without a typed action still banner.
for (const a of quotaActions) {
alerts.push(
`<div class="banner"><strong>Walled pools:</strong> ${walled.map((p) => esc(`${p.provider} (${p.plan})`)).join(", ")} — <a href="/quota">quota</a></div>`,
`<div class="banner"><strong>Quota action:</strong> ${esc(a.title)} — ${esc(a.detail)} <a href="${esc(a.href)}">quota</a></div>`,
);
}
for (const p of walled.filter((p) => !quotaActions.some((a) => a.poolId === p.id))) {
alerts.push(
`<div class="banner"><strong>Walled pools:</strong> ${esc(`${p.provider} (${p.plan})`)} — <a href="/quota">quota</a></div>`,
);
}
// #1139: the derived view's banner names the section's TRUE state —
Expand All @@ -3370,8 +3380,14 @@ export function renderTodayBody(
const actions = today.actions?.length
? `<h2>Pushed back into motion</h2>${today.actions.map((a) => `<div class="card"><span class="badge">${esc(a.kind)}</span> <strong>${esc(a.target)}</strong><br>${esc(a.summary)}<br><span class="muted">${esc(fmtDate(a.at))}</span></div>`).join("")}`
: "";
const objectives = today.objectives.length
? today.objectives
const quotaObjectives = quotaActions.map((a) => ({
title: a.title,
detail: a.detail,
chip: "quota · action",
}));
const allObjectives = [...today.objectives, ...quotaObjectives];
const objectives = allObjectives.length
? allObjectives
.map(
(o, i) =>
`<div class="card obj"><div class="n">${i + 1}</div><div><strong>${esc(o.title)}</strong> <span class="badge src">${esc(o.chip)}</span>${o.detail ? `<br><span class="muted">${esc(o.detail)}</span>` : ""}${o.ref ? `<br><a href="${esc(refUrl(o.ref))}">${esc(o.ref)}</a>` : ""}</div></div>`,
Expand Down
17 changes: 15 additions & 2 deletions pm-stack/aggregator/src/server.ts
Original file line number Diff line number Diff line change
Expand Up @@ -25,6 +25,7 @@ import {
import { loadConfig } from "./config.ts";
import { itemServiceLinks } from "./service-links.ts";
import { loadFeed } from "./feed.ts";
import { AA_PLAN_FACTS } from "./aa.ts";
import {
buildHealth,
pageRenderHealth,
Expand All @@ -35,7 +36,7 @@ import {
} from "./health.ts";
import { manifestRouteFor } from "./pages.ts";
import { watchdogTierItems, WATCHDOG_PROBE_SERVICE_IDS } from "./watchdog.ts";
import { allRefs, buildAttention, deriveToday, projectPace, type RoadmapGrain } from "./derive.ts";
import { allRefs, buildAttention, buildQuotaActions, deriveToday, projectPace, quotaActionTierItems, type RoadmapGrain } from "./derive.ts";
import {
reconcileRoadmap,
roadmapDriftReport,
Expand Down Expand Up @@ -434,8 +435,20 @@ async function attentionContext() {
// recomputes it on every orchestration read (#1139).
const diagnosis = source.diagnostics?.().orchestration;
const watchdog = await deriveWatchdogItems(diagnosis);
const quota = await source.quota().catch(() => null);
const view = buildAttention(feed, summary, Date.now(), diagnosis, watchdog);
// #1174: the same eligible quota actions as /quota's strip and
// /today, as orchestrator-tier task items — derived once here from
// the same pure function, so entries clear on every surface the
// moment their source condition no longer holds.
if (quota) {
view.orchestratorExtra = [
...view.orchestratorExtra,
...quotaActionTierItems(buildQuotaActions(quota, AA_PLAN_FACTS)),
];
Comment on lines +445 to +448

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

P2 Badge Include synthesized quota tasks in the attention API

Whenever an eligible quota action exists, adding its task only to orchestratorExtra makes it visible in the HTML queue but absent from /api/attention.json: that handler serializes only view.all and view.watchdog, neither of which receives these tasks. The page explicitly advertises that endpoint as its machine-first interface, so machine consumers cannot discover or act on the new quota tasks despite their appearance in Needs Orchestrator. Include the synthesized tasks in the API response with the corresponding tier annotation.

Useful? React with 👍 / 👎.

}
return {
view: buildAttention(feed, summary, Date.now(), diagnosis, watchdog),
view,
coverage,
orchestration: summary,
diagnosis,
Expand Down
10 changes: 8 additions & 2 deletions pm-stack/aggregator/src/sources/fixtures.ts
Original file line number Diff line number Diff line change
Expand Up @@ -24,7 +24,7 @@ import {
refsInText,
refUrl,
} from "../model.ts";
import { buildAttentionFlags } from "../derive.ts";
import { buildAttentionFlags, buildQuotaActions, quotaActionFlags } from "../derive.ts";
import { gristDocUrl } from "../service-links.ts";
import { recordFromSessionEntry } from "../registry.ts";
import type { Source } from "../source.ts";
Expand Down Expand Up @@ -1402,7 +1402,13 @@ export function fixtureSource(): Source {
return [...byRef.values()];
},
quota: async () => FIXTURE_QUOTA,
usage: async () => ({ ...FIXTURE_USAGE, attention: buildAttentionFlags(FIXTURE_QUOTA, FIXTURE_USAGE) }),
usage: async () => ({
...FIXTURE_USAGE,
attention: [
...buildAttentionFlags(FIXTURE_QUOTA, FIXTURE_USAGE),
...quotaActionFlags(buildQuotaActions(FIXTURE_QUOTA)),
],
}),
attention: async () => FIXTURE_ATTENTION,
// Fixtures carry no orchestration section: "not measured" is the
// honest fixture state for a job that only runs live (#899).
Expand Down
Loading
Loading