@saadnvd1/agent-os 0.3.567 → 0.3.569
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/components/Usage/WindowCard.tsx +19 -8
- package/lib/git-remote.ts +47 -0
- package/lib/orchestrator/brief.ts +1 -1
- package/lib/orchestrator/repo-slug.ts +2 -24
- package/lib/orchestrator/usage.ts +120 -28
- package/lib/plans/features.ts +47 -6
- package/lib/plans/pieces.ts +1 -1
- package/lib/plans/release.ts +106 -11
- package/lib/plans/view.ts +2 -8
- package/lib/usage/rate-limits.ts +78 -14
- package/lib/usage/turns.ts +1 -1
- package/lib/usage/windows.ts +70 -15
- package/package.json +1 -1
|
@@ -19,29 +19,40 @@ export function WindowCard({
|
|
|
19
19
|
window: WindowView | null | undefined;
|
|
20
20
|
}) {
|
|
21
21
|
const level = w ? meterLevel(w.pct) : "ok";
|
|
22
|
+
const stale = !!w?.stale;
|
|
22
23
|
return (
|
|
23
24
|
<div className="bg-foreground/[0.03] space-y-2 rounded-xl p-3">
|
|
24
25
|
<div className="flex items-baseline justify-between gap-2">
|
|
25
26
|
<span className="text-muted-foreground text-xs">{label}</span>
|
|
26
|
-
<span
|
|
27
|
+
<span
|
|
28
|
+
className={cn(
|
|
29
|
+
"text-lg font-semibold tabular-nums",
|
|
30
|
+
stale && "text-muted-foreground"
|
|
31
|
+
)}
|
|
32
|
+
>
|
|
27
33
|
{w ? `${w.estimated ? "~" : ""}${w.pct}%` : "–"}
|
|
28
34
|
</span>
|
|
29
35
|
</div>
|
|
30
36
|
<div className="bg-foreground/10 h-1.5 overflow-hidden rounded-full">
|
|
31
37
|
<div
|
|
32
|
-
className={cn(
|
|
38
|
+
className={cn(
|
|
39
|
+
"h-full rounded-full",
|
|
40
|
+
stale ? "bg-foreground/30" : BAR[level]
|
|
41
|
+
)}
|
|
33
42
|
style={{ width: `${Math.min(w?.pct ?? 0, 100)}%` }}
|
|
34
43
|
/>
|
|
35
44
|
</div>
|
|
36
45
|
<p className="text-muted-foreground text-[11px]">
|
|
37
46
|
{w?.estimated && "Estimate from recorded usage · "}
|
|
38
47
|
{!w
|
|
39
|
-
? "Not
|
|
40
|
-
: w.
|
|
41
|
-
? `
|
|
42
|
-
: w.
|
|
43
|
-
? `
|
|
44
|
-
:
|
|
48
|
+
? "Not sampled"
|
|
49
|
+
: w.stale
|
|
50
|
+
? `Last seen ${w.ageSec !== undefined ? `${formatDuration(w.ageSec)} ago` : "a while ago"}${w.resetsIn !== null ? ` · resets in ${formatDuration(w.resetsIn)}` : ""}`
|
|
51
|
+
: w.capsIn
|
|
52
|
+
? `Runs out in ${formatDuration(w.capsIn)} at this rate`
|
|
53
|
+
: w.resetsIn !== null
|
|
54
|
+
? `Resets in ${formatDuration(w.resetsIn)}`
|
|
55
|
+
: "Used"}
|
|
45
56
|
</p>
|
|
46
57
|
</div>
|
|
47
58
|
);
|
|
@@ -0,0 +1,47 @@
|
|
|
1
|
+
// Which GitHub repository a folder is a clone of ("owner/repo", lower
|
|
2
|
+
// case), read from its origin's URL as configured, with no gh call. Kept free
|
|
3
|
+
// of the task and orchestrator modules so plan checks can use it.
|
|
4
|
+
|
|
5
|
+
import { git, gitSync } from "./git-exec";
|
|
6
|
+
|
|
7
|
+
const GITHUB_REMOTE =
|
|
8
|
+
/^(?:https?:\/\/(?:[^@/]+@)?|ssh:\/\/(?:[^@/]+@)?|[^@/:]+@)github\.com[:/]([\w.-]+)\/([\w.-]+?)(?:\.git)?\/?$/i;
|
|
9
|
+
|
|
10
|
+
export function slugOfRemote(url: string | null | undefined): string | null {
|
|
11
|
+
const m = GITHUB_REMOTE.exec((url ?? "").trim());
|
|
12
|
+
return m ? `${m[1]}/${m[2]}`.toLowerCase() : null;
|
|
13
|
+
}
|
|
14
|
+
|
|
15
|
+
// A remote's URL doesn't change under a running server often enough to ask
|
|
16
|
+
// git on every lookup.
|
|
17
|
+
const SLUG_TTL_MS = 5 * 60 * 1000;
|
|
18
|
+
const slugs = new Map<string, { at: number; slug: string | null }>();
|
|
19
|
+
|
|
20
|
+
export async function repoSlug(dir: string): Promise<string | null> {
|
|
21
|
+
const hit = slugs.get(dir);
|
|
22
|
+
if (hit && Date.now() - hit.at < SLUG_TTL_MS) return hit.slug;
|
|
23
|
+
const url = await git(dir, ["config", "--get", "remote.origin.url"], {
|
|
24
|
+
timeout: 10000,
|
|
25
|
+
}).catch(() => "");
|
|
26
|
+
const slug = slugOfRemote(url);
|
|
27
|
+
slugs.set(dir, { at: Date.now(), slug });
|
|
28
|
+
return slug;
|
|
29
|
+
}
|
|
30
|
+
|
|
31
|
+
// The same, without waiting: a cached local `git config` read for callers that
|
|
32
|
+
// can't be async. Null when the folder isn't a GitHub clone.
|
|
33
|
+
export function repoSlugSync(dir: string): string | null {
|
|
34
|
+
const hit = slugs.get(dir);
|
|
35
|
+
if (hit && Date.now() - hit.at < SLUG_TTL_MS) return hit.slug;
|
|
36
|
+
let url = "";
|
|
37
|
+
try {
|
|
38
|
+
url = gitSync(dir, ["config", "--get", "remote.origin.url"], {
|
|
39
|
+
timeout: 5000,
|
|
40
|
+
});
|
|
41
|
+
} catch {
|
|
42
|
+
url = "";
|
|
43
|
+
}
|
|
44
|
+
const slug = slugOfRemote(url);
|
|
45
|
+
slugs.set(dir, { at: Date.now(), slug });
|
|
46
|
+
return slug;
|
|
47
|
+
}
|
|
@@ -8,7 +8,7 @@ import { mergeApprovalsOn } from "./merge-approvals";
|
|
|
8
8
|
import { noApprovalsOn } from "../approvals/no-approvals";
|
|
9
9
|
import { buildDigest } from "./digest";
|
|
10
10
|
|
|
11
|
-
const BRAKES_ON = `Starting work (\`start_task\`, \`start_session\`, \`stack\`) is braked: at most a set number of sessions running in this workspace (4 by default), at most a set number of starts an hour (6 by default), and nothing new once the account's usage window would run out before it resets, or
|
|
11
|
+
const BRAKES_ON = `Starting work (\`start_task\`, \`start_session\`, \`stack\`) is braked: at most a set number of sessions running in this workspace (4 by default), at most a set number of starts an hour (6 by default), and nothing new once the account's usage window would run out before it resets, or isn't being sampled and was near its cap when last seen. Each card of a stack you start counts as a start of its own; a braked card waits in the stack. A brake refuses the start with its reason and writes one note; running work carries on. Don't retry a braked start in a loop: carry on with reviews, answers and merges, and start again when a later event gives you reason to.`;
|
|
12
12
|
|
|
13
13
|
const BRAKES_OFF = `Starting work has no limits right now: no cap on running sessions or starts, and no usage-window check. Saad's Pause still stops new starts; the start tools say so when it's on.`;
|
|
14
14
|
|
|
@@ -4,33 +4,11 @@
|
|
|
4
4
|
// of its projects'.
|
|
5
5
|
|
|
6
6
|
import type { Project } from "../db";
|
|
7
|
-
import { git } from "../git-exec";
|
|
8
7
|
import { expandHome } from "../tasks/session";
|
|
9
8
|
import { workspaceProjects } from "./brief";
|
|
9
|
+
import { repoSlug } from "../git-remote";
|
|
10
10
|
|
|
11
|
-
|
|
12
|
-
/^(?:https?:\/\/(?:[^@/]+@)?|ssh:\/\/(?:[^@/]+@)?|[^@/:]+@)github\.com[:/]([\w.-]+)\/([\w.-]+?)(?:\.git)?\/?$/i;
|
|
13
|
-
|
|
14
|
-
export function slugOfRemote(url: string | null | undefined): string | null {
|
|
15
|
-
const m = GITHUB_REMOTE.exec((url ?? "").trim());
|
|
16
|
-
return m ? `${m[1]}/${m[2]}`.toLowerCase() : null;
|
|
17
|
-
}
|
|
18
|
-
|
|
19
|
-
// A remote's URL doesn't change under a running server often enough to ask
|
|
20
|
-
// git on every lookup.
|
|
21
|
-
const SLUG_TTL_MS = 5 * 60 * 1000;
|
|
22
|
-
const slugs = new Map<string, { at: number; slug: string | null }>();
|
|
23
|
-
|
|
24
|
-
export async function repoSlug(dir: string): Promise<string | null> {
|
|
25
|
-
const hit = slugs.get(dir);
|
|
26
|
-
if (hit && Date.now() - hit.at < SLUG_TTL_MS) return hit.slug;
|
|
27
|
-
const url = await git(dir, ["config", "--get", "remote.origin.url"], {
|
|
28
|
-
timeout: 10000,
|
|
29
|
-
}).catch(() => "");
|
|
30
|
-
const slug = slugOfRemote(url);
|
|
31
|
-
slugs.set(dir, { at: Date.now(), slug });
|
|
32
|
-
return slug;
|
|
33
|
-
}
|
|
11
|
+
export { repoSlug, repoSlugSync, slugOfRemote } from "../git-remote";
|
|
34
12
|
|
|
35
13
|
export interface WorkspaceRepo {
|
|
36
14
|
project: Project;
|
|
@@ -1,17 +1,20 @@
|
|
|
1
1
|
/**
|
|
2
2
|
* Where the account's 5-hour usage window stands, ported from dispatch's
|
|
3
|
-
* `account_limit`.
|
|
4
|
-
*
|
|
5
|
-
*
|
|
6
|
-
*
|
|
7
|
-
*
|
|
8
|
-
*
|
|
9
|
-
*
|
|
3
|
+
* `account_limit`. Two things sample it: AgentOS's own agents (every
|
|
4
|
+
* rate-limit event a chat worker sees is stored in `rate_limit_readings`) and
|
|
5
|
+
* the terminal statusline, which writes limits.json. This reads both and uses
|
|
6
|
+
* whichever was sampled last, never mixing their samples. A sample that's a
|
|
7
|
+
* little stale or about a window that already reset says nothing about the
|
|
8
|
+
* burn rate, so the projection is skipped. Past 15 minutes without a sample
|
|
9
|
+
* the window is unknown: that is said plainly, with the last value seen, and
|
|
10
|
+
* a start is refused only when nothing was ever sampled or the last value was
|
|
11
|
+
* near the cap and its window hasn't reset.
|
|
10
12
|
*/
|
|
11
13
|
|
|
12
14
|
import fs from "fs";
|
|
13
15
|
import os from "os";
|
|
14
16
|
import path from "path";
|
|
17
|
+
import { lastAgentActivity, windowSamples } from "../usage/rate-limits";
|
|
15
18
|
|
|
16
19
|
export const LIMITS_FILE =
|
|
17
20
|
process.env.AGENTOS_LIMITS_FILE ??
|
|
@@ -72,31 +75,110 @@ export function usageWindow(
|
|
|
72
75
|
|
|
73
76
|
// Past this the window can't be vouched for at all (seconds).
|
|
74
77
|
const UNKNOWN_AFTER = 15 * 60;
|
|
78
|
+
// A start is refused on an unknown window only when it was this full.
|
|
79
|
+
const STALE_REFUSE_PCT = 90;
|
|
75
80
|
|
|
76
|
-
|
|
81
|
+
// What was last seen of the window, however long ago (seconds).
|
|
82
|
+
export interface LastSeen {
|
|
83
|
+
pct: number;
|
|
84
|
+
resetsAt: number | null;
|
|
85
|
+
sampledAt: number;
|
|
86
|
+
}
|
|
77
87
|
|
|
78
|
-
export
|
|
79
|
-
|
|
80
|
-
|
|
81
|
-
|
|
82
|
-
|
|
88
|
+
export type UsageState =
|
|
89
|
+
| { window: UsageWindow | null }
|
|
90
|
+
| { unknown: string; last?: LastSeen | null };
|
|
91
|
+
|
|
92
|
+
export interface Series {
|
|
93
|
+
window: number | null;
|
|
94
|
+
samples: [number, number][];
|
|
95
|
+
}
|
|
96
|
+
|
|
97
|
+
function readLimitsFile(file: string): LimitsFile | null {
|
|
83
98
|
try {
|
|
84
|
-
|
|
99
|
+
return JSON.parse(fs.readFileSync(file, "utf8")) as LimitsFile;
|
|
85
100
|
} catch {
|
|
86
|
-
return
|
|
101
|
+
return null;
|
|
87
102
|
}
|
|
88
|
-
|
|
103
|
+
}
|
|
104
|
+
|
|
105
|
+
function toSeries(state: LimitsFile | null): Series | null {
|
|
106
|
+
const samples = Array.isArray(state?.samples)
|
|
89
107
|
? (state.samples as unknown[][])
|
|
108
|
+
.map((s) => [Number(s[0]), Number(s[1])] as [number, number])
|
|
109
|
+
.filter(([at, pct]) => Number.isFinite(at) && Number.isFinite(pct))
|
|
90
110
|
: [];
|
|
91
|
-
|
|
92
|
-
|
|
93
|
-
|
|
94
|
-
|
|
95
|
-
|
|
96
|
-
|
|
97
|
-
|
|
98
|
-
|
|
99
|
-
|
|
111
|
+
if (!samples.length) return null;
|
|
112
|
+
return {
|
|
113
|
+
window: typeof state?.window === "number" ? state.window : null,
|
|
114
|
+
samples,
|
|
115
|
+
};
|
|
116
|
+
}
|
|
117
|
+
|
|
118
|
+
function storedSeries(): Series | null {
|
|
119
|
+
const { resetsAt, samples } = windowSamples("five_hour");
|
|
120
|
+
return samples.length
|
|
121
|
+
? { window: resetsAt === null ? null : resetsAt / 1000, samples }
|
|
122
|
+
: null;
|
|
123
|
+
}
|
|
124
|
+
|
|
125
|
+
// A ledger that can't be read is a source with nothing in it.
|
|
126
|
+
const quietly = <T>(read: () => T | null): T | null => {
|
|
127
|
+
try {
|
|
128
|
+
return read();
|
|
129
|
+
} catch {
|
|
130
|
+
return null;
|
|
131
|
+
}
|
|
132
|
+
};
|
|
133
|
+
|
|
134
|
+
const clock = (sec: number, nowSec: number): string => {
|
|
135
|
+
const d = new Date(sec * 1000);
|
|
136
|
+
const time = d.toLocaleTimeString("en-US", {
|
|
137
|
+
hour: "numeric",
|
|
138
|
+
minute: "2-digit",
|
|
139
|
+
});
|
|
140
|
+
return d.toDateString() === new Date(nowSec * 1000).toDateString()
|
|
141
|
+
? time
|
|
142
|
+
: `${d.toLocaleDateString("en-US", { month: "short", day: "numeric" })}, ${time}`;
|
|
143
|
+
};
|
|
144
|
+
|
|
145
|
+
// Why the window isn't known: nothing was ever sampled, no agent has run
|
|
146
|
+
// since the last sample, or agents ran without reporting it.
|
|
147
|
+
export function unknownText(
|
|
148
|
+
sampledAt: number | null,
|
|
149
|
+
activityAt: number | null,
|
|
150
|
+
nowSec: number
|
|
151
|
+
): string {
|
|
152
|
+
if (sampledAt === null)
|
|
153
|
+
return "No agent has reported the usage window yet, so usage isn't sampled";
|
|
154
|
+
if (activityAt === null || activityAt / 1000 <= sampledAt + 60)
|
|
155
|
+
return `No agent has run since ${clock(sampledAt, nowSec)}, so usage isn't sampled`;
|
|
156
|
+
return `Agents ran at ${clock(activityAt / 1000, nowSec)} but didn't report the usage window; it was last sampled at ${clock(sampledAt, nowSec)}`;
|
|
157
|
+
}
|
|
158
|
+
|
|
159
|
+
export function readUsage(
|
|
160
|
+
file = LIMITS_FILE,
|
|
161
|
+
nowSec = Date.now() / 1000,
|
|
162
|
+
stored: () => Series | null = storedSeries,
|
|
163
|
+
activity: () => number | null = lastAgentActivity
|
|
164
|
+
): UsageState {
|
|
165
|
+
const newestAt = (s: Series) => s.samples[s.samples.length - 1][0];
|
|
166
|
+
const sources = [toSeries(readLimitsFile(file)), quietly(stored)].filter(
|
|
167
|
+
(s): s is Series => s !== null
|
|
168
|
+
);
|
|
169
|
+
const chosen = sources.sort((a, b) => newestAt(b) - newestAt(a))[0] ?? null;
|
|
170
|
+
const sampledAt = chosen ? newestAt(chosen) : null;
|
|
171
|
+
if (chosen && sampledAt !== null && nowSec - sampledAt <= UNKNOWN_AFTER)
|
|
172
|
+
return { window: usageWindow(chosen, nowSec) };
|
|
173
|
+
const activityAt = quietly(activity);
|
|
174
|
+
const newest = chosen && chosen.samples[chosen.samples.length - 1];
|
|
175
|
+
return {
|
|
176
|
+
unknown: unknownText(sampledAt, activityAt, nowSec),
|
|
177
|
+
last:
|
|
178
|
+
chosen && newest && sampledAt !== null
|
|
179
|
+
? { pct: Math.trunc(newest[1]), resetsAt: chosen.window, sampledAt }
|
|
180
|
+
: null,
|
|
181
|
+
};
|
|
100
182
|
}
|
|
101
183
|
|
|
102
184
|
function minutes(seconds: number): string {
|
|
@@ -104,11 +186,21 @@ function minutes(seconds: number): string {
|
|
|
104
186
|
return m >= 60 ? `${Math.floor(m / 60)}h${m % 60}m` : `${m}m`;
|
|
105
187
|
}
|
|
106
188
|
|
|
107
|
-
// Why the window refuses a new start, or null.
|
|
189
|
+
// Why the window refuses a new start, or null. An unknown window refuses
|
|
190
|
+
// unless it was last seen low or its window has since reset: nothing is
|
|
191
|
+
// sampling, so the figure can't have moved, and the next start's own run
|
|
192
|
+
// samples it again.
|
|
108
193
|
export function windowRefusal(
|
|
109
|
-
state: UsageState | UsageWindow | null
|
|
194
|
+
state: UsageState | UsageWindow | null,
|
|
195
|
+
nowSec = Date.now() / 1000
|
|
110
196
|
): string | null {
|
|
111
|
-
if (state && "unknown" in state)
|
|
197
|
+
if (state && "unknown" in state) {
|
|
198
|
+
const last = state.last;
|
|
199
|
+
if (!last) return state.unknown;
|
|
200
|
+
if (last.resetsAt !== null && last.resetsAt <= nowSec) return null;
|
|
201
|
+
if (last.pct < STALE_REFUSE_PCT) return null;
|
|
202
|
+
return `${state.unknown}; it was ${last.pct}% used when last seen${last.resetsAt === null ? "" : ` and resets at ${clock(last.resetsAt, nowSec)}`}`;
|
|
203
|
+
}
|
|
112
204
|
const w = state && "window" in state ? state.window : state;
|
|
113
205
|
if (!w) return null;
|
|
114
206
|
if (w.pct >= 100)
|
package/lib/plans/features.ts
CHANGED
|
@@ -7,6 +7,7 @@ import path from "path";
|
|
|
7
7
|
import { randomUUID } from "crypto";
|
|
8
8
|
import { db, type Session } from "../db";
|
|
9
9
|
import { loadProjectConfig } from "../project-config";
|
|
10
|
+
import { repoSlugSync, slugOfRemote } from "../git-remote";
|
|
10
11
|
import {
|
|
11
12
|
applyEdits,
|
|
12
13
|
checkPieces,
|
|
@@ -77,6 +78,33 @@ function ref(id: string): ProjectRef | null {
|
|
|
77
78
|
return { id: p.id, name: p.name, deploys: !!deploy?.deployed };
|
|
78
79
|
}
|
|
79
80
|
|
|
81
|
+
// A repo named in a plan resolves by project name, then by the repo's folder
|
|
82
|
+
// name, then by its GitHub owner/name (or just the name part), ignoring case.
|
|
83
|
+
// A folder or GitHub match must be a single project: two would be a guess.
|
|
84
|
+
export function resolveRepo<
|
|
85
|
+
T extends { id: string; name: string; working_directory: string },
|
|
86
|
+
>(rows: T[], token: string): T | null {
|
|
87
|
+
const want = token
|
|
88
|
+
.trim()
|
|
89
|
+
.replace(/^https?:\/\/github\.com\//i, "")
|
|
90
|
+
.replace(/\.git$/i, "")
|
|
91
|
+
.replace(/\/+$/, "")
|
|
92
|
+
.toLowerCase();
|
|
93
|
+
if (!want) return null;
|
|
94
|
+
const named = rows.filter((r) => r.name.toLowerCase() === want);
|
|
95
|
+
if (named.length) return named.length === 1 ? named[0] : null;
|
|
96
|
+
const folder = (r: T) =>
|
|
97
|
+
path.basename(expandHome(r.working_directory)).toLowerCase();
|
|
98
|
+
const byFolder = rows.filter((r) => folder(r) === want);
|
|
99
|
+
if (byFolder.length) return byFolder.length === 1 ? byFolder[0] : null;
|
|
100
|
+
const asSlug = slugOfRemote(`https://github.com/${want}`) ?? null;
|
|
101
|
+
const bySlug = rows.filter((r) => {
|
|
102
|
+
const slug = repoSlugSync(expandHome(r.working_directory));
|
|
103
|
+
return slug && (slug === asSlug || slug.split("/")[1] === want);
|
|
104
|
+
});
|
|
105
|
+
return bySlug.length === 1 ? bySlug[0] : null;
|
|
106
|
+
}
|
|
107
|
+
|
|
80
108
|
export function checkContext(
|
|
81
109
|
s: Pick<Session, "project_id">
|
|
82
110
|
): CheckContext | null {
|
|
@@ -84,16 +112,14 @@ export function checkContext(
|
|
|
84
112
|
if (!own) return null;
|
|
85
113
|
const rows = db
|
|
86
114
|
.prepare(
|
|
87
|
-
`SELECT id, name FROM projects
|
|
115
|
+
`SELECT id, name, working_directory FROM projects
|
|
88
116
|
WHERE workspace_id = (SELECT workspace_id FROM projects WHERE id = ?)`
|
|
89
117
|
)
|
|
90
|
-
.all(own.id) as { id: string; name: string }[];
|
|
118
|
+
.all(own.id) as { id: string; name: string; working_directory: string }[];
|
|
91
119
|
return {
|
|
92
120
|
own,
|
|
93
121
|
find: (name) => {
|
|
94
|
-
const hit = rows
|
|
95
|
-
(r) => r.name.toLowerCase() === name.trim().toLowerCase()
|
|
96
|
-
);
|
|
122
|
+
const hit = resolveRepo(rows, name);
|
|
97
123
|
return hit ? ref(hit.id) : null;
|
|
98
124
|
},
|
|
99
125
|
};
|
|
@@ -122,10 +148,25 @@ export function piecesOf(featureId: string): FeaturePiece[] {
|
|
|
122
148
|
|
|
123
149
|
export function pieceOfSession(sessionId: string): FeaturePiece | undefined {
|
|
124
150
|
return db
|
|
125
|
-
.prepare(
|
|
151
|
+
.prepare(
|
|
152
|
+
`SELECT * FROM feature_pieces WHERE session_id = ? ORDER BY position`
|
|
153
|
+
)
|
|
126
154
|
.get(sessionId) as FeaturePiece | undefined;
|
|
127
155
|
}
|
|
128
156
|
|
|
157
|
+
// The PR a piece's task merged, when it is in the piece's own repository: a
|
|
158
|
+
// session's PR is always in its project's repo, so another project's merge is
|
|
159
|
+
// not this piece's work.
|
|
160
|
+
export function prOf(p: FeaturePiece): number | null {
|
|
161
|
+
if (!p.session_id) return null;
|
|
162
|
+
const s = db
|
|
163
|
+
.prepare(`SELECT pr_number, project_id FROM sessions WHERE id = ?`)
|
|
164
|
+
.get(p.session_id) as
|
|
165
|
+
| { pr_number: number | null; project_id: string | null }
|
|
166
|
+
| undefined;
|
|
167
|
+
return s && s.project_id === p.project_id ? s.pr_number : null;
|
|
168
|
+
}
|
|
169
|
+
|
|
129
170
|
export function featureOfPlan(sessionId: string): Feature | undefined {
|
|
130
171
|
return db
|
|
131
172
|
.prepare(`SELECT * FROM features WHERE plan_session_id = ?`)
|
package/lib/plans/pieces.ts
CHANGED
|
@@ -208,7 +208,7 @@ export function checkPieces(text: string, ctx: CheckContext): PiecesResult {
|
|
|
208
208
|
const found = ctx.find(r.repo);
|
|
209
209
|
if (!found)
|
|
210
210
|
errors.push(
|
|
211
|
-
`"${r.name}": (repo: ${r.repo})
|
|
211
|
+
`"${r.name}": (repo: ${r.repo}) doesn't match one project of this workspace (by name, folder or GitHub repo)`
|
|
212
212
|
);
|
|
213
213
|
return found ?? ctx.own;
|
|
214
214
|
});
|
package/lib/plans/release.ts
CHANGED
|
@@ -9,13 +9,14 @@ import { createTask } from "../tasks";
|
|
|
9
9
|
import { startOrQueue } from "../tasks/queue";
|
|
10
10
|
import { braked, BrakeRefused } from "../orchestrator/brakes";
|
|
11
11
|
import { queueEvent, queueEventOnce } from "../orchestrator/events";
|
|
12
|
-
import { amendBrief } from "../orchestrator/amendments";
|
|
12
|
+
import { amendBrief, amendmentsOf } from "../orchestrator/amendments";
|
|
13
13
|
import { usageRows } from "../session-logs/gather";
|
|
14
14
|
import { pieceBrief } from "./pieces";
|
|
15
15
|
import {
|
|
16
16
|
getFeature,
|
|
17
17
|
planPathOf,
|
|
18
18
|
piecesOf,
|
|
19
|
+
prOf,
|
|
19
20
|
type Feature,
|
|
20
21
|
type FeaturePiece,
|
|
21
22
|
} from "./features";
|
|
@@ -46,8 +47,10 @@ const setPiece = (id: number, status: string, note: string | null = null) =>
|
|
|
46
47
|
.prepare(`UPDATE feature_pieces SET status = ?, note = ? WHERE id = ?`)
|
|
47
48
|
.run(status, note, id);
|
|
48
49
|
|
|
50
|
+
const RELEASED_ANYWAY = "released anyway";
|
|
51
|
+
|
|
49
52
|
// What a piece's task did: merged, or ended some other way.
|
|
50
|
-
function syncPieces(pieces: FeaturePiece[]): void {
|
|
53
|
+
function syncPieces(feature: Feature, pieces: FeaturePiece[]): void {
|
|
51
54
|
for (const p of pieces) {
|
|
52
55
|
if (p.status !== "building") continue;
|
|
53
56
|
const s = sessionOf(p.session_id);
|
|
@@ -65,8 +68,19 @@ function syncPieces(pieces: FeaturePiece[]): void {
|
|
|
65
68
|
else if (!q) setPiece(p.id, "blocked", "its task is gone");
|
|
66
69
|
continue;
|
|
67
70
|
}
|
|
68
|
-
if (s.task_status === "merged")
|
|
69
|
-
|
|
71
|
+
if (s.task_status === "merged") {
|
|
72
|
+
// A PR counts only for a piece in its own repository.
|
|
73
|
+
if (s.project_id && s.project_id !== p.project_id) {
|
|
74
|
+
const where = getProject(s.project_id)?.name ?? "another project";
|
|
75
|
+
const note = `its task merged #${s.pr_number ?? "?"} in ${where}, not in ${getProject(p.project_id)?.name ?? "this piece's project"}`;
|
|
76
|
+
setPiece(p.id, "blocked", note);
|
|
77
|
+
say(
|
|
78
|
+
feature,
|
|
79
|
+
`wrong-repo-${p.id}`,
|
|
80
|
+
`Feature "${feature.name}": piece ${p.position} ${p.name} ${note}. If that PR already built it, drop the piece; otherwise release it anyway.`
|
|
81
|
+
);
|
|
82
|
+
} else setPiece(p.id, "merged");
|
|
83
|
+
} else if (s.task_status === "dropped" || s.task_status === "done")
|
|
70
84
|
setPiece(p.id, "dropped", `its task was ${s.task_status}`);
|
|
71
85
|
}
|
|
72
86
|
}
|
|
@@ -141,11 +155,77 @@ function beforeLines(all: FeaturePiece[], upTo: number): string[] {
|
|
|
141
155
|
return all
|
|
142
156
|
.filter((p) => p.position < upTo && p.status === "merged")
|
|
143
157
|
.map((p) => {
|
|
144
|
-
const pr =
|
|
158
|
+
const pr = prOf(p);
|
|
145
159
|
return `${p.position} ${p.name} merged${pr ? ` as #${pr}` : ""}`;
|
|
146
160
|
});
|
|
147
161
|
}
|
|
148
162
|
|
|
163
|
+
// The planning task's own merge covers what it built in its repository: its
|
|
164
|
+
// own piece there, else every piece still waiting there. A recorded scope
|
|
165
|
+
// change means it may have built only part, so nothing is credited and the
|
|
166
|
+
// duplicate guard asks a person instead.
|
|
167
|
+
function creditParentMerge(feature: Feature, all: FeaturePiece[]): void {
|
|
168
|
+
const parent = sessionOf(feature.plan_session_id);
|
|
169
|
+
if (!parent || parent.task_status !== "merged" || !parent.project_id) return;
|
|
170
|
+
if (
|
|
171
|
+
all.some(
|
|
172
|
+
(p) => p.session_id === parent.id && p.project_id === parent.project_id
|
|
173
|
+
)
|
|
174
|
+
)
|
|
175
|
+
return;
|
|
176
|
+
if (amendmentsOf(parent.id).length) return;
|
|
177
|
+
const covered = all.filter(
|
|
178
|
+
(p) => p.status === "waiting" && p.project_id === parent.project_id
|
|
179
|
+
);
|
|
180
|
+
if (!covered.length) return;
|
|
181
|
+
for (const p of covered)
|
|
182
|
+
db.prepare(
|
|
183
|
+
`UPDATE feature_pieces SET status = 'merged', session_id = ?, note = ?
|
|
184
|
+
WHERE id = ? AND status = 'waiting'`
|
|
185
|
+
).run(
|
|
186
|
+
parent.id,
|
|
187
|
+
`built by the planning task in #${parent.pr_number ?? "?"}`,
|
|
188
|
+
p.id
|
|
189
|
+
);
|
|
190
|
+
queueEventOnce(
|
|
191
|
+
feature.workspace_id,
|
|
192
|
+
`feature:parent-built-${feature.id}-${parent.pr_number}`,
|
|
193
|
+
feature.plan_session_id,
|
|
194
|
+
`Feature "${feature.name}": the planning task's own PR #${parent.pr_number} built ${covered.map((p) => `${p.position} ${p.name}`).join(", ")}, so ${covered.length === 1 ? "that piece is" : "those pieces are"} marked merged and won't start again.`
|
|
195
|
+
);
|
|
196
|
+
}
|
|
197
|
+
|
|
198
|
+
// A merged PR from this feature in the piece's repository that no merged
|
|
199
|
+
// piece there accounts for may already contain the piece's work.
|
|
200
|
+
function unaccountedMerge(
|
|
201
|
+
feature: Feature,
|
|
202
|
+
all: FeaturePiece[],
|
|
203
|
+
piece: FeaturePiece
|
|
204
|
+
): Session | null {
|
|
205
|
+
const ids = new Set<string>([
|
|
206
|
+
feature.plan_session_id,
|
|
207
|
+
...all.map((p) => p.session_id).filter((id): id is string => !!id),
|
|
208
|
+
]);
|
|
209
|
+
for (const id of ids) {
|
|
210
|
+
const s = sessionOf(id);
|
|
211
|
+
if (
|
|
212
|
+
!s ||
|
|
213
|
+
s.task_status !== "merged" ||
|
|
214
|
+
!s.pr_number ||
|
|
215
|
+
s.project_id !== piece.project_id
|
|
216
|
+
)
|
|
217
|
+
continue;
|
|
218
|
+
const accounted = all.some(
|
|
219
|
+
(p) =>
|
|
220
|
+
p.status === "merged" &&
|
|
221
|
+
p.session_id === id &&
|
|
222
|
+
p.project_id === piece.project_id
|
|
223
|
+
);
|
|
224
|
+
if (!accounted) return s;
|
|
225
|
+
}
|
|
226
|
+
return null;
|
|
227
|
+
}
|
|
228
|
+
|
|
149
229
|
async function releaseOne(
|
|
150
230
|
feature: Feature,
|
|
151
231
|
all: FeaturePiece[],
|
|
@@ -183,6 +263,20 @@ async function releaseOne(
|
|
|
183
263
|
}
|
|
184
264
|
}
|
|
185
265
|
}
|
|
266
|
+
if (piece.note !== RELEASED_ANYWAY) {
|
|
267
|
+
const dup = unaccountedMerge(feature, all, piece);
|
|
268
|
+
if (dup) {
|
|
269
|
+
const where = getProject(piece.project_id)?.name ?? "its project";
|
|
270
|
+
const note = `${where} already has #${dup.pr_number} merged from this feature; check it doesn't already build this`;
|
|
271
|
+
setPiece(piece.id, "blocked", note);
|
|
272
|
+
say(
|
|
273
|
+
feature,
|
|
274
|
+
`duplicate-${piece.id}`,
|
|
275
|
+
`Feature "${feature.name}": piece ${piece.position} ${piece.name} is held: ${note}. Release it anyway to start it, or drop it.`
|
|
276
|
+
);
|
|
277
|
+
return;
|
|
278
|
+
}
|
|
279
|
+
}
|
|
186
280
|
const claimed = db
|
|
187
281
|
.prepare(
|
|
188
282
|
`UPDATE feature_pieces SET status = 'building', session_id = ?, note = NULL
|
|
@@ -229,8 +323,8 @@ function close(feature: Feature, all: FeaturePiece[], deps: ReleaseDeps): void {
|
|
|
229
323
|
return;
|
|
230
324
|
}
|
|
231
325
|
if (live.some((p) => p.status !== "merged")) return;
|
|
232
|
-
const sessions = live
|
|
233
|
-
.map((
|
|
326
|
+
const sessions = [...new Set(live.map((p) => p.session_id))]
|
|
327
|
+
.map((id) => sessionOf(id))
|
|
234
328
|
.filter(Boolean) as Session[];
|
|
235
329
|
const cost = sessions.reduce(
|
|
236
330
|
(n, s) => n + usageRows(s.id).reduce((m, r) => m + r.costUsd, 0),
|
|
@@ -265,7 +359,7 @@ function close(feature: Feature, all: FeaturePiece[], deps: ReleaseDeps): void {
|
|
|
265
359
|
say(
|
|
266
360
|
feature,
|
|
267
361
|
`done-${feature.id}`,
|
|
268
|
-
`Feature "${feature.name}" is merged: ${
|
|
362
|
+
`Feature "${feature.name}" is merged: ${sessions.length} ${sessions.length === 1 ? "PR" : "PRs"}.${short}`
|
|
269
363
|
);
|
|
270
364
|
deps.pushFeatureMerged(feature);
|
|
271
365
|
}
|
|
@@ -302,7 +396,8 @@ export async function releasePieces(
|
|
|
302
396
|
.prepare(`SELECT * FROM features WHERE status = 'building'`)
|
|
303
397
|
.all() as Feature[];
|
|
304
398
|
for (const feature of features) {
|
|
305
|
-
syncPieces(piecesOf(feature.id));
|
|
399
|
+
syncPieces(feature, piecesOf(feature.id));
|
|
400
|
+
creditParentMerge(feature, piecesOf(feature.id));
|
|
306
401
|
const all = piecesOf(feature.id);
|
|
307
402
|
for (const piece of all.filter((p) => p.status === "waiting"))
|
|
308
403
|
await releaseOne(feature, piecesOf(feature.id), piece, deps).catch((e) =>
|
|
@@ -319,9 +414,9 @@ export function releaseAnyway(pieceId: number): boolean {
|
|
|
319
414
|
db
|
|
320
415
|
.prepare(
|
|
321
416
|
`UPDATE feature_pieces SET status = 'waiting', after_position = NULL,
|
|
322
|
-
after_kind = NULL, note =
|
|
417
|
+
after_kind = NULL, note = ? WHERE id = ? AND status = 'blocked'`
|
|
323
418
|
)
|
|
324
|
-
.run(pieceId).changes > 0
|
|
419
|
+
.run(RELEASED_ANYWAY, pieceId).changes > 0
|
|
325
420
|
);
|
|
326
421
|
}
|
|
327
422
|
|
package/lib/plans/view.ts
CHANGED
|
@@ -2,7 +2,7 @@
|
|
|
2
2
|
// is, and each piece's state in one line.
|
|
3
3
|
|
|
4
4
|
import { db } from "../db";
|
|
5
|
-
import { piecesOf, type Feature, type FeaturePiece } from "./features";
|
|
5
|
+
import { piecesOf, prOf, type Feature, type FeaturePiece } from "./features";
|
|
6
6
|
|
|
7
7
|
export interface PieceView {
|
|
8
8
|
id: number;
|
|
@@ -40,13 +40,7 @@ const LINE = {
|
|
|
40
40
|
};
|
|
41
41
|
|
|
42
42
|
function pieceView(p: FeaturePiece): PieceView {
|
|
43
|
-
const pr = p
|
|
44
|
-
? ((
|
|
45
|
-
db
|
|
46
|
-
.prepare(`SELECT pr_number FROM sessions WHERE id = ?`)
|
|
47
|
-
.get(p.session_id) as { pr_number: number | null } | undefined
|
|
48
|
-
)?.pr_number ?? null)
|
|
49
|
-
: null;
|
|
43
|
+
const pr = prOf(p);
|
|
50
44
|
return {
|
|
51
45
|
id: p.id,
|
|
52
46
|
position: p.position,
|
package/lib/usage/rate-limits.ts
CHANGED
|
@@ -32,23 +32,38 @@ const num = (v: unknown) =>
|
|
|
32
32
|
const eventPercent = (fraction: number) => fraction * 100;
|
|
33
33
|
|
|
34
34
|
// Readings in what the CLI emits: a rate_limit_event's info, or a plan's
|
|
35
|
-
// per-window object. Anything else gives none.
|
|
35
|
+
// per-window object. Anything else gives none. An event's top-level reading
|
|
36
|
+
// is only there when a threshold was crossed, while its `unifiedWindows`
|
|
37
|
+
// (read from the same response headers) carries both windows every time, so
|
|
38
|
+
// that one wins for a window both name.
|
|
36
39
|
export function parseRateLimit(info: unknown): Reading[] {
|
|
37
40
|
if (!info || typeof info !== "object") return [];
|
|
38
41
|
const i = info as Record<string, unknown>;
|
|
39
|
-
const out
|
|
42
|
+
const out = new Map<LimitWindow, Reading>();
|
|
40
43
|
const status = typeof i.status === "string" ? i.status : null;
|
|
41
|
-
const
|
|
42
|
-
|
|
43
|
-
|
|
44
|
-
|
|
45
|
-
|
|
46
|
-
|
|
44
|
+
const eventReading = (
|
|
45
|
+
window: LimitWindow,
|
|
46
|
+
fraction: unknown,
|
|
47
|
+
resetsAt: unknown
|
|
48
|
+
) => {
|
|
49
|
+
const raw = num(fraction);
|
|
50
|
+
if (raw === null) return;
|
|
51
|
+
const reset = num(resetsAt);
|
|
52
|
+
out.set(window, {
|
|
53
|
+
window,
|
|
47
54
|
utilization: eventPercent(raw),
|
|
48
55
|
raw,
|
|
49
56
|
resetsAt: reset === null ? null : reset * 1000,
|
|
50
57
|
status,
|
|
51
58
|
});
|
|
59
|
+
};
|
|
60
|
+
const type = i.rateLimitType;
|
|
61
|
+
if (type === "five_hour" || type === "seven_day")
|
|
62
|
+
eventReading(type, i.utilization, i.resetsAt);
|
|
63
|
+
const unified = i.unifiedWindows as Record<string, unknown> | undefined;
|
|
64
|
+
for (const w of WINDOWS) {
|
|
65
|
+
const o = unified?.[w] as Record<string, unknown> | null | undefined;
|
|
66
|
+
if (o && typeof o === "object") eventReading(w, o.utilization, o.resetsAt);
|
|
52
67
|
}
|
|
53
68
|
for (const w of WINDOWS) {
|
|
54
69
|
const o = i[w] as Record<string, unknown> | null | undefined;
|
|
@@ -56,7 +71,7 @@ export function parseRateLimit(info: unknown): Reading[] {
|
|
|
56
71
|
if (!o || u === null || u === undefined) continue;
|
|
57
72
|
const reset =
|
|
58
73
|
typeof o.resets_at === "string" ? Date.parse(o.resets_at) : NaN;
|
|
59
|
-
out.
|
|
74
|
+
out.set(w, {
|
|
60
75
|
window: w,
|
|
61
76
|
utilization: u,
|
|
62
77
|
raw: u,
|
|
@@ -64,11 +79,16 @@ export function parseRateLimit(info: unknown): Reading[] {
|
|
|
64
79
|
status,
|
|
65
80
|
});
|
|
66
81
|
}
|
|
67
|
-
return out;
|
|
82
|
+
return [...out.values()];
|
|
68
83
|
}
|
|
69
84
|
|
|
85
|
+
// An unchanged reading refreshes the last row's time at most this often, so
|
|
86
|
+
// the newest row's `at` is when the value was last seen, not when it moved.
|
|
87
|
+
const REFRESH_MS = 60_000;
|
|
88
|
+
|
|
70
89
|
// Stores a reading only when it differs from that window's last, so a busy
|
|
71
|
-
// day adds a handful of rows, not one per response.
|
|
90
|
+
// day adds a handful of rows, not one per response. A reading that hasn't
|
|
91
|
+
// moved only touches the last row's time. Returns the rows added.
|
|
72
92
|
export function recordReadings(
|
|
73
93
|
readings: Reading[],
|
|
74
94
|
source: string,
|
|
@@ -79,19 +99,31 @@ export function recordReadings(
|
|
|
79
99
|
for (const r of readings) {
|
|
80
100
|
const last = db
|
|
81
101
|
.prepare(
|
|
82
|
-
`SELECT utilization, resets_at, status FROM rate_limit_readings
|
|
102
|
+
`SELECT id, at, utilization, resets_at, status FROM rate_limit_readings
|
|
83
103
|
WHERE window = ? ORDER BY id DESC LIMIT 1`
|
|
84
104
|
)
|
|
85
105
|
.get(r.window) as
|
|
86
|
-
| {
|
|
106
|
+
| {
|
|
107
|
+
id: number;
|
|
108
|
+
at: number;
|
|
109
|
+
utilization: number;
|
|
110
|
+
resets_at: number | null;
|
|
111
|
+
status: string | null;
|
|
112
|
+
}
|
|
87
113
|
| undefined;
|
|
88
114
|
if (
|
|
89
115
|
last &&
|
|
90
116
|
last.utilization === r.utilization &&
|
|
91
117
|
last.resets_at === r.resetsAt &&
|
|
92
118
|
last.status === r.status
|
|
93
|
-
)
|
|
119
|
+
) {
|
|
120
|
+
if (at - last.at >= REFRESH_MS)
|
|
121
|
+
db.prepare(`UPDATE rate_limit_readings SET at = ? WHERE id = ?`).run(
|
|
122
|
+
at,
|
|
123
|
+
last.id
|
|
124
|
+
);
|
|
94
125
|
continue;
|
|
126
|
+
}
|
|
95
127
|
db.prepare(
|
|
96
128
|
`INSERT INTO rate_limit_readings (at, window, utilization, raw, resets_at, status, session_id, source)
|
|
97
129
|
VALUES (?, ?, ?, ?, ?, ?, ?, ?)`
|
|
@@ -135,6 +167,38 @@ export function lastReading(
|
|
|
135
167
|
: null;
|
|
136
168
|
}
|
|
137
169
|
|
|
170
|
+
// A window's readings in its current reset period over the hour up to the
|
|
171
|
+
// newest, oldest first as [epoch seconds, percent]: the series a burn rate
|
|
172
|
+
// is fitted to. Empty when there are none.
|
|
173
|
+
export function windowSamples(
|
|
174
|
+
window: LimitWindow,
|
|
175
|
+
spanMs = 3_600_000
|
|
176
|
+
): { resetsAt: number | null; samples: [number, number][] } {
|
|
177
|
+
const newest = lastReading(window);
|
|
178
|
+
if (!newest) return { resetsAt: null, samples: [] };
|
|
179
|
+
const rows = db
|
|
180
|
+
.prepare(
|
|
181
|
+
`SELECT at, utilization FROM rate_limit_readings
|
|
182
|
+
WHERE window = ? AND resets_at IS ? AND at >= ? ORDER BY at, id`
|
|
183
|
+
)
|
|
184
|
+
.all(window, newest.resetsAt, newest.at - spanMs) as {
|
|
185
|
+
at: number;
|
|
186
|
+
utilization: number;
|
|
187
|
+
}[];
|
|
188
|
+
return {
|
|
189
|
+
resetsAt: newest.resetsAt,
|
|
190
|
+
samples: rows.map((r) => [r.at / 1000, r.utilization]),
|
|
191
|
+
};
|
|
192
|
+
}
|
|
193
|
+
|
|
194
|
+
// When any agent call was last recorded.
|
|
195
|
+
export function lastAgentActivity(): number | null {
|
|
196
|
+
const r = db.prepare(`SELECT MAX(at) AS at FROM usage_calls`).get() as {
|
|
197
|
+
at: number | null;
|
|
198
|
+
};
|
|
199
|
+
return r.at;
|
|
200
|
+
}
|
|
201
|
+
|
|
138
202
|
const WEEK = 7 * 24 * 3600 * 1000;
|
|
139
203
|
|
|
140
204
|
export function weightedBetween(from: number, to: number): number {
|
package/lib/usage/turns.ts
CHANGED
|
@@ -33,7 +33,7 @@ function savedTotals(sessionId: string): UsageTotals | null {
|
|
|
33
33
|
}
|
|
34
34
|
|
|
35
35
|
// The plan's 5-hour window when a turn on it ended, for its share of the
|
|
36
|
-
// plan (lib/usage/compare): unknown when
|
|
36
|
+
// plan (lib/usage/compare): unknown when nothing is sampling it.
|
|
37
37
|
export function planWindowPct(): number | null {
|
|
38
38
|
const u = readUsage();
|
|
39
39
|
return "window" in u && u.window ? u.window.pct : null;
|
package/lib/usage/windows.ts
CHANGED
|
@@ -1,15 +1,23 @@
|
|
|
1
1
|
/**
|
|
2
|
-
* The account's rolling usage windows. The 5-hour one comes from the
|
|
3
|
-
*
|
|
4
|
-
*
|
|
5
|
-
*
|
|
2
|
+
* The account's rolling usage windows. The 5-hour one comes from the samples
|
|
3
|
+
* in lib/orchestrator/usage.ts (AgentOS's own agents and the terminal
|
|
4
|
+
* statusline, whichever is fresher), which also give its burn rate; the
|
|
5
|
+
* weekly one from the readings AgentOS's agents store, else the plan's answer
|
|
6
|
+
* to a short-lived agent start (no turn is spent), kept for a few minutes.
|
|
7
|
+
* With nothing fresh, the last value seen is shown as such.
|
|
6
8
|
*/
|
|
7
9
|
|
|
8
10
|
import os from "os";
|
|
9
11
|
import { query, type SDKUserMessage } from "@anthropic-ai/claude-agent-sdk";
|
|
10
12
|
import { InputQueue } from "../chat/queue";
|
|
11
|
-
import { readUsage } from "../orchestrator/usage";
|
|
12
|
-
import {
|
|
13
|
+
import { readUsage, type LastSeen } from "../orchestrator/usage";
|
|
14
|
+
import {
|
|
15
|
+
currentWindow,
|
|
16
|
+
lastReading,
|
|
17
|
+
parseRateLimit,
|
|
18
|
+
recordReadings,
|
|
19
|
+
type WindowReading,
|
|
20
|
+
} from "./rate-limits";
|
|
13
21
|
|
|
14
22
|
export interface WindowView {
|
|
15
23
|
pct: number;
|
|
@@ -20,6 +28,11 @@ export interface WindowView {
|
|
|
20
28
|
burnPerMin?: number | null;
|
|
21
29
|
// Worked out from recorded usage, not read from the account.
|
|
22
30
|
estimated?: boolean;
|
|
31
|
+
// Not sampled lately: the last value seen, and when (epoch ms).
|
|
32
|
+
stale?: boolean;
|
|
33
|
+
sampledAt?: number;
|
|
34
|
+
// Seconds since then, when this was worked out.
|
|
35
|
+
ageSec?: number;
|
|
23
36
|
}
|
|
24
37
|
|
|
25
38
|
export interface UsageWindows {
|
|
@@ -71,7 +84,12 @@ async function askAgent(): Promise<PlanLimits | null> {
|
|
|
71
84
|
);
|
|
72
85
|
}),
|
|
73
86
|
]);
|
|
74
|
-
|
|
87
|
+
const limits = usage.rate_limits_available
|
|
88
|
+
? ((usage.rate_limits ?? null) as PlanLimits | null)
|
|
89
|
+
: null;
|
|
90
|
+
// A plan answer is a reading too: keep it for the screens and brakes.
|
|
91
|
+
if (limits) recordReadings(parseRateLimit(limits), "plan");
|
|
92
|
+
return limits;
|
|
75
93
|
} finally {
|
|
76
94
|
clearTimeout(timer);
|
|
77
95
|
input.end();
|
|
@@ -125,6 +143,34 @@ const fromReading = (
|
|
|
125
143
|
}
|
|
126
144
|
: null;
|
|
127
145
|
|
|
146
|
+
// The last value seen, shown as such, while its window is still open.
|
|
147
|
+
export function fromLast(
|
|
148
|
+
last: LastSeen | null | undefined,
|
|
149
|
+
nowSec = Date.now() / 1000
|
|
150
|
+
): WindowView | null {
|
|
151
|
+
if (!last || (last.resetsAt !== null && last.resetsAt <= nowSec)) return null;
|
|
152
|
+
return {
|
|
153
|
+
pct: last.pct,
|
|
154
|
+
resetsIn:
|
|
155
|
+
last.resetsAt === null ? null : Math.round(last.resetsAt - nowSec),
|
|
156
|
+
stale: true,
|
|
157
|
+
sampledAt: Math.round(last.sampledAt * 1000),
|
|
158
|
+
ageSec: Math.max(0, Math.round(nowSec - last.sampledAt)),
|
|
159
|
+
};
|
|
160
|
+
}
|
|
161
|
+
|
|
162
|
+
function lastWeekly(nowMs: number): WindowView | null {
|
|
163
|
+
const r = lastReading("seven_day");
|
|
164
|
+
return fromLast(
|
|
165
|
+
r && {
|
|
166
|
+
pct: Math.round(r.utilization),
|
|
167
|
+
resetsAt: r.resetsAt === null ? null : r.resetsAt / 1000,
|
|
168
|
+
sampledAt: r.at / 1000,
|
|
169
|
+
},
|
|
170
|
+
nowMs / 1000
|
|
171
|
+
);
|
|
172
|
+
}
|
|
173
|
+
|
|
128
174
|
export async function usageWindows(): Promise<UsageWindows> {
|
|
129
175
|
const sampled = readUsage();
|
|
130
176
|
const read = (w: "five_hour" | "seven_day") => {
|
|
@@ -144,14 +190,23 @@ export async function usageWindows(): Promise<UsageWindows> {
|
|
|
144
190
|
capsIn: sampled.window.capsIn,
|
|
145
191
|
burnPerMin: sampled.window.burnPerMin,
|
|
146
192
|
}
|
|
147
|
-
: (fromPlan(plan.limits?.five_hour) ??
|
|
148
|
-
|
|
193
|
+
: (fromPlan(plan.limits?.five_hour) ??
|
|
194
|
+
read("five_hour") ??
|
|
195
|
+
("unknown" in sampled ? fromLast(sampled.last) : null));
|
|
196
|
+
let sevenDay: WindowView | null = null;
|
|
197
|
+
try {
|
|
198
|
+
sevenDay =
|
|
199
|
+
fromPlan(plan.limits?.seven_day) ??
|
|
200
|
+
read("seven_day") ??
|
|
201
|
+
lastWeekly(Date.now());
|
|
202
|
+
} catch {
|
|
203
|
+
// No readings to show.
|
|
204
|
+
}
|
|
149
205
|
const note =
|
|
150
|
-
!
|
|
151
|
-
?
|
|
152
|
-
|
|
153
|
-
|
|
154
|
-
|
|
155
|
-
: undefined;
|
|
206
|
+
"unknown" in sampled && (!fiveHour || fiveHour.stale)
|
|
207
|
+
? sampled.unknown
|
|
208
|
+
: !sevenDay || !fiveHour
|
|
209
|
+
? (plan.error ?? "the plan's limits aren't available for this login")
|
|
210
|
+
: undefined;
|
|
156
211
|
return { fiveHour, sevenDay, note };
|
|
157
212
|
}
|