@foldrun/core 2.4.0 → 2.6.0
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/dist/src/agent-activity.d.ts +5 -0
- package/dist/src/agent-activity.js +31 -0
- package/dist/src/agent-import.js +2 -2
- package/dist/src/agent-tools.js +2 -2
- package/dist/src/agents-md.d.ts +5 -1
- package/dist/src/agents-md.js +42 -18
- package/dist/src/alerts.d.ts +60 -0
- package/dist/src/alerts.js +161 -0
- package/dist/src/browser-pod.js +4 -0
- package/dist/src/capture.d.ts +23 -0
- package/dist/src/capture.js +80 -0
- package/dist/src/compare.d.ts +57 -0
- package/dist/src/compare.js +116 -0
- package/dist/src/completions.js +26 -3
- package/dist/src/deploy.d.ts +36 -1
- package/dist/src/deploy.js +237 -13
- package/dist/src/egress.d.ts +16 -1
- package/dist/src/evals.d.ts +27 -2
- package/dist/src/evals.js +62 -19
- package/dist/src/flow-lint.d.ts +14 -0
- package/dist/src/flow-lint.js +35 -0
- package/dist/src/flow-patterns.js +29 -14
- package/dist/src/frontmatter-fields.d.ts +27 -0
- package/dist/src/frontmatter-fields.js +60 -3
- package/dist/src/frontmatter-guard.d.ts +19 -0
- package/dist/src/frontmatter-guard.js +67 -0
- package/dist/src/frontmatter-schema.d.ts +2 -2
- package/dist/src/frontmatter-schema.js +33 -2
- package/dist/src/frontmatter-text.d.ts +31 -0
- package/dist/src/frontmatter-text.js +102 -0
- package/dist/src/history.d.ts +4 -0
- package/dist/src/history.js +15 -0
- package/dist/src/hooks.js +1 -1
- package/dist/src/insights.d.ts +30 -0
- package/dist/src/insights.js +152 -0
- package/dist/src/issues.d.ts +140 -0
- package/dist/src/issues.js +302 -0
- package/dist/src/judge-verdict.d.ts +19 -0
- package/dist/src/judge-verdict.js +54 -0
- package/dist/src/library.js +1 -1
- package/dist/src/limits.d.ts +8 -0
- package/dist/src/limits.js +36 -5
- package/dist/src/linkable.js +1 -1
- package/dist/src/matter.d.ts +42 -0
- package/dist/src/matter.js +132 -0
- package/dist/src/model-credential.d.ts +14 -0
- package/dist/src/model-credential.js +40 -0
- package/dist/src/network.d.ts +12 -1
- package/dist/src/network.js +35 -8
- package/dist/src/notify.d.ts +16 -0
- package/dist/src/notify.js +17 -7
- package/dist/src/okf.js +1 -1
- package/dist/src/online-checks.d.ts +76 -0
- package/dist/src/online-checks.js +432 -0
- package/dist/src/operator.d.ts +277 -0
- package/dist/src/operator.js +176 -2
- package/dist/src/otel-config.d.ts +18 -0
- package/dist/src/otel-config.js +69 -0
- package/dist/src/otel.d.ts +38 -0
- package/dist/src/otel.js +85 -0
- package/dist/src/outcome.d.ts +40 -0
- package/dist/src/outcome.js +77 -0
- package/dist/src/package.js +9 -2
- package/dist/src/platform.d.ts +1 -1
- package/dist/src/prompt-layers.d.ts +30 -0
- package/dist/src/prompt-layers.js +77 -0
- package/dist/src/provider-health.js +2 -2
- package/dist/src/providers.d.ts +5 -0
- package/dist/src/providers.js +2 -2
- package/dist/src/run-container.d.ts +25 -2
- package/dist/src/run-container.js +61 -6
- package/dist/src/runner.d.ts +53 -2
- package/dist/src/runner.js +471 -102
- package/dist/src/runs-filter.d.ts +48 -0
- package/dist/src/runs-filter.js +73 -0
- package/dist/src/scan.d.ts +30 -1
- package/dist/src/scan.js +145 -14
- package/dist/src/starter.js +27 -2
- package/dist/src/step-exec.d.ts +39 -3
- package/dist/src/step-exec.js +118 -15
- package/dist/src/store.d.ts +144 -3
- package/dist/src/store.js +150 -7
- package/dist/src/tokens.d.ts +38 -0
- package/dist/src/tokens.js +57 -0
- package/dist/src/tool-names.js +1 -1
- package/dist/src/tool-programs.js +1 -1
- package/dist/src/trace-tree.d.ts +40 -0
- package/dist/src/trace-tree.js +156 -0
- package/dist/src/translator.d.ts +8 -0
- package/dist/src/translator.js +7 -2
- package/dist/src/trigger-log.d.ts +1 -1
- package/dist/src/workspace-check.d.ts +18 -0
- package/dist/src/workspace-check.js +34 -0
- package/package.json +2 -2
- package/src/agent-activity.ts +32 -0
- package/src/agent-import.ts +2 -2
- package/src/agent-tools.ts +2 -2
- package/src/agents-md.ts +39 -14
- package/src/alerts.ts +190 -0
- package/src/browser-pod.ts +3 -0
- package/src/capture.ts +88 -0
- package/src/compare.ts +146 -0
- package/src/completions.ts +27 -3
- package/src/deploy.ts +238 -12
- package/src/egress.ts +12 -2
- package/src/evals.ts +64 -19
- package/src/flow-lint.ts +36 -0
- package/src/flow-patterns.ts +26 -13
- package/src/frontmatter-fields.ts +83 -3
- package/src/frontmatter-guard.ts +70 -0
- package/src/frontmatter-schema.ts +33 -4
- package/src/frontmatter-text.ts +109 -0
- package/src/history.ts +13 -0
- package/src/hooks.ts +1 -1
- package/src/insights.ts +174 -0
- package/src/issues.ts +396 -0
- package/src/judge-verdict.ts +52 -0
- package/src/library.ts +1 -1
- package/src/limits.ts +38 -5
- package/src/linkable.ts +1 -1
- package/src/matter.ts +145 -0
- package/src/model-credential.ts +45 -0
- package/src/network.ts +35 -7
- package/src/notify.ts +22 -7
- package/src/okf.ts +1 -1
- package/src/online-checks.ts +450 -0
- package/src/operator.ts +213 -3
- package/src/otel-config.ts +67 -0
- package/src/otel.ts +100 -0
- package/src/outcome.ts +90 -0
- package/src/package.ts +8 -2
- package/src/platform.ts +1 -1
- package/src/prompt-layers.ts +90 -0
- package/src/provider-health.ts +2 -2
- package/src/providers.ts +7 -2
- package/src/run-container.ts +77 -8
- package/src/runner.ts +477 -79
- package/src/runs-filter.ts +88 -0
- package/src/scan.ts +153 -14
- package/src/starter.ts +27 -2
- package/src/step-exec.ts +168 -17
- package/src/store.ts +268 -9
- package/src/tokens.ts +68 -0
- package/src/tool-names.ts +1 -1
- package/src/tool-programs.ts +1 -1
- package/src/trace-tree.ts +187 -0
- package/src/translator.ts +13 -2
- package/src/trigger-log.ts +1 -0
- package/src/workspace-check.ts +41 -0
package/src/issues.ts
ADDED
|
@@ -0,0 +1,396 @@
|
|
|
1
|
+
// Issues: what went wrong, kept open until a person closes it (FOL-54 step 3).
|
|
2
|
+
//
|
|
3
|
+
// The overview used to list "needs attention" from each flow's newest run in
|
|
4
|
+
// the last day, so a later success hid a failure and anything older than a
|
|
5
|
+
// day vanished on its own. An issue is the same thing going wrong again —
|
|
6
|
+
// one desk's flow failing at one agent, or ending BLOCKED or BAD, or one
|
|
7
|
+
// platform cause across every desk — with when it was first and last seen,
|
|
8
|
+
// how many times, which runs, and what to do. It stays open until someone
|
|
9
|
+
// resolves it; a later good run is shown ("fine since …") but closes nothing.
|
|
10
|
+
// (Closing on its own is opt-in, per flow — step 4.)
|
|
11
|
+
//
|
|
12
|
+
// Issues are derived from the runs; only what people did to them is stored
|
|
13
|
+
// (IssueState: acknowledged, snoozed, resolved, reopened, with a history).
|
|
14
|
+
// A run after the issue was resolved opens it again. Pure: the browser, the
|
|
15
|
+
// API and the CLI read the same function.
|
|
16
|
+
|
|
17
|
+
import type { Outcome } from "./outcome.ts";
|
|
18
|
+
|
|
19
|
+
export type IssueKind = "platform" | "failed" | "blocked" | "bad" | "missed";
|
|
20
|
+
export type IssueStatus = "open" | "acknowledged" | "snoozed" | "resolved";
|
|
21
|
+
export type IssueAction = "acknowledge" | "snooze" | "resolve" | "reopen";
|
|
22
|
+
|
|
23
|
+
/** As much of a run as an issue needs (the overview's RunLite carries it). */
|
|
24
|
+
export interface IssueRun {
|
|
25
|
+
workspace: string;
|
|
26
|
+
id: string;
|
|
27
|
+
/** The flow's name, or "agent: x" / "eval: y". */
|
|
28
|
+
what: string;
|
|
29
|
+
outcome: Outcome;
|
|
30
|
+
startedAt: string;
|
|
31
|
+
finishedAt?: string | null;
|
|
32
|
+
failure?: { agent: string; reason: string } | null;
|
|
33
|
+
headline?: string | null;
|
|
34
|
+
/** The revision the run ran (RunRecord.revision), for "regressed since". */
|
|
35
|
+
revision?: string | null;
|
|
36
|
+
/** Not a run: a scheduled fire that started none (FOL-72 step 7). Its
|
|
37
|
+
* `startedAt` is when it was due; its id is `missed-<when>`. */
|
|
38
|
+
missed?: boolean;
|
|
39
|
+
}
|
|
40
|
+
|
|
41
|
+
export interface IssueEvent {
|
|
42
|
+
at: string;
|
|
43
|
+
/** Who: a person's email, or a key's label. */
|
|
44
|
+
by: string;
|
|
45
|
+
action: IssueAction;
|
|
46
|
+
/** Snoozed until, ISO. */
|
|
47
|
+
until?: string;
|
|
48
|
+
/** Or snoozed until it happens this many more times. */
|
|
49
|
+
untilCount?: number;
|
|
50
|
+
note?: string;
|
|
51
|
+
}
|
|
52
|
+
|
|
53
|
+
/** What people did to one issue. Stored; the rest is derived. */
|
|
54
|
+
export interface IssueState {
|
|
55
|
+
history: IssueEvent[];
|
|
56
|
+
}
|
|
57
|
+
|
|
58
|
+
export interface Issue {
|
|
59
|
+
id: string;
|
|
60
|
+
kind: IssueKind;
|
|
61
|
+
status: IssueStatus;
|
|
62
|
+
/** The desk; for a platform issue, the newest desk it hit. */
|
|
63
|
+
workspace: string;
|
|
64
|
+
/** For a platform issue, every desk it hit. */
|
|
65
|
+
workspaces: string[];
|
|
66
|
+
/** The flow (or agent run); for a platform issue, what broke. */
|
|
67
|
+
what: string;
|
|
68
|
+
/** The agent the failure was at, for a failed issue. */
|
|
69
|
+
agent: string | null;
|
|
70
|
+
/** Why, from the newest run: its failure, or its summary line. */
|
|
71
|
+
reason: string;
|
|
72
|
+
/** What to do about it, in plain words. */
|
|
73
|
+
action: string;
|
|
74
|
+
/** Since it was last resolved (or ever): when the first and the latest of
|
|
75
|
+
* its runs went wrong (their end, else their start), how many. */
|
|
76
|
+
firstSeen: string;
|
|
77
|
+
lastSeen: string;
|
|
78
|
+
count: number;
|
|
79
|
+
/** The newest runs it covers, newest first, at most 20; a missed
|
|
80
|
+
* schedule's are the fires that started nothing (`missed`). */
|
|
81
|
+
runs: { workspace: string; id: string; at: string; missed?: boolean }[];
|
|
82
|
+
/** The newest run of the same flow went fine after the last occurrence. */
|
|
83
|
+
recoveredAt: string | null;
|
|
84
|
+
acknowledged: { at: string; by: string } | null;
|
|
85
|
+
snoozedUntil: string | null;
|
|
86
|
+
/** Snoozed until it happens this many more times: how many are left. */
|
|
87
|
+
snoozedFor: number | null;
|
|
88
|
+
/** It was resolved before and came back: this episode follows a resolve. */
|
|
89
|
+
regressed: boolean;
|
|
90
|
+
/** The revision the first run of this episode ran — "since abc1234". */
|
|
91
|
+
sinceRevision: string | null;
|
|
92
|
+
resolved: { at: string; by: string } | null;
|
|
93
|
+
history: IssueEvent[];
|
|
94
|
+
}
|
|
95
|
+
|
|
96
|
+
/** The model refusing the credential, as the runner sees it (runner.ts
|
|
97
|
+
* isAuthRefusal reads this one pattern, so a refusal the runner retries on
|
|
98
|
+
* is the same refusal an issue groups by — Codex review). */
|
|
99
|
+
export const AUTH_REFUSAL = /API Error: 401|access token has (been revoked|expired)|OAuth token (has )?(been )?(revoked|expired)|Failed to authenticate/i;
|
|
100
|
+
|
|
101
|
+
/** A failure that is the platform's, not the desk's. */
|
|
102
|
+
export interface PlatformCause {
|
|
103
|
+
key: string;
|
|
104
|
+
/** What broke, in the words a person would use. */
|
|
105
|
+
label: string;
|
|
106
|
+
/** What to do about it. None of it is editing the desk. */
|
|
107
|
+
fix: string;
|
|
108
|
+
}
|
|
109
|
+
|
|
110
|
+
// On 2026-09-30 every browser step on every desk failed "login-checker: pod
|
|
111
|
+
// never became ready" — the nightly prune had deleted the runner image — and
|
|
112
|
+
// the overview listed it as a dozen separate desk failures, each looking like
|
|
113
|
+
// that desk's login-checker was broken; on 2026-09-29 the worker could not
|
|
114
|
+
// reach the Kubernetes API for eight minutes ("pod create failed … dial tcp
|
|
115
|
+
// 10.43.0.1:443: i/o timeout"). These are the failures no edit to a
|
|
116
|
+
// flow can cause or fix; they are grouped into one item that says so.
|
|
117
|
+
const PLATFORM_CAUSES: (PlatformCause & { test: RegExp })[] = [
|
|
118
|
+
{
|
|
119
|
+
key: "sandbox",
|
|
120
|
+
label: "Run sandboxes cannot start",
|
|
121
|
+
fix: "the runner image or the node — not the desks. Rerun once it is fixed.",
|
|
122
|
+
test: /pod never became ready|pod create failed|ImagePullBackOff|ErrImagePull|failed to pull image|sandbox (never started|failed to start)/i,
|
|
123
|
+
},
|
|
124
|
+
{
|
|
125
|
+
key: "model-auth",
|
|
126
|
+
label: "The model credential is refused",
|
|
127
|
+
fix: "the Claude sign-in expired or was revoked; sign in again, then rerun.",
|
|
128
|
+
test: new RegExp(
|
|
129
|
+
`${AUTH_REFUSAL.source}|refresh token expired|authentication_error|invalid x-api-key|(claude|anthropic|model)[^\\n]{0,40}\\b401\\b|\\b401\\b[^\\n]{0,40}(claude|anthropic|model)`,
|
|
130
|
+
"i",
|
|
131
|
+
),
|
|
132
|
+
},
|
|
133
|
+
{
|
|
134
|
+
key: "model-cap",
|
|
135
|
+
label: "The model usage cap is reached",
|
|
136
|
+
fix: "runs resume when the cap resets; nothing to change in the desks.",
|
|
137
|
+
test: /weekly (usage )?limit|usage limit (reached|exceeded)|reached your (weekly|usage) limit/i,
|
|
138
|
+
},
|
|
139
|
+
{
|
|
140
|
+
// The provider out of capacity is not the account's cap: it passes in
|
|
141
|
+
// minutes, not at a reset (Codex review).
|
|
142
|
+
key: "model-busy",
|
|
143
|
+
label: "The model provider is overloaded",
|
|
144
|
+
fix: "the provider's capacity, not the desks; rerun in a few minutes.",
|
|
145
|
+
test: /overloaded_error|\b529\b[^\n]{0,40}overloaded|overloaded[^\n]{0,40}\b529\b/i,
|
|
146
|
+
},
|
|
147
|
+
{
|
|
148
|
+
key: "worker",
|
|
149
|
+
label: "The platform lost the run",
|
|
150
|
+
fix: "a worker restarted mid-run; rerun it.",
|
|
151
|
+
test: /worker (restarted|was restarted|died|lost)|run was orphaned|lost its worker/i,
|
|
152
|
+
},
|
|
153
|
+
];
|
|
154
|
+
|
|
155
|
+
/** Whether a failure reason is the platform's, and which kind. */
|
|
156
|
+
export function platformCause(reason: string | null | undefined): PlatformCause | null {
|
|
157
|
+
if (!reason) return null;
|
|
158
|
+
const hit = PLATFORM_CAUSES.find((c) => c.test.test(reason));
|
|
159
|
+
return hit ? { key: hit.key, label: hit.label, fix: hit.fix } : null;
|
|
160
|
+
}
|
|
161
|
+
|
|
162
|
+
/** A short stable id from the issue's key (FNV-1a, 64 bits as two halves). */
|
|
163
|
+
function idOf(key: string): string {
|
|
164
|
+
let a = 0x811c9dc5, b = 0x01000193 ^ key.length;
|
|
165
|
+
for (let i = 0; i < key.length; i++) {
|
|
166
|
+
const c = key.charCodeAt(i);
|
|
167
|
+
a = Math.imul(a ^ c, 0x01000193) >>> 0;
|
|
168
|
+
b = Math.imul(b ^ c, 0x5bd1e995) >>> 0;
|
|
169
|
+
}
|
|
170
|
+
return `iss_${a.toString(36)}${b.toString(36)}`;
|
|
171
|
+
}
|
|
172
|
+
|
|
173
|
+
/** Which issue a run belongs to, or null when it is not a problem. */
|
|
174
|
+
export function issueKeyOf(r: IssueRun): { id: string; kind: IssueKind; agent: string | null; cause: PlatformCause | null } | null {
|
|
175
|
+
if (r.missed) return { id: idOf(`missed\u0000${r.workspace}\u0000${r.what}`), kind: "missed", agent: null, cause: null };
|
|
176
|
+
if (r.outcome === "failed") {
|
|
177
|
+
const cause = platformCause(r.failure?.reason);
|
|
178
|
+
if (cause) return { id: idOf(`platform\u0000${cause.key}`), kind: "platform", agent: null, cause };
|
|
179
|
+
const agent = r.failure?.agent ?? null;
|
|
180
|
+
return { id: idOf(`failed\u0000${r.workspace}\u0000${r.what}\u0000${agent ?? ""}`), kind: "failed", agent, cause: null };
|
|
181
|
+
}
|
|
182
|
+
if (r.outcome === "blocked" || r.outcome === "bad") {
|
|
183
|
+
return { id: idOf(`${r.outcome}\u0000${r.workspace}\u0000${r.what}`), kind: r.outcome, agent: null, cause: null };
|
|
184
|
+
}
|
|
185
|
+
return null;
|
|
186
|
+
}
|
|
187
|
+
|
|
188
|
+
const ACTION: Record<Exclude<IssueKind, "platform">, string> = {
|
|
189
|
+
failed: "Read why it failed, fix the flow or agent, then rerun",
|
|
190
|
+
blocked: "Clear what it is waiting on (a login, a key, an input), then rerun",
|
|
191
|
+
bad: "Read what it found and decide what to do",
|
|
192
|
+
missed: "Check why it did not start: the flow or the account paused, out of credit, or the scheduler down — then run it by hand",
|
|
193
|
+
};
|
|
194
|
+
|
|
195
|
+
const lastOf = (h: IssueEvent[], actions: IssueAction[]) => [...h].reverse().find((e) => actions.includes(e.action)) ?? null;
|
|
196
|
+
|
|
197
|
+
/**
|
|
198
|
+
* Every issue the runs show, with what people did to them folded in.
|
|
199
|
+
* `states` is keyed by issue id. Resolved issues are kept (with their last
|
|
200
|
+
* occurrences) while their runs are still in the window, so their history
|
|
201
|
+
* can be read; a status filter is the caller's.
|
|
202
|
+
*/
|
|
203
|
+
export function foldIssues(
|
|
204
|
+
runs: Iterable<IssueRun>,
|
|
205
|
+
states: Record<string, IssueState>,
|
|
206
|
+
now: number,
|
|
207
|
+
/** `autoClose(workspace, flow)`: the flow says `issues: auto-close`, so a
|
|
208
|
+
* run that goes fine after a failure closes it (FOL-72 step 7). Never a
|
|
209
|
+
* platform cause or a missed schedule — those stay until a person says. */
|
|
210
|
+
opts: { autoClose?: (workspace: string, what: string) => boolean } = {},
|
|
211
|
+
): Issue[] {
|
|
212
|
+
// When it went wrong (or right): its end, else its start. A run that
|
|
213
|
+
// started before a resolve and failed after it is news, and of two
|
|
214
|
+
// overlapping failures the one that ended last is the newest (Codex review).
|
|
215
|
+
const occurred = (r: IssueRun) => r.finishedAt ?? r.startedAt;
|
|
216
|
+
const all = [...runs].sort((a, b) => occurred(a).localeCompare(occurred(b)));
|
|
217
|
+
// The last run of each desk's flow to finish, to say "fine since" — only
|
|
218
|
+
// when it is itself fine, and finished after the issue's last occurrence
|
|
219
|
+
// (a success between two failures, or one that overlapped a later
|
|
220
|
+
// failure, is not a recovery — Codex review).
|
|
221
|
+
const newestDone = new Map<string, IssueRun>();
|
|
222
|
+
for (const r of all) {
|
|
223
|
+
if (r.outcome === "running" || r.outcome === "queued" || r.outcome === "waiting") continue;
|
|
224
|
+
const k = `${r.workspace}\u0000${r.what}`;
|
|
225
|
+
const cur = newestDone.get(k);
|
|
226
|
+
if (!cur || occurred(r) >= occurred(cur)) newestDone.set(k, r);
|
|
227
|
+
}
|
|
228
|
+
const fine = (o: Outcome) => o === "good" || o === "done" || o === "quiet";
|
|
229
|
+
|
|
230
|
+
const groups = new Map<string, { key: NonNullable<ReturnType<typeof issueKeyOf>>; runs: IssueRun[] }>();
|
|
231
|
+
for (const r of all) {
|
|
232
|
+
const key = issueKeyOf(r);
|
|
233
|
+
if (!key) continue;
|
|
234
|
+
const g = groups.get(key.id) ?? { key, runs: [] };
|
|
235
|
+
g.runs.push(r);
|
|
236
|
+
groups.set(key.id, g);
|
|
237
|
+
}
|
|
238
|
+
|
|
239
|
+
const out: Issue[] = [];
|
|
240
|
+
for (const [id, { key, runs: hit }] of groups) {
|
|
241
|
+
const history = states[id]?.history ?? [];
|
|
242
|
+
const res = lastOf(history, ["resolve", "reopen"]);
|
|
243
|
+
// Replay the runs and the resolves/reopens in time order: a run going
|
|
244
|
+
// wrong after a resolve starts a new episode, a resolve closes the
|
|
245
|
+
// current one, a reopen carries it on. The issue shows its current (or
|
|
246
|
+
// last closed) episode — never every one ever closed. A run that ended
|
|
247
|
+
// at the moment of a resolve was closed by it. (Codex review, in rounds.)
|
|
248
|
+
// In a flow that says `issues: auto-close`, every run of it that went
|
|
249
|
+
// fine closes the episode as a resolve would, marked as the platform's.
|
|
250
|
+
const auto = key.kind !== "platform" && key.kind !== "missed" && hit[0] && opts.autoClose?.(hit[0].workspace, hit[0].what) === true;
|
|
251
|
+
const fineRuns = auto ? all.filter((r) => r.workspace === hit[0]!.workspace && r.what === hit[0]!.what && fine(r.outcome)) : [];
|
|
252
|
+
const timeline = [
|
|
253
|
+
...hit.map((r) => ({ t: occurred(r), order: 0, run: r as IssueRun | null, act: null as IssueAction | "auto" | null })),
|
|
254
|
+
...history.filter((e) => e.action === "resolve" || e.action === "reopen").map((e) => ({ t: e.at, order: 1, run: null, act: e.action as IssueAction | "auto" })),
|
|
255
|
+
...fineRuns.map((r) => ({ t: occurred(r), order: 1, run: null, act: "auto" as const })),
|
|
256
|
+
].sort((a, b) => a.t.localeCompare(b.t) || a.order - b.order);
|
|
257
|
+
let shown: IssueRun[] = [];
|
|
258
|
+
let closed = false;
|
|
259
|
+
let closedBy: "person" | "auto" | null = null;
|
|
260
|
+
let autoAt: string | null = null;
|
|
261
|
+
// The last time the platform closed it: an acknowledge or a snooze from
|
|
262
|
+
// before then belongs to the episode it closed (Codex review).
|
|
263
|
+
let lastAutoClose: string | null = null;
|
|
264
|
+
let episodes = 0;
|
|
265
|
+
for (const ev of timeline) {
|
|
266
|
+
if (ev.run) {
|
|
267
|
+
if (closed || !shown.length) {
|
|
268
|
+
shown = [];
|
|
269
|
+
episodes++;
|
|
270
|
+
}
|
|
271
|
+
closed = false;
|
|
272
|
+
shown.push(ev.run);
|
|
273
|
+
} else if (ev.act === "resolve" || (ev.act === "auto" && shown.length)) {
|
|
274
|
+
closed = true;
|
|
275
|
+
closedBy = ev.act === "auto" ? "auto" : "person";
|
|
276
|
+
if (ev.act === "auto") {
|
|
277
|
+
autoAt = ev.t;
|
|
278
|
+
lastAutoClose = ev.t;
|
|
279
|
+
}
|
|
280
|
+
} else if (ev.act === "reopen") closed = false;
|
|
281
|
+
}
|
|
282
|
+
if (!shown.length) continue;
|
|
283
|
+
const resolvedAt = closed ? (closedBy === "auto" ? autoAt : res?.action === "resolve" ? res.at : null) : null;
|
|
284
|
+
const newest = shown[shown.length - 1]!;
|
|
285
|
+
// A snooze holds while its time has not come and, for "until N more",
|
|
286
|
+
// while fewer than N have happened since it.
|
|
287
|
+
const current = (e: IssueEvent | null) => (e && (!lastAutoClose || e.at > lastAutoClose) ? e : null);
|
|
288
|
+
const snoozeEv = current(lastOf(history, ["snooze", "resolve", "reopen"]));
|
|
289
|
+
const since = snoozeEv?.action === "snooze" ? shown.filter((r) => occurred(r) > snoozeEv.at).length : 0;
|
|
290
|
+
const snoozeLeft = snoozeEv?.action === "snooze" && snoozeEv.untilCount ? Math.max(0, snoozeEv.untilCount - since) : null;
|
|
291
|
+
const snoozeHolds =
|
|
292
|
+
snoozeEv?.action === "snooze" &&
|
|
293
|
+
(!snoozeEv.until || Date.parse(snoozeEv.until) > now) &&
|
|
294
|
+
(snoozeLeft === null || snoozeLeft > 0) &&
|
|
295
|
+
Boolean(snoozeEv.until || snoozeEv.untilCount);
|
|
296
|
+
const status: IssueStatus = (() => {
|
|
297
|
+
if (resolvedAt) return "resolved";
|
|
298
|
+
if (snoozeHolds) return "snoozed";
|
|
299
|
+
const ack = current(lastOf(history, ["acknowledge", "resolve", "reopen"]));
|
|
300
|
+
if (ack?.action === "acknowledge") return "acknowledged";
|
|
301
|
+
return "open";
|
|
302
|
+
})();
|
|
303
|
+
const ack = current(lastOf(history, ["acknowledge", "resolve", "reopen"]));
|
|
304
|
+
const flowKey = `${newest.workspace}\u0000${newest.what}`;
|
|
305
|
+
const last = key.kind === "platform" ? null : newestDone.get(flowKey) ?? null;
|
|
306
|
+
const ok = last && fine(last.outcome) && occurred(last) > occurred(newest) ? occurred(last) : null;
|
|
307
|
+
const workspaces = [...new Set(shown.map((r) => r.workspace))].sort();
|
|
308
|
+
out.push({
|
|
309
|
+
id,
|
|
310
|
+
kind: key.kind,
|
|
311
|
+
status,
|
|
312
|
+
workspace: newest.workspace,
|
|
313
|
+
workspaces,
|
|
314
|
+
what: key.cause ? key.cause.label : newest.what,
|
|
315
|
+
agent: key.agent,
|
|
316
|
+
reason: key.kind === "missed"
|
|
317
|
+
? newest.headline ?? `due ${newest.startedAt}, no run started`
|
|
318
|
+
: key.cause
|
|
319
|
+
? newest.failure ? `${newest.failure.agent}: ${newest.failure.reason}` : key.cause.label
|
|
320
|
+
: newest.failure ? `${newest.failure.agent}: ${newest.failure.reason}` : newest.headline?.trim() || newest.outcome,
|
|
321
|
+
action: key.cause ? `Fix ${key.cause.fix}` : ACTION[key.kind as Exclude<IssueKind, "platform">],
|
|
322
|
+
firstSeen: occurred(shown[0]!),
|
|
323
|
+
lastSeen: occurred(newest),
|
|
324
|
+
count: shown.length,
|
|
325
|
+
runs: shown.slice(-20).reverse().map((r) => ({ workspace: r.workspace, id: r.id, at: occurred(r), ...(r.missed ? { missed: true } : {}) })),
|
|
326
|
+
recoveredAt: ok,
|
|
327
|
+
acknowledged: ack?.action === "acknowledge" ? { at: ack.at, by: ack.by } : null,
|
|
328
|
+
snoozedUntil: status === "snoozed" && snoozeEv?.until ? snoozeEv.until : null,
|
|
329
|
+
snoozedFor: status === "snoozed" ? snoozeLeft : null,
|
|
330
|
+
regressed: episodes > 1,
|
|
331
|
+
sinceRevision: shown[0]!.revision ?? null,
|
|
332
|
+
// Only while it is resolved; once it happens again, the history says it was.
|
|
333
|
+
resolved:
|
|
334
|
+
status === "resolved"
|
|
335
|
+
? closedBy === "auto"
|
|
336
|
+
? { at: resolvedAt!, by: "auto: a later run went fine" }
|
|
337
|
+
: res
|
|
338
|
+
? { at: res.at, by: res.by }
|
|
339
|
+
: null
|
|
340
|
+
: null,
|
|
341
|
+
history,
|
|
342
|
+
});
|
|
343
|
+
}
|
|
344
|
+
const rank: Record<IssueStatus, number> = { open: 0, acknowledged: 1, snoozed: 2, resolved: 3 };
|
|
345
|
+
const kindRank: Record<IssueKind, number> = { platform: 0, missed: 1, failed: 2, blocked: 3, bad: 4 };
|
|
346
|
+
return out.sort((a, b) => rank[a.status] - rank[b.status] || kindRank[a.kind] - kindRank[b.kind] || b.lastSeen.localeCompare(a.lastSeen));
|
|
347
|
+
}
|
|
348
|
+
|
|
349
|
+
/** An issue still asking for someone: open or acknowledged, not snoozed or resolved. */
|
|
350
|
+
export const isActiveIssue = (i: Issue) => i.status === "open" || i.status === "acknowledged";
|
|
351
|
+
|
|
352
|
+
/**
|
|
353
|
+
* What the run doctor needs to look at an issue: its newest run, the
|
|
354
|
+
* workspace that run is in (the assistant works inside one), and the
|
|
355
|
+
* question the run page's Doctor button asks — so the issue's button, the
|
|
356
|
+
* CLI's `issues doctor` and the run page put it the same way. Null for a
|
|
357
|
+
* missed schedule, which has no run to read.
|
|
358
|
+
*/
|
|
359
|
+
export function issueDoctor(issue: Pick<Issue, "kind" | "runs">): { workspace: string; runId: string; prompt: string } | null {
|
|
360
|
+
const run = issue.runs.find((r) => !r.missed);
|
|
361
|
+
if (!run || issue.kind === "missed") return null;
|
|
362
|
+
const ended = issue.kind === "blocked" ? "end BLOCKED" : issue.kind === "bad" ? "end BAD" : "fail";
|
|
363
|
+
return { workspace: run.workspace, runId: run.id, prompt: `Doctor: why did run ${run.id} ${ended}, and how do I fix it?` };
|
|
364
|
+
}
|
|
365
|
+
|
|
366
|
+
/** Parse "30m", "4h", "1d", "1w" into milliseconds; null if not one. */
|
|
367
|
+
export function snoozeMs(s: string): number | null {
|
|
368
|
+
const m = /^(\d+)\s*(m|h|d|w)$/.exec(s.trim());
|
|
369
|
+
if (!m) return null;
|
|
370
|
+
const n = Number(m[1]);
|
|
371
|
+
const unit = { m: 60_000, h: 3_600_000, d: 86_400_000, w: 7 * 86_400_000 }[m[2] as "m" | "h" | "d" | "w"];
|
|
372
|
+
return n > 0 ? n * unit : null;
|
|
373
|
+
}
|
|
374
|
+
|
|
375
|
+
/** One person's act on an issue, appended to its history. Refuses a snooze
|
|
376
|
+
* with no future `until`. Pure: the caller stores the result. */
|
|
377
|
+
export function applyIssueAction(state: IssueState | null, ev: IssueEvent, now: number): IssueState {
|
|
378
|
+
if (ev.action === "snooze" && !(ev.until && Date.parse(ev.until) > now) && !(typeof ev.untilCount === "number" && ev.untilCount > 0)) {
|
|
379
|
+
throw new Error("a snooze needs a time in the future, or a number of times it may happen again");
|
|
380
|
+
}
|
|
381
|
+
const history = [...(state?.history ?? []), ev];
|
|
382
|
+
// Bounded: an issue snoozed every morning for a year is still one row. The
|
|
383
|
+
// newest of each kind of act is always kept, whatever its age, because the
|
|
384
|
+
// issue's state is read from them — dropping the last resolve would bring
|
|
385
|
+
// back what it closed (Codex review).
|
|
386
|
+
if (history.length <= 100) return { history };
|
|
387
|
+
const keep = new Set(history.map((_, i) => i).slice(-100));
|
|
388
|
+
for (const a of ["acknowledge", "snooze", "resolve", "reopen"] as IssueAction[]) {
|
|
389
|
+
const i = history.map((e) => e.action).lastIndexOf(a);
|
|
390
|
+
if (i >= 0) keep.add(i);
|
|
391
|
+
}
|
|
392
|
+
// Every resolve and reopen: the episodes an issue shows are replayed from
|
|
393
|
+
// them, and each one is a person's act, so there are few (Codex review).
|
|
394
|
+
history.forEach((e, i) => (e.action === "resolve" || e.action === "reopen") && keep.add(i));
|
|
395
|
+
return { history: history.filter((_, i) => keep.has(i)) };
|
|
396
|
+
}
|
|
@@ -0,0 +1,52 @@
|
|
|
1
|
+
// What a judge said: the PASS or FAIL alone on its last line, after its
|
|
2
|
+
// reasoning. Shared by the eval judge (evals.ts) and a flow step's
|
|
3
|
+
// `verify: judge:` (step-exec.ts), which ask for the same shape.
|
|
4
|
+
//
|
|
5
|
+
// The word comes last because, asked for it first, a judge commits before
|
|
6
|
+
// it has looked and then argues the other way — "FAIL … which meets the
|
|
7
|
+
// criterion" (vaza evals, 21 Oct 2026).
|
|
8
|
+
|
|
9
|
+
import { EFFORT_LEVELS } from "./frontmatter-fields.ts";
|
|
10
|
+
|
|
11
|
+
/** The line to end a judge prompt with. */
|
|
12
|
+
export const JUDGE_VERDICT_INSTRUCTION =
|
|
13
|
+
"Check each part against the text in a few short sentences. Then, on the last line and alone, write exactly PASS or FAIL — PASS only if every part holds.";
|
|
14
|
+
|
|
15
|
+
/**
|
|
16
|
+
* The judge's verdict: the PASS or FAIL alone on its last line, with the
|
|
17
|
+
* reasoning before it as the detail. An answer in the old shape (the word
|
|
18
|
+
* first) still reads; one with no verdict at all is a FAIL that says so.
|
|
19
|
+
*/
|
|
20
|
+
export function judgeVerdict(text: string): { passed: boolean; detail: string } {
|
|
21
|
+
const lines = text.trim().split(/\r?\n/).map((l) => l.trim()).filter(Boolean);
|
|
22
|
+
const word = (l: string | undefined) => /^[*_`#\s]*(PASS|FAIL)[*_`.\s]*$/i.exec(l ?? "")?.[1]?.toUpperCase();
|
|
23
|
+
let verdict = word(lines[lines.length - 1]);
|
|
24
|
+
let reason = lines.slice(0, -1).join(" ");
|
|
25
|
+
if (!verdict) {
|
|
26
|
+
const first = /^[*_`#\s]*(PASS|FAIL)\b[*_`:.\s-]*(.*)$/i.exec(lines[0] ?? "");
|
|
27
|
+
if (first) {
|
|
28
|
+
verdict = first[1]!.toUpperCase();
|
|
29
|
+
reason = [first[2], ...lines.slice(1)].join(" ").trim();
|
|
30
|
+
}
|
|
31
|
+
}
|
|
32
|
+
if (!verdict) return { passed: false, detail: `judge gave no PASS or FAIL: ${text.trim().slice(0, 300) || "nothing"}` };
|
|
33
|
+
// The end of the reasoning is the part that decided it.
|
|
34
|
+
const why = reason.length > 400 ? `…${reason.slice(-400)}` : reason;
|
|
35
|
+
return { passed: verdict === "PASS", detail: `${verdict}${why ? ` — ${why}` : ""}` };
|
|
36
|
+
}
|
|
37
|
+
|
|
38
|
+
/** `judge:` in AGENTS.md — the model and effort every judge in the workspace
|
|
39
|
+
* grades with, unless an eval names its own. `model` is a tier or alias as
|
|
40
|
+
* anywhere else; what is wrong with the block, in words, for check. */
|
|
41
|
+
export function parseJudge(raw: unknown): { model?: string; effort?: string; problems: string[] } {
|
|
42
|
+
if (raw === undefined || raw === null) return { problems: [] };
|
|
43
|
+
if (typeof raw !== "object" || Array.isArray(raw)) return { problems: ["judge: is a block — {model: fast | default | max, effort: low…max}"] };
|
|
44
|
+
const o = raw as Record<string, unknown>;
|
|
45
|
+
const problems: string[] = [];
|
|
46
|
+
for (const k of Object.keys(o)) if (k !== "model" && k !== "effort") problems.push(`judge: ${k} — not a key; the keys are model and effort`);
|
|
47
|
+
const model = typeof o.model === "string" && o.model.trim() ? o.model.trim() : undefined;
|
|
48
|
+
if (o.model !== undefined && !model) problems.push("judge: model — a tier (fast, default, max), an alias or a model id");
|
|
49
|
+
const effort = typeof o.effort === "string" && (EFFORT_LEVELS as readonly string[]).includes(o.effort.trim()) ? o.effort.trim() : undefined;
|
|
50
|
+
if (o.effort !== undefined && !effort) problems.push(`judge: effort ${String(o.effort)} — low, medium, high, xhigh or max`);
|
|
51
|
+
return { ...(model ? { model } : {}), ...(effort ? { effort } : {}), problems };
|
|
52
|
+
}
|
package/src/library.ts
CHANGED
|
@@ -15,7 +15,7 @@ import { platform } from "./platform.ts";
|
|
|
15
15
|
import fs from "node:fs";
|
|
16
16
|
import path from "node:path";
|
|
17
17
|
import { dataRoot, singleWorkspace, singleAccountRoot, MAX_EDITABLE_FILE } from "./paths.ts";
|
|
18
|
-
import matter from "
|
|
18
|
+
import matter from "./matter.ts";
|
|
19
19
|
import { KINDS } from "./kinds.ts";
|
|
20
20
|
import { recordRevision, registerTreeReader } from "./history.ts";
|
|
21
21
|
import {
|
package/src/limits.ts
CHANGED
|
@@ -6,6 +6,7 @@
|
|
|
6
6
|
// web: 150 # every web action together
|
|
7
7
|
// crm: 20 # an API, script or MCP tool, by the name it is granted as
|
|
8
8
|
// calls: 300 # every tool call in the step, whatever the tool
|
|
9
|
+
// repeat: 5 # the same call — one tool, the same input — at most this often
|
|
9
10
|
//
|
|
10
11
|
// Counted per step and enforced in step-exec.ts's PreToolUse hook — the one
|
|
11
12
|
// place every call passes, the step's own and its sub-agents', on the
|
|
@@ -18,6 +19,7 @@
|
|
|
18
19
|
// Nearest wins per key: the account's AGENTS.md, then the workspace's, then
|
|
19
20
|
// the agent's own `limits:`, then a flow step's `limits:` option.
|
|
20
21
|
|
|
22
|
+
import crypto from "node:crypto";
|
|
21
23
|
import { WEB_ACTIONS } from "./providers.ts";
|
|
22
24
|
import { DELEGATE_TOOLS } from "./subagents.ts";
|
|
23
25
|
|
|
@@ -26,6 +28,23 @@ export type Limits = Record<string, number>;
|
|
|
26
28
|
/** The key that caps every call in the step. */
|
|
27
29
|
export const TOTAL_KEY = "calls";
|
|
28
30
|
|
|
31
|
+
/** The key that caps one identical call: a model asking the same tool the
|
|
32
|
+
* same thing over and over is stuck, and every repeat is spend. Each call
|
|
33
|
+
* also counts under `repeat:<hash of tool and input>`; `repeat: N` caps
|
|
34
|
+
* every one of those. */
|
|
35
|
+
export const REPEAT_KEY = "repeat";
|
|
36
|
+
|
|
37
|
+
/** JSON with its keys sorted, so `{a,b}` and `{b,a}` are the same call. */
|
|
38
|
+
function stable(v: unknown): string {
|
|
39
|
+
if (Array.isArray(v)) return `[${v.map(stable).join(",")}]`;
|
|
40
|
+
if (v && typeof v === "object") return `{${Object.keys(v as object).sort().map((k) => `${JSON.stringify(k)}:${stable((v as Record<string, unknown>)[k])}`).join(",")}}`;
|
|
41
|
+
return JSON.stringify(v) ?? "null";
|
|
42
|
+
}
|
|
43
|
+
|
|
44
|
+
function repeatKey(toolName: string, input: Record<string, unknown> | undefined): string {
|
|
45
|
+
return `${REPEAT_KEY}:${crypto.createHash("sha1").update(`${toolName}\0${stable(input ?? {})}`).digest("hex").slice(0, 16)}`;
|
|
46
|
+
}
|
|
47
|
+
|
|
29
48
|
/** SDK built-ins, counted under the foldrun group that grants them — and
|
|
30
49
|
* under their own SDK name, so `Bash: 5` works for a Claude Code file. */
|
|
31
50
|
const BUILTIN_GROUP: Record<string, string> = {
|
|
@@ -43,6 +62,7 @@ const PLATFORM_SERVERS: Record<string, string> = {
|
|
|
43
62
|
foldrun_history: "history",
|
|
44
63
|
foldrun_desks: "desks",
|
|
45
64
|
foldrun_ask: "ask",
|
|
65
|
+
foldrun_peers: "peers",
|
|
46
66
|
};
|
|
47
67
|
|
|
48
68
|
/** One spelling for comparing: `desk-email` and `desk_email` are the same
|
|
@@ -78,7 +98,7 @@ export function readLimits(raw: unknown, where = "limits"): { limits: Limits; pr
|
|
|
78
98
|
for (const [key, value] of flat) {
|
|
79
99
|
const k = key.trim();
|
|
80
100
|
if (!KEY_RE.test(k)) {
|
|
81
|
-
problems.push(`${where}: "${k}" is not a key — a tool's name, web.<action>, or
|
|
101
|
+
problems.push(`${where}: "${k}" is not a key — a tool's name, web.<action>, calls, or repeat.`);
|
|
82
102
|
continue;
|
|
83
103
|
}
|
|
84
104
|
if (k.includes(".")) {
|
|
@@ -166,6 +186,12 @@ export function limitKeysFor(toolName: string, input: Record<string, unknown> |
|
|
|
166
186
|
return keys;
|
|
167
187
|
}
|
|
168
188
|
|
|
189
|
+
/** What step-exec counts one call under: limitKeysFor's keys and the
|
|
190
|
+
* call's own `repeat:` key, for `limits: repeat`. */
|
|
191
|
+
export function callKeysFor(toolName: string, input: Record<string, unknown> | undefined, owners: Record<string, string> = {}): string[] {
|
|
192
|
+
return [...limitKeysFor(toolName, input, owners), repeatKey(toolName, input)];
|
|
193
|
+
}
|
|
194
|
+
|
|
169
195
|
export interface LimitRefusal {
|
|
170
196
|
key: string;
|
|
171
197
|
used: number;
|
|
@@ -193,14 +219,17 @@ export class CallCounter {
|
|
|
193
219
|
take(keys: string[]): LimitRefusal | null {
|
|
194
220
|
const norm = [...new Set(keys.map(normKey))];
|
|
195
221
|
for (const k of norm) {
|
|
196
|
-
const
|
|
222
|
+
const repeat = k.startsWith(`${REPEAT_KEY}:`);
|
|
223
|
+
const l = this.limits.get(repeat ? REPEAT_KEY : k);
|
|
197
224
|
if (!l) continue;
|
|
198
225
|
const used = this.used.get(k) ?? 0;
|
|
199
226
|
if (used >= l.limit) {
|
|
200
227
|
this.refused++;
|
|
201
228
|
return {
|
|
202
229
|
key: l.key, used, limit: l.limit,
|
|
203
|
-
message:
|
|
230
|
+
message: repeat
|
|
231
|
+
? `limit reached: this exact call (same tool, same input) ${used} of ${l.limit} times in this step — repeating it will not change the answer; try something different, or say what is stuck`
|
|
232
|
+
: `limit reached: ${l.key} ${used} of ${l.limit} in this step — work with what you have, or say what more you would need`,
|
|
204
233
|
};
|
|
205
234
|
}
|
|
206
235
|
}
|
|
@@ -223,7 +252,11 @@ export class CallCounter {
|
|
|
223
252
|
/** `limits: web.search 40/40, crm 3/20` — null when no limits are set. */
|
|
224
253
|
summary(): string | null {
|
|
225
254
|
if (!this.active) return null;
|
|
226
|
-
|
|
255
|
+
// repeat: the most any one call was made, against its cap.
|
|
256
|
+
const most = (k: string) => k === REPEAT_KEY
|
|
257
|
+
? Math.max(0, ...[...this.used.entries()].filter(([u]) => u.startsWith(`${REPEAT_KEY}:`)).map(([, n]) => n))
|
|
258
|
+
: this.used.get(k) ?? 0;
|
|
259
|
+
const parts = [...this.limits.entries()].map(([k, l]) => `${l.key} ${most(k)}/${l.limit}`);
|
|
227
260
|
return `limits: ${parts.join(", ")}${this.refused ? ` — ${this.refused} call${this.refused === 1 ? "" : "s"} refused` : ""}`;
|
|
228
261
|
}
|
|
229
262
|
}
|
|
@@ -253,7 +286,7 @@ export function limitKeyProblems(
|
|
|
253
286
|
return [n];
|
|
254
287
|
}).map(normKey));
|
|
255
288
|
for (const key of Object.keys(limits)) {
|
|
256
|
-
if (key === TOTAL_KEY) continue;
|
|
289
|
+
if (key === TOTAL_KEY || key === REPEAT_KEY) continue;
|
|
257
290
|
const base = key.startsWith("web.") ? "web" : key;
|
|
258
291
|
const n = normKey(base);
|
|
259
292
|
if (!known.has(n)) {
|