@jameslovespancakes/pi-plus 1.0.12 → 1.0.14
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/README.md +7 -3
- package/package.json +7 -6
- package/src/core/accounts/oauth-pool.ts +35 -0
- package/src/core/accounts/registry.ts +4 -0
- package/src/core/accounts/routing.ts +22 -0
- package/src/core/anthropic/identity.ts +54 -0
- package/src/core/anthropic/oauth.ts +3 -0
- package/src/core/anthropic/quota.ts +227 -28
- package/src/core/anthropic/store.ts +4 -1
- package/src/core/codex/quota.ts +43 -11
- package/src/core/codex/store.ts +21 -11
- package/src/core/config.ts +8 -10
- package/src/domains/agents/format.ts +39 -0
- package/src/domains/agents/index.ts +39 -50
- package/src/domains/remote/config-path.ts +0 -2
- package/src/domains/remote/index.ts +4 -41
- package/src/domains/subscriptions/accounts-picker.ts +44 -4
- package/src/domains/subscriptions/accounts.ts +31 -8
- package/src/domains/subscriptions/index.ts +2 -2
- package/src/domains/subscriptions/provider.ts +46 -5
- package/src/domains/subscriptions/providers/anthropic.ts +37 -5
- package/src/domains/subscriptions/providers/codex.ts +120 -254
- package/src/domains/subscriptions/providers/hosted.ts +2 -2
- package/src/domains/subscriptions/providers/oauth-pool.ts +124 -72
- package/src/domains/workflows/index.ts +1 -1
- package/src/domains/workflows/runtime/agent-attempt.ts +41 -11
- package/src/domains/workflows/runtime/agent-failure.ts +14 -0
- package/src/domains/workflows/runtime/agent-limits.ts +24 -1
- package/src/domains/workflows/runtime/agent-retry.ts +11 -6
- package/src/domains/workflows/runtime/agent-runner.ts +42 -20
- package/src/domains/workflows/runtime/agent-workspace.ts +0 -24
- package/src/domains/workflows/runtime/engine.ts +48 -12
- package/src/domains/workflows/runtime/progress.ts +2 -2
- package/src/domains/workflows/runtime/types.ts +4 -4
- package/src/domains/workflows/runtime/ui/display-text.ts +15 -0
- package/src/domains/workflows/runtime/ui/workflow-inspector.ts +51 -14
- package/src/domains/workflows/runtime/ui/workflow-viewer-layout.ts +6 -6
- package/src/domains/workflows/runtime/workflow-run-history.ts +3 -1
- package/src/domains/workflows/runtime/workflow-run-record.ts +1 -0
- package/src/domains/workflows/runtime/worktree.ts +35 -1
- package/src/ui/usage-bars.ts +1 -2
- package/src/core/codex/oauth.ts +0 -129
|
@@ -1,28 +1,51 @@
|
|
|
1
1
|
import { randomUUID } from "node:crypto";
|
|
2
|
-
import type { ModelAuth, OAuthAuth, OAuthCredential, Provider } from "@earendil-works/pi-ai";
|
|
2
|
+
import type { Api, ModelAuth, OAuthAuth, OAuthCredential, Provider } from "@earendil-works/pi-ai";
|
|
3
3
|
import type { ExtensionAPI } from "@earendil-works/pi-coding-agent";
|
|
4
4
|
import type { AccountContext, AccountProvider, ManagedAccount, RoutingMode } from "../../../core/accounts/registry.ts";
|
|
5
|
-
import { quotaStateFromHeaders, selectRoutingCandidate, type AccountQuotaState } from "../../../core/accounts/routing.ts";
|
|
6
5
|
import {
|
|
7
|
-
|
|
6
|
+
quotaStateFromHeaders,
|
|
7
|
+
refreshAbortSignal,
|
|
8
|
+
selectRoutingCandidate,
|
|
9
|
+
} from "../../../core/accounts/routing.ts";
|
|
10
|
+
import {
|
|
8
11
|
oauthIdentity,
|
|
9
|
-
|
|
10
|
-
|
|
11
|
-
setOAuthPoolMode,
|
|
12
|
+
setPrimaryQuota,
|
|
13
|
+
sharedOAuthPoolStore,
|
|
12
14
|
type PooledOAuthAccount,
|
|
15
|
+
type PooledOAuthStore,
|
|
13
16
|
} from "../../../core/accounts/oauth-pool.ts";
|
|
14
17
|
|
|
15
|
-
export interface PooledOAuthProviderSpec {
|
|
18
|
+
export interface PooledOAuthProviderSpec<TApi extends Api> {
|
|
16
19
|
id: string;
|
|
17
20
|
label: string;
|
|
18
|
-
createProvider(): Provider
|
|
21
|
+
createProvider(): Provider<TApi>;
|
|
22
|
+
/** Backing storage. Defaults to the shared pi-plus OAuth pool file. */
|
|
23
|
+
store?: PooledOAuthStore;
|
|
24
|
+
/** Confirmation shown before an interactive `add`. */
|
|
25
|
+
addPrompt?: string;
|
|
26
|
+
/** Display label for an account. */
|
|
27
|
+
describeAccount?(account: PooledOAuthAccount): string;
|
|
28
|
+
/** Stable identity used to reject a duplicate login. Defaults to JWT claims. */
|
|
29
|
+
identityOf?(access: string): string | undefined;
|
|
30
|
+
/**
|
|
31
|
+
* Records a response's quota signal against an account. Defaults to the
|
|
32
|
+
* generic `x-ratelimit-*` reader; providers with their own headers (Codex
|
|
33
|
+
* sends `x-codex-*`) override it. Must never throw: it runs inside the
|
|
34
|
+
* awaited `onResponse`, so a rejection would kill an in-flight stream.
|
|
35
|
+
*/
|
|
36
|
+
recordQuota?(accountId: string, status: number, headers: Record<string, string>): void;
|
|
19
37
|
}
|
|
20
38
|
|
|
39
|
+
const MAIN = "main";
|
|
40
|
+
|
|
21
41
|
const lastUsed = new Map<string, number>();
|
|
22
|
-
const primaryQuota = new Map<string, AccountQuotaState>();
|
|
23
42
|
const refreshes = new Map<string, Promise<PooledOAuthAccount>>();
|
|
24
43
|
|
|
25
|
-
function
|
|
44
|
+
function storeFor<TApi extends Api>(spec: PooledOAuthProviderSpec<TApi>): PooledOAuthStore {
|
|
45
|
+
return spec.store ?? sharedOAuthPoolStore(spec.id);
|
|
46
|
+
}
|
|
47
|
+
|
|
48
|
+
function oauthFor<TApi extends Api>(spec: PooledOAuthProviderSpec<TApi>): OAuthAuth {
|
|
26
49
|
const oauth = spec.createProvider().auth.oauth;
|
|
27
50
|
if (!oauth) throw new Error(`${spec.label} does not expose subscription OAuth.`);
|
|
28
51
|
return oauth;
|
|
@@ -44,7 +67,7 @@ async function notifyAuthEvent(ctx: AccountContext, event: any): Promise<void> {
|
|
|
44
67
|
if (typeof event?.message === "string") ctx.ui.notify(event.message, "info");
|
|
45
68
|
}
|
|
46
69
|
|
|
47
|
-
async function authenticate(spec: PooledOAuthProviderSpec
|
|
70
|
+
async function authenticate<TApi extends Api>(spec: PooledOAuthProviderSpec<TApi>, ctx: AccountContext): Promise<OAuthCredential> {
|
|
48
71
|
const signal = ctx.signal ?? new AbortController().signal;
|
|
49
72
|
return oauthFor(spec).login({
|
|
50
73
|
signal,
|
|
@@ -64,111 +87,123 @@ async function authenticate(spec: PooledOAuthProviderSpec, ctx: AccountContext):
|
|
|
64
87
|
});
|
|
65
88
|
}
|
|
66
89
|
|
|
67
|
-
function accountLabel(account: PooledOAuthAccount): string {
|
|
68
|
-
return account.label || account.identity || account.id.slice(0, 8);
|
|
90
|
+
function accountLabel<TApi extends Api>(spec: PooledOAuthProviderSpec<TApi>, account: PooledOAuthAccount): string {
|
|
91
|
+
return spec.describeAccount?.(account) ?? (account.label || account.identity || account.id.slice(0, 8));
|
|
69
92
|
}
|
|
70
93
|
|
|
71
|
-
function
|
|
72
|
-
|
|
73
|
-
|
|
94
|
+
function identityFor<TApi extends Api>(spec: PooledOAuthProviderSpec<TApi>, access: string): string | undefined {
|
|
95
|
+
return (spec.identityOf ?? oauthIdentity)(access);
|
|
96
|
+
}
|
|
97
|
+
|
|
98
|
+
function duplicateAccount<TApi extends Api>(
|
|
99
|
+
spec: PooledOAuthProviderSpec<TApi>,
|
|
100
|
+
credential: OAuthCredential,
|
|
101
|
+
excludeId?: string,
|
|
102
|
+
): PooledOAuthAccount | undefined {
|
|
103
|
+
const identity = identityFor(spec, credential.access);
|
|
104
|
+
return storeFor(spec).load().accounts.find((account) =>
|
|
74
105
|
account.id !== excludeId && (identity ? account.identity === identity : account.access === credential.access));
|
|
75
106
|
}
|
|
76
107
|
|
|
77
|
-
export function createPooledOAuthAdapter(spec: PooledOAuthProviderSpec): AccountProvider {
|
|
108
|
+
export function createPooledOAuthAdapter<TApi extends Api>(spec: PooledOAuthProviderSpec<TApi>): AccountProvider {
|
|
109
|
+
const store = () => storeFor(spec);
|
|
110
|
+
|
|
78
111
|
return {
|
|
79
112
|
id: spec.id,
|
|
80
113
|
label: spec.label,
|
|
81
114
|
|
|
82
115
|
async list(): Promise<ManagedAccount[]> {
|
|
83
|
-
return
|
|
84
|
-
|
|
85
|
-
|
|
86
|
-
|
|
87
|
-
|
|
88
|
-
|
|
116
|
+
return store().load().accounts.map((account) => {
|
|
117
|
+
const identity = account.identity ?? identityFor(spec, account.access);
|
|
118
|
+
return {
|
|
119
|
+
id: account.id,
|
|
120
|
+
label: accountLabel(spec, account),
|
|
121
|
+
enabled: account.enabled !== false,
|
|
122
|
+
expiresAt: account.expires,
|
|
123
|
+
...(identity && { identity }),
|
|
124
|
+
};
|
|
125
|
+
});
|
|
89
126
|
},
|
|
90
127
|
|
|
128
|
+
identify: (accessToken) => identityFor(spec, accessToken),
|
|
129
|
+
|
|
91
130
|
async add(ctx, label): Promise<string | undefined> {
|
|
92
|
-
|
|
93
|
-
|
|
94
|
-
|
|
131
|
+
const prompt = spec.addPrompt ?? `Sign in with another ${spec.label} subscription?`;
|
|
132
|
+
if (!await ctx.ui.confirm(`Add ${spec.label} account`, prompt)) return undefined;
|
|
133
|
+
|
|
95
134
|
const credential = await authenticate(spec, ctx);
|
|
96
135
|
const duplicate = duplicateAccount(spec, credential);
|
|
97
|
-
if (duplicate) throw new Error(`That account is already saved as “${accountLabel(duplicate)}”.`);
|
|
136
|
+
if (duplicate) throw new Error(`That account is already saved as “${accountLabel(spec, duplicate)}”.`);
|
|
98
137
|
|
|
99
|
-
|
|
138
|
+
store().saveAccount({
|
|
100
139
|
...credential,
|
|
101
140
|
id: randomUUID(),
|
|
102
141
|
label,
|
|
103
142
|
enabled: true,
|
|
104
|
-
identity:
|
|
143
|
+
identity: identityFor(spec, credential.access),
|
|
105
144
|
addedAt: Date.now(),
|
|
106
145
|
});
|
|
107
146
|
return label;
|
|
108
147
|
},
|
|
109
148
|
|
|
110
149
|
async reauth(ctx, accountId): Promise<string | undefined> {
|
|
111
|
-
const account =
|
|
150
|
+
const account = store().load().accounts.find(
|
|
112
151
|
(candidate) => candidate.id === accountId || candidate.label === accountId,
|
|
113
152
|
);
|
|
114
153
|
if (!account) throw new Error(`${spec.label} account “${accountId}” not found.`);
|
|
115
154
|
|
|
116
155
|
const credential = await authenticate(spec, ctx);
|
|
117
156
|
const duplicate = duplicateAccount(spec, credential, account.id);
|
|
118
|
-
if (duplicate) throw new Error(`That account is already saved as “${accountLabel(duplicate)}”.`);
|
|
119
|
-
|
|
157
|
+
if (duplicate) throw new Error(`That account is already saved as “${accountLabel(spec, duplicate)}”.`);
|
|
158
|
+
store().saveAccount({
|
|
120
159
|
...account,
|
|
121
160
|
...credential,
|
|
122
|
-
identity:
|
|
161
|
+
identity: identityFor(spec, credential.access),
|
|
123
162
|
});
|
|
124
163
|
return account.label;
|
|
125
164
|
},
|
|
126
165
|
|
|
127
166
|
async setEnabled(accountId, enabled): Promise<void> {
|
|
128
|
-
const
|
|
129
|
-
const account = pool.accounts.find((candidate) => candidate.id === accountId);
|
|
167
|
+
const account = store().load().accounts.find((candidate) => candidate.id === accountId);
|
|
130
168
|
if (!account) throw new Error(`${spec.label} account “${accountId}” not found.`);
|
|
131
|
-
|
|
132
|
-
saveOAuthPool(spec.id, pool);
|
|
169
|
+
store().saveAccount({ ...account, enabled });
|
|
133
170
|
},
|
|
134
171
|
|
|
135
172
|
async rename(accountId, label): Promise<void> {
|
|
136
|
-
const
|
|
137
|
-
const account = pool.accounts.find((candidate) => candidate.id === accountId);
|
|
173
|
+
const account = store().load().accounts.find((candidate) => candidate.id === accountId);
|
|
138
174
|
if (!account) throw new Error(`${spec.label} account “${accountId}” not found.`);
|
|
139
|
-
|
|
140
|
-
saveOAuthPool(spec.id, pool);
|
|
175
|
+
store().saveAccount({ ...account, label });
|
|
141
176
|
},
|
|
142
177
|
|
|
143
178
|
routing: {
|
|
144
179
|
async get(): Promise<RoutingMode> {
|
|
145
|
-
return
|
|
180
|
+
return store().load().mode;
|
|
146
181
|
},
|
|
147
182
|
async set(mode): Promise<RoutingMode> {
|
|
148
|
-
|
|
183
|
+
store().saveMode(mode);
|
|
149
184
|
return mode;
|
|
150
185
|
},
|
|
151
186
|
describe(mode): string {
|
|
152
187
|
return mode === "quota-aware"
|
|
153
|
-
? `Uses ${spec.label}
|
|
154
|
-
: `Uses ${spec.label}
|
|
188
|
+
? `Uses the ${spec.label} subscription with the most remaining quota.`
|
|
189
|
+
: `Uses ${spec.label} subscriptions in order, moving on when one is exhausted.`;
|
|
155
190
|
},
|
|
156
191
|
},
|
|
157
192
|
};
|
|
158
193
|
}
|
|
159
194
|
|
|
160
|
-
function chooseCredential(
|
|
161
|
-
spec: PooledOAuthProviderSpec
|
|
195
|
+
export function chooseCredential<TApi extends Api>(
|
|
196
|
+
spec: PooledOAuthProviderSpec<TApi>,
|
|
162
197
|
primary: OAuthCredential,
|
|
163
198
|
): { id: string; credential: OAuthCredential; account?: PooledOAuthAccount } {
|
|
164
|
-
const pool =
|
|
199
|
+
const pool = storeFor(spec).load();
|
|
165
200
|
const candidates = [
|
|
166
201
|
{
|
|
167
|
-
id:
|
|
202
|
+
id: MAIN,
|
|
168
203
|
order: 0,
|
|
169
|
-
lastUsed: lastUsed.get(`${spec.id}
|
|
170
|
-
quota:
|
|
171
|
-
value: { id:
|
|
204
|
+
lastUsed: lastUsed.get(`${spec.id}:${MAIN}`) ?? 0,
|
|
205
|
+
quota: storeFor(spec).primaryQuota(),
|
|
206
|
+
value: { id: MAIN, credential: primary },
|
|
172
207
|
},
|
|
173
208
|
...pool.accounts
|
|
174
209
|
.filter((account) => account.enabled !== false && account.access)
|
|
@@ -180,45 +215,47 @@ function chooseCredential(
|
|
|
180
215
|
value: { id: account.id, credential: account, account },
|
|
181
216
|
})),
|
|
182
217
|
];
|
|
183
|
-
return selectRoutingCandidate(candidates, pool.mode)?.value ?? { id:
|
|
218
|
+
return selectRoutingCandidate(candidates, pool.mode)?.value ?? { id: MAIN, credential: primary };
|
|
184
219
|
}
|
|
185
220
|
|
|
186
|
-
async function freshCredential
|
|
221
|
+
async function freshCredential<TApi extends Api>(
|
|
222
|
+
spec: PooledOAuthProviderSpec<TApi>,
|
|
223
|
+
account: PooledOAuthAccount,
|
|
224
|
+
): Promise<PooledOAuthAccount> {
|
|
187
225
|
if (account.expires > Date.now() + 60_000) return account;
|
|
188
226
|
|
|
189
227
|
const key = `${spec.id}:${account.id}`;
|
|
190
228
|
const active = refreshes.get(key);
|
|
191
229
|
if (active) return active;
|
|
192
230
|
|
|
193
|
-
|
|
231
|
+
// Bounded: nothing upstream constrains this call, and a hung refresh would be
|
|
232
|
+
// shared by every later request for the account through the map above.
|
|
233
|
+
const refresh = oauthFor(spec).refresh(account, refreshAbortSignal()).then((credential) => {
|
|
194
234
|
const updated = { ...account, ...credential };
|
|
195
|
-
|
|
235
|
+
storeFor(spec).saveAccount(updated);
|
|
196
236
|
return updated;
|
|
197
237
|
}).finally(() => refreshes.delete(key));
|
|
198
238
|
refreshes.set(key, refresh);
|
|
199
239
|
return refresh;
|
|
200
240
|
}
|
|
201
241
|
|
|
202
|
-
async function routedAuth(
|
|
203
|
-
spec: PooledOAuthProviderSpec
|
|
242
|
+
async function routedAuth<TApi extends Api>(
|
|
243
|
+
spec: PooledOAuthProviderSpec<TApi>,
|
|
204
244
|
oauth: OAuthAuth,
|
|
205
245
|
primary: OAuthCredential,
|
|
206
246
|
): Promise<ModelAuth> {
|
|
207
247
|
const selected = chooseCredential(spec, primary);
|
|
208
|
-
const
|
|
209
|
-
const credential = selected.account
|
|
210
|
-
? await freshCredential(spec, selected.account, signal)
|
|
211
|
-
: primary;
|
|
248
|
+
const credential = selected.account ? await freshCredential(spec, selected.account) : primary;
|
|
212
249
|
const now = Date.now();
|
|
213
250
|
lastUsed.set(`${spec.id}:${selected.id}`, now);
|
|
214
251
|
|
|
215
252
|
if (selected.account && now - (selected.account.lastUsed ?? 0) >= 60_000) {
|
|
216
|
-
|
|
253
|
+
storeFor(spec).saveAccount({ ...selected.account, ...credential, lastUsed: now });
|
|
217
254
|
}
|
|
218
255
|
return oauth.toAuth(credential);
|
|
219
256
|
}
|
|
220
257
|
|
|
221
|
-
export function registerPooledOAuthProvider(pi: ExtensionAPI, spec: PooledOAuthProviderSpec): void {
|
|
258
|
+
export function registerPooledOAuthProvider<TApi extends Api>(pi: ExtensionAPI, spec: PooledOAuthProviderSpec<TApi>): void {
|
|
222
259
|
const provider = spec.createProvider();
|
|
223
260
|
const oauth = provider.auth.oauth;
|
|
224
261
|
if (!oauth) return;
|
|
@@ -226,7 +263,11 @@ export function registerPooledOAuthProvider(pi: ExtensionAPI, spec: PooledOAuthP
|
|
|
226
263
|
const withQuotaObserver = (options: any) => ({
|
|
227
264
|
...options,
|
|
228
265
|
onResponse: async (response: { status: number; headers: Record<string, string> }, model: unknown) => {
|
|
229
|
-
|
|
266
|
+
try {
|
|
267
|
+
recordQuotaResponse(spec, requestAccessToken(options), response.status, response.headers);
|
|
268
|
+
} catch {
|
|
269
|
+
// Quota accounting is telemetry; it must never fail the response.
|
|
270
|
+
}
|
|
230
271
|
await options?.onResponse?.(response, model);
|
|
231
272
|
},
|
|
232
273
|
});
|
|
@@ -255,19 +296,30 @@ function requestAccessToken(options: any): string | undefined {
|
|
|
255
296
|
return match?.[1];
|
|
256
297
|
}
|
|
257
298
|
|
|
258
|
-
|
|
259
|
-
|
|
299
|
+
/** Resolves which pooled account served a request, falling back to the primary. */
|
|
300
|
+
function accountIdForToken<TApi extends Api>(spec: PooledOAuthProviderSpec<TApi>, access: string | undefined): string {
|
|
301
|
+
if (!access) return MAIN;
|
|
302
|
+
return storeFor(spec).load().accounts.find((candidate) => candidate.access === access)?.id ?? MAIN;
|
|
303
|
+
}
|
|
304
|
+
|
|
305
|
+
function recordQuotaResponse<TApi extends Api>(
|
|
306
|
+
spec: PooledOAuthProviderSpec<TApi>,
|
|
260
307
|
access: string | undefined,
|
|
261
308
|
status: number,
|
|
262
309
|
headers: Record<string, string>,
|
|
263
310
|
): void {
|
|
311
|
+
if (spec.recordQuota) {
|
|
312
|
+
spec.recordQuota(accountIdForToken(spec, access), status, headers);
|
|
313
|
+
return;
|
|
314
|
+
}
|
|
315
|
+
|
|
264
316
|
if (!access) return;
|
|
265
|
-
const
|
|
266
|
-
const account =
|
|
267
|
-
const previous = account?.quota ?? primaryQuota
|
|
317
|
+
const store = storeFor(spec);
|
|
318
|
+
const account = store.load().accounts.find((candidate) => candidate.access === access);
|
|
319
|
+
const previous = account?.quota ?? store.primaryQuota();
|
|
268
320
|
const quota = quotaStateFromHeaders(status, headers, previous);
|
|
269
321
|
if (!quota || quota === previous) return;
|
|
270
322
|
|
|
271
|
-
if (account)
|
|
272
|
-
else
|
|
323
|
+
if (account) store.saveAccount({ ...account, quota });
|
|
324
|
+
else setPrimaryQuota(spec.id, quota);
|
|
273
325
|
}
|
|
@@ -646,7 +646,7 @@ function registerWorkflowTool(
|
|
|
646
646
|
"Inline scripts may compose registered workflows in-process via `api.workflow(\"<name>\", args)` (e.g. `await api.workflow(\"code-review\", \"HEAD~3\")`); it returns the sub-workflow's result and nests one level only.",
|
|
647
647
|
"Subagents receive no skills by default. In inline workflows, pass `skills: [\"skill-name\"]` per `agent()` call when the user asks for a skill or a stage should use one; grant only the needed skills.",
|
|
648
648
|
"Always pass a plain string as the first `api.agent()` argument; build prompts with template strings before calling agent().",
|
|
649
|
-
"When using `isolation: \"worktree\"`, `api.agent()` returns `{ result, patch, changed }`; use `.result` for the answer and `.patch` for the isolated diff.",
|
|
649
|
+
"When using `isolation: \"worktree\"`, `api.agent()` returns `{ result, patch, changed }`; use `.result` for the answer and `.patch` for the isolated diff. Successful runs clean up the worktree; failed runs retain recoverable work and report its path.",
|
|
650
650
|
"If an inline subagent needs grep/find/code-search helpers, use `tools: [\"read\", \"bash\", \"grep\", \"find\", \"ls\"]` plus `toolHints: [\"search\"]` so installed tools such as ast-grep, mgrep, ffgrep, or fffind are discovered dynamically.",
|
|
651
651
|
"`api.budget` exposes `{ total, spent(), remaining() }` (output tokens). When the run is budgeted, scale fleets from `budget.total` and guard loops with `while (budget.total && budget.remaining() > N) { await api.agent(...) }`; `api.agent()` throws once the ceiling is reached.",
|
|
652
652
|
ADAPTIVE_WORKFLOW_GUIDANCE,
|
|
@@ -1,5 +1,7 @@
|
|
|
1
1
|
import type { Api, Model } from "@earendil-works/pi-ai";
|
|
2
2
|
import { assertWorkflowBudgetAvailable } from "./budget.ts";
|
|
3
|
+
import { isWorkflowPauseError } from "./cancellation.ts";
|
|
4
|
+
import type { WorkflowAgentReservation } from "./agent-limits.ts";
|
|
3
5
|
import {
|
|
4
6
|
type AgentExecutionOptions,
|
|
5
7
|
type AgentRunTags,
|
|
@@ -20,13 +22,13 @@ import {
|
|
|
20
22
|
import { openAgentSession, promptAgentSession, type AgentSessionHandle } from "./agent-session.ts";
|
|
21
23
|
import {
|
|
22
24
|
createAgentWorkspace,
|
|
23
|
-
disposeAgentWorkspace,
|
|
24
25
|
type AgentWorkspace,
|
|
25
26
|
} from "./agent-workspace.ts";
|
|
26
27
|
import type { AgentResumeBaseContext, AgentResumeContext, RepositoryResumeContext } from "./resume-context.ts";
|
|
27
28
|
import { captureRepositoryMutationGuard } from "./resume-context.ts";
|
|
29
|
+
import { unknownErrorMessage } from "./unknown-error.ts";
|
|
28
30
|
|
|
29
|
-
/** Execute one
|
|
31
|
+
/** Execute one agent attempt. Sessions close immediately; worktrees settle at the workflow boundary. */
|
|
30
32
|
export async function executeAgentAttempt(input: {
|
|
31
33
|
readonly rc: RunContext;
|
|
32
34
|
readonly prompt: string;
|
|
@@ -37,9 +39,9 @@ export async function executeAgentAttempt(input: {
|
|
|
37
39
|
readonly label: string;
|
|
38
40
|
readonly rowId: number;
|
|
39
41
|
readonly tags: AgentRunTags;
|
|
40
|
-
readonly
|
|
42
|
+
readonly reserveLiveAgent: () => WorkflowAgentReservation;
|
|
41
43
|
}): Promise<AgentAttemptResult> {
|
|
42
|
-
const { rc, prompt, opts, resumeBaseContext, model, replay, label, rowId, tags,
|
|
44
|
+
const { rc, prompt, opts, resumeBaseContext, model, replay, label, rowId, tags, reserveLiveAgent } = input;
|
|
43
45
|
let repositoryBefore: RepositoryResumeContext | undefined;
|
|
44
46
|
let evidence: AgentReplayEvidence | undefined;
|
|
45
47
|
if (isReplayEnabled(replay)) {
|
|
@@ -57,8 +59,17 @@ export async function executeAgentAttempt(input: {
|
|
|
57
59
|
}
|
|
58
60
|
let workspace: AgentWorkspace | undefined;
|
|
59
61
|
let handle: AgentSessionHandle | undefined;
|
|
62
|
+
let admission: WorkflowAgentReservation | undefined;
|
|
63
|
+
let liveStarted = false;
|
|
60
64
|
|
|
61
65
|
try {
|
|
66
|
+
// Calls with no replay path cannot become cache hits. Reserve their live
|
|
67
|
+
// slot before creating a disposable worktree or model session so a limit
|
|
68
|
+
// error cannot leave behind a fresh, untouched workspace.
|
|
69
|
+
if (!isReplayEnabled(replay)) {
|
|
70
|
+
assertWorkflowBudgetAvailable(rc.budget);
|
|
71
|
+
admission = reserveLiveAgent();
|
|
72
|
+
}
|
|
62
73
|
workspace = await createAgentWorkspace(rc, opts, label);
|
|
63
74
|
if (replay.kind === "isolated" && evidence?.kind === "isolated") {
|
|
64
75
|
if (workspace.kind !== "isolated") throw new Error("Isolated replay created a shared workspace.");
|
|
@@ -104,8 +115,13 @@ export async function executeAgentAttempt(input: {
|
|
|
104
115
|
}
|
|
105
116
|
}
|
|
106
117
|
|
|
107
|
-
|
|
108
|
-
|
|
118
|
+
if (!admission) {
|
|
119
|
+
assertWorkflowBudgetAvailable(rc.budget);
|
|
120
|
+
admission = reserveLiveAgent();
|
|
121
|
+
}
|
|
122
|
+
admission.commit();
|
|
123
|
+
liveStarted = true;
|
|
124
|
+
if (workspace.kind === "isolated") rc.worktrees.markRecoverable(workspace.cwd);
|
|
109
125
|
const rawResult = await promptAgentSession({
|
|
110
126
|
rc,
|
|
111
127
|
handle,
|
|
@@ -136,12 +152,26 @@ export async function executeAgentAttempt(input: {
|
|
|
136
152
|
}
|
|
137
153
|
rc.progress.log(`${label}: read-only resume contract was not recorded (${validation.reason})`);
|
|
138
154
|
return { kind: "live-unrecordable", result };
|
|
155
|
+
} catch (error) {
|
|
156
|
+
if (
|
|
157
|
+
workspace?.kind === "isolated"
|
|
158
|
+
&& liveStarted
|
|
159
|
+
&& !rc.signal?.aborted
|
|
160
|
+
&& !isWorkflowPauseError(error)
|
|
161
|
+
) {
|
|
162
|
+
rc.worktrees.preserve(workspace.cwd);
|
|
163
|
+
rc.progress.log(`${label}: retained failed worktree ${workspace.cwd}`);
|
|
164
|
+
}
|
|
165
|
+
throw error;
|
|
139
166
|
} finally {
|
|
140
|
-
|
|
141
|
-
|
|
142
|
-
|
|
143
|
-
|
|
144
|
-
|
|
167
|
+
admission?.release();
|
|
168
|
+
const session = handle?.session;
|
|
169
|
+
if (session) {
|
|
170
|
+
try {
|
|
171
|
+
rc.perf.timeSync("agent.dispose_ms", () => session.dispose(), tags);
|
|
172
|
+
} catch (error) {
|
|
173
|
+
rc.progress.log(`${label}: session cleanup failed (${unknownErrorMessage(error)})`);
|
|
174
|
+
}
|
|
145
175
|
}
|
|
146
176
|
}
|
|
147
177
|
}
|
|
@@ -0,0 +1,14 @@
|
|
|
1
|
+
import { unknownErrorMessage } from "./unknown-error.ts";
|
|
2
|
+
|
|
3
|
+
/** Retains every failed attempt when a later retry is blocked or also fails. */
|
|
4
|
+
export function combinedAgentAttemptError(
|
|
5
|
+
label: string,
|
|
6
|
+
prior: readonly unknown[],
|
|
7
|
+
finalError: unknown,
|
|
8
|
+
): AggregateError {
|
|
9
|
+
const earlier = prior.map(unknownErrorMessage).join("; ");
|
|
10
|
+
return new AggregateError(
|
|
11
|
+
[...prior, finalError],
|
|
12
|
+
`Workflow agent "${label}" could not complete. Earlier attempt: ${earlier}. Final failure: ${unknownErrorMessage(finalError)}`,
|
|
13
|
+
);
|
|
14
|
+
}
|
|
@@ -25,6 +25,13 @@ export class WorkflowAgentTimeoutError extends Error {
|
|
|
25
25
|
}
|
|
26
26
|
|
|
27
27
|
/** Shared run-level admission counter. Cache hits do not call admit(). */
|
|
28
|
+
export interface WorkflowAgentReservation {
|
|
29
|
+
/** Permanently count this reservation as a live model attempt. */
|
|
30
|
+
commit(): void;
|
|
31
|
+
/** Return an uncommitted reservation after setup or replay succeeds/fails. */
|
|
32
|
+
release(): void;
|
|
33
|
+
}
|
|
34
|
+
|
|
28
35
|
export class WorkflowAgentLimiter {
|
|
29
36
|
readonly maxAgents: number | null;
|
|
30
37
|
private admitted = 0;
|
|
@@ -33,11 +40,27 @@ export class WorkflowAgentLimiter {
|
|
|
33
40
|
this.maxAgents = maxAgents;
|
|
34
41
|
}
|
|
35
42
|
|
|
36
|
-
|
|
43
|
+
reserve(signal: AbortSignal | undefined): WorkflowAgentReservation {
|
|
37
44
|
throwIfAborted(signal);
|
|
38
45
|
if (this.maxAgents !== null && this.admitted >= this.maxAgents) {
|
|
39
46
|
throw new WorkflowAgentLimitError(this.maxAgents);
|
|
40
47
|
}
|
|
41
48
|
this.admitted++;
|
|
49
|
+
let state: "pending" | "committed" | "released" = "pending";
|
|
50
|
+
return {
|
|
51
|
+
commit() {
|
|
52
|
+
if (state === "released") throw new Error("Cannot commit a released workflow agent reservation.");
|
|
53
|
+
state = "committed";
|
|
54
|
+
},
|
|
55
|
+
release: () => {
|
|
56
|
+
if (state !== "pending") return;
|
|
57
|
+
state = "released";
|
|
58
|
+
this.admitted--;
|
|
59
|
+
},
|
|
60
|
+
};
|
|
61
|
+
}
|
|
62
|
+
|
|
63
|
+
admit(signal: AbortSignal | undefined): void {
|
|
64
|
+
this.reserve(signal).commit();
|
|
42
65
|
}
|
|
43
66
|
}
|
|
@@ -8,6 +8,7 @@ import {
|
|
|
8
8
|
export const AGENT_RETRY_BASE_DELAY_MS = 1_000;
|
|
9
9
|
export const AGENT_RETRY_MAX_DELAY_MS = 30_000;
|
|
10
10
|
export const WORKFLOW_PROVIDER_ERROR_CODE = "WORKFLOW_PROVIDER_ERROR";
|
|
11
|
+
const CODEX_ACCESS_VERIFICATION_ERROR = /Unable to verify\s+[^.\r\n]{1,120}\s+access\.\s*Please try again\.?/i;
|
|
11
12
|
|
|
12
13
|
export interface AgentRetryScheduler {
|
|
13
14
|
sleep(delayMs: number, signal: AbortSignal | undefined): Promise<void>;
|
|
@@ -25,12 +26,11 @@ export interface ProviderErrorDetails {
|
|
|
25
26
|
export class WorkflowProviderError extends Error {
|
|
26
27
|
override readonly name = "WorkflowProviderError";
|
|
27
28
|
readonly code = WORKFLOW_PROVIDER_ERROR_CODE;
|
|
29
|
+
readonly details: ProviderErrorDetails;
|
|
28
30
|
|
|
29
|
-
constructor(
|
|
30
|
-
message: string,
|
|
31
|
-
readonly details: ProviderErrorDetails,
|
|
32
|
-
) {
|
|
31
|
+
constructor(message: string, details: ProviderErrorDetails) {
|
|
33
32
|
super(message);
|
|
33
|
+
this.details = details;
|
|
34
34
|
}
|
|
35
35
|
|
|
36
36
|
get retryable(): boolean {
|
|
@@ -79,13 +79,18 @@ export function providerErrorFromMessages(
|
|
|
79
79
|
if (usageLimit && options.pauseOnUsageLimit) return usageLimit;
|
|
80
80
|
const message = messages.findLast(isAssistantMessage);
|
|
81
81
|
if (!message || message.stopReason !== "error") return undefined;
|
|
82
|
-
const
|
|
82
|
+
const providerMessage = typeof message.errorMessage === "string" && message.errorMessage.length > 0
|
|
83
83
|
? message.errorMessage
|
|
84
84
|
: "Provider session ended with an unspecified error.";
|
|
85
|
-
const retryable = isRetryableAssistantError(message as AssistantMessage);
|
|
86
85
|
const provider = stringDetail(message.provider);
|
|
87
86
|
const model = stringDetail(message.model);
|
|
88
87
|
const api = stringDetail(message.api);
|
|
88
|
+
const codexAccessVerification = provider === "openai-codex"
|
|
89
|
+
&& CODEX_ACCESS_VERIFICATION_ERROR.test(providerMessage);
|
|
90
|
+
const retryable = isRetryableAssistantError(message as AssistantMessage) || codexAccessVerification;
|
|
91
|
+
const errorMessage = codexAccessVerification && model
|
|
92
|
+
? `Codex temporarily could not verify access for the selected model ${provider}/${model}. No alternate model was requested. Provider response: ${providerMessage}`
|
|
93
|
+
: providerMessage;
|
|
89
94
|
return new WorkflowProviderError(errorMessage, {
|
|
90
95
|
stopReason: "error",
|
|
91
96
|
retryable,
|
|
@@ -1,4 +1,5 @@
|
|
|
1
1
|
import { assertWorkflowBudgetAvailable } from "./budget.ts";
|
|
2
|
+
import { combinedAgentAttemptError } from "./agent-failure.ts";
|
|
2
3
|
import { WorkflowAgentTimeoutError } from "./agent-limits.ts";
|
|
3
4
|
import { abortReason, linkAbortSignal, throwIfAborted } from "./cancellation.ts";
|
|
4
5
|
import { executeAgentAttempt } from "./agent-attempt.ts";
|
|
@@ -82,6 +83,7 @@ export async function runAgent(
|
|
|
82
83
|
|
|
83
84
|
let attemptPlan: AgentReplayPlan = replay;
|
|
84
85
|
let providerRetries = 0;
|
|
86
|
+
const priorAttemptFailures: unknown[] = [];
|
|
85
87
|
while (true) {
|
|
86
88
|
let outcome: AgentAttemptResult;
|
|
87
89
|
try {
|
|
@@ -95,25 +97,36 @@ export async function runAgent(
|
|
|
95
97
|
label,
|
|
96
98
|
rowId,
|
|
97
99
|
tags,
|
|
98
|
-
|
|
100
|
+
reserveLiveAgent: liveScope.reserve,
|
|
99
101
|
});
|
|
100
102
|
} catch (error) {
|
|
101
|
-
if (!(error instanceof WorkflowProviderError) || !error.retryable)
|
|
103
|
+
if (!(error instanceof WorkflowProviderError) || !error.retryable) {
|
|
104
|
+
throw priorAttemptFailures.length > 0
|
|
105
|
+
? combinedAgentAttemptError(label, priorAttemptFailures, error)
|
|
106
|
+
: error;
|
|
107
|
+
}
|
|
102
108
|
if (providerRetries >= agentRc.agentRetries) {
|
|
103
109
|
if (agentRc.agentRetries > 0) {
|
|
104
110
|
rc.progress.log(`${label}: transient provider failure after ${providerRetries} retries; giving up`);
|
|
105
111
|
}
|
|
106
|
-
throw
|
|
112
|
+
throw priorAttemptFailures.length > 0
|
|
113
|
+
? combinedAgentAttemptError(label, priorAttemptFailures, error)
|
|
114
|
+
: error;
|
|
107
115
|
}
|
|
108
116
|
|
|
109
|
-
|
|
110
|
-
|
|
111
|
-
|
|
112
|
-
|
|
113
|
-
|
|
114
|
-
|
|
115
|
-
|
|
116
|
-
|
|
117
|
+
priorAttemptFailures.push(error);
|
|
118
|
+
try {
|
|
119
|
+
assertWorkflowBudgetAvailable(agentRc.budget);
|
|
120
|
+
providerRetries++;
|
|
121
|
+
const delayMs = agentRetryDelayMs(providerRetries);
|
|
122
|
+
rc.progress.log(`${label}: transient provider failure; retry ${providerRetries}/${agentRc.agentRetries} in ${delayMs}ms`);
|
|
123
|
+
rc.perf.counter("agent.provider_retry", 1, tags);
|
|
124
|
+
rc.perf.observe("agent.provider_retry_delay_ms", delayMs, tags);
|
|
125
|
+
attemptPlan = { kind: "off" };
|
|
126
|
+
await agentRc.retryScheduler.sleep(delayMs, agentRc.signal);
|
|
127
|
+
} catch (retryError) {
|
|
128
|
+
throw combinedAgentAttemptError(label, priorAttemptFailures, retryError);
|
|
129
|
+
}
|
|
117
130
|
continue;
|
|
118
131
|
}
|
|
119
132
|
const settlement = await settleAgentAttempt({ rc: agentRc, label, tags, replay: attemptPlan, outcome });
|
|
@@ -151,15 +164,24 @@ function createAgentLiveScope(rc: RunContext, label: string) {
|
|
|
151
164
|
|
|
152
165
|
return {
|
|
153
166
|
signal: controller.signal,
|
|
154
|
-
|
|
155
|
-
rc.agentLimiter.
|
|
156
|
-
|
|
157
|
-
|
|
158
|
-
|
|
159
|
-
|
|
160
|
-
|
|
161
|
-
|
|
162
|
-
|
|
167
|
+
reserve() {
|
|
168
|
+
const reservation = rc.agentLimiter.reserve(controller.signal);
|
|
169
|
+
let committed = false;
|
|
170
|
+
return {
|
|
171
|
+
commit() {
|
|
172
|
+
if (committed) return;
|
|
173
|
+
reservation.commit();
|
|
174
|
+
committed = true;
|
|
175
|
+
if (timerStarted || rc.agentTimeoutMs === null) return;
|
|
176
|
+
timerStarted = true;
|
|
177
|
+
const timeoutMs = rc.agentTimeoutMs;
|
|
178
|
+
timer = setTimeout(
|
|
179
|
+
() => controller.abort(new WorkflowAgentTimeoutError(label, timeoutMs)),
|
|
180
|
+
timeoutMs,
|
|
181
|
+
);
|
|
182
|
+
},
|
|
183
|
+
release: () => reservation.release(),
|
|
184
|
+
};
|
|
163
185
|
},
|
|
164
186
|
dispose() {
|
|
165
187
|
if (timer) clearTimeout(timer);
|