@jameslovespancakes/pi-plus 1.0.12 → 1.0.13

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.
Files changed (45) hide show
  1. package/README.md +24 -3
  2. package/package.json +4 -2
  3. package/src/core/accounts/oauth-pool.ts +35 -0
  4. package/src/core/accounts/registry.ts +4 -0
  5. package/src/core/accounts/routing.ts +22 -0
  6. package/src/core/anthropic/identity.ts +54 -0
  7. package/src/core/anthropic/oauth.ts +3 -0
  8. package/src/core/anthropic/quota.ts +227 -28
  9. package/src/core/anthropic/store.ts +4 -1
  10. package/src/core/codex/quota.ts +43 -11
  11. package/src/core/codex/store.ts +21 -11
  12. package/src/core/config.ts +12 -0
  13. package/src/domains/agents/format.ts +97 -0
  14. package/src/domains/agents/index.ts +58 -47
  15. package/src/domains/compact/archive.ts +140 -0
  16. package/src/domains/compact/chunking.ts +166 -0
  17. package/src/domains/compact/index.ts +452 -0
  18. package/src/domains/compact/jev.ts +242 -0
  19. package/src/domains/compact/policy.ts +255 -0
  20. package/src/domains/compact/types.ts +79 -0
  21. package/src/domains/subscriptions/accounts-picker.ts +44 -4
  22. package/src/domains/subscriptions/accounts.ts +31 -8
  23. package/src/domains/subscriptions/index.ts +2 -2
  24. package/src/domains/subscriptions/provider.ts +46 -5
  25. package/src/domains/subscriptions/providers/anthropic.ts +37 -5
  26. package/src/domains/subscriptions/providers/codex.ts +120 -254
  27. package/src/domains/subscriptions/providers/hosted.ts +2 -2
  28. package/src/domains/subscriptions/providers/oauth-pool.ts +124 -72
  29. package/src/domains/workflows/index.ts +1 -1
  30. package/src/domains/workflows/runtime/agent-attempt.ts +41 -11
  31. package/src/domains/workflows/runtime/agent-failure.ts +14 -0
  32. package/src/domains/workflows/runtime/agent-limits.ts +24 -1
  33. package/src/domains/workflows/runtime/agent-runner.ts +42 -20
  34. package/src/domains/workflows/runtime/agent-workspace.ts +0 -24
  35. package/src/domains/workflows/runtime/engine.ts +48 -12
  36. package/src/domains/workflows/runtime/progress.ts +2 -2
  37. package/src/domains/workflows/runtime/types.ts +4 -4
  38. package/src/domains/workflows/runtime/ui/display-text.ts +15 -0
  39. package/src/domains/workflows/runtime/ui/workflow-inspector.ts +51 -14
  40. package/src/domains/workflows/runtime/ui/workflow-viewer-layout.ts +6 -6
  41. package/src/domains/workflows/runtime/workflow-run-history.ts +3 -1
  42. package/src/domains/workflows/runtime/workflow-run-record.ts +1 -0
  43. package/src/domains/workflows/runtime/worktree.ts +35 -1
  44. package/src/ui/usage-bars.ts +1 -2
  45. 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
- loadOAuthPool,
6
+ quotaStateFromHeaders,
7
+ refreshAbortSignal,
8
+ selectRoutingCandidate,
9
+ } from "../../../core/accounts/routing.ts";
10
+ import {
8
11
  oauthIdentity,
9
- saveOAuthAccount,
10
- saveOAuthPool,
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 oauthFor(spec: PooledOAuthProviderSpec): OAuthAuth {
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, ctx: AccountContext): Promise<OAuthCredential> {
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 duplicateAccount(spec: PooledOAuthProviderSpec, credential: OAuthCredential, excludeId?: string): PooledOAuthAccount | undefined {
72
- const identity = oauthIdentity(credential.access);
73
- return loadOAuthPool(spec.id).accounts.find((account) =>
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 loadOAuthPool(spec.id).accounts.map((account) => ({
84
- id: account.id,
85
- label: accountLabel(account),
86
- enabled: account.enabled !== false,
87
- expiresAt: account.expires,
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
- if (!await ctx.ui.confirm(`Add ${spec.label} account`, `Sign in with another ${spec.label} subscription?`)) {
93
- return undefined;
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
- saveOAuthAccount(spec.id, {
138
+ store().saveAccount({
100
139
  ...credential,
101
140
  id: randomUUID(),
102
141
  label,
103
142
  enabled: true,
104
- identity: oauthIdentity(credential.access),
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 = loadOAuthPool(spec.id).accounts.find(
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
- saveOAuthAccount(spec.id, {
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: oauthIdentity(credential.access),
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 pool = loadOAuthPool(spec.id);
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
- account.enabled = enabled;
132
- saveOAuthPool(spec.id, pool);
169
+ store().saveAccount({ ...account, enabled });
133
170
  },
134
171
 
135
172
  async rename(accountId, label): Promise<void> {
136
- const pool = loadOAuthPool(spec.id);
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
- account.label = label;
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 loadOAuthPool(spec.id).mode;
180
+ return store().load().mode;
146
181
  },
147
182
  async set(mode): Promise<RoutingMode> {
148
- setOAuthPoolMode(spec.id, mode);
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} quota headers when available, otherwise rotates fairly.`
154
- : `Uses ${spec.label} accounts in order, moving on when one is rate-limited.`;
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 = loadOAuthPool(spec.id);
199
+ const pool = storeFor(spec).load();
165
200
  const candidates = [
166
201
  {
167
- id: "main",
202
+ id: MAIN,
168
203
  order: 0,
169
- lastUsed: lastUsed.get(`${spec.id}:main`) ?? 0,
170
- quota: primaryQuota.get(spec.id),
171
- value: { id: "main", credential: primary },
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: "main", credential: primary };
218
+ return selectRoutingCandidate(candidates, pool.mode)?.value ?? { id: MAIN, credential: primary };
184
219
  }
185
220
 
186
- async function freshCredential(spec: PooledOAuthProviderSpec, account: PooledOAuthAccount, signal: AbortSignal): Promise<PooledOAuthAccount> {
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
- const refresh = oauthFor(spec).refresh(account, signal).then((credential) => {
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
- saveOAuthAccount(spec.id, updated);
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 signal = new AbortController().signal;
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
- saveOAuthAccount(spec.id, { ...selected.account, ...credential, lastUsed: now });
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
- recordQuotaResponse(spec, requestAccessToken(options), response.status, response.headers);
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
- function recordQuotaResponse(
259
- spec: PooledOAuthProviderSpec,
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 pool = loadOAuthPool(spec.id);
266
- const account = pool.accounts.find((candidate) => candidate.access === access);
267
- const previous = account?.quota ?? primaryQuota.get(spec.id);
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) saveOAuthAccount(spec.id, { ...account, quota });
272
- else primaryQuota.set(spec.id, quota);
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 fully bracketed workspace/session attempt. Cleanup always precedes settlement. */
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 admitLiveAgent: () => void;
42
+ readonly reserveLiveAgent: () => WorkflowAgentReservation;
41
43
  }): Promise<AgentAttemptResult> {
42
- const { rc, prompt, opts, resumeBaseContext, model, replay, label, rowId, tags, admitLiveAgent } = input;
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
- assertWorkflowBudgetAvailable(rc.budget);
108
- admitLiveAgent();
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
- try {
141
- const session = handle?.session;
142
- if (session) rc.perf.timeSync("agent.dispose_ms", () => session.dispose(), tags);
143
- } finally {
144
- await disposeAgentWorkspace(rc, label, workspace);
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
- admit(signal: AbortSignal | undefined): void {
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
  }
@@ -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
- admitLiveAgent: liveScope.admit,
100
+ reserveLiveAgent: liveScope.reserve,
99
101
  });
100
102
  } catch (error) {
101
- if (!(error instanceof WorkflowProviderError) || !error.retryable) throw error;
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 error;
112
+ throw priorAttemptFailures.length > 0
113
+ ? combinedAgentAttemptError(label, priorAttemptFailures, error)
114
+ : error;
107
115
  }
108
116
 
109
- assertWorkflowBudgetAvailable(agentRc.budget);
110
- providerRetries++;
111
- const delayMs = agentRetryDelayMs(providerRetries);
112
- rc.progress.log(`${label}: transient provider failure; retry ${providerRetries}/${agentRc.agentRetries} in ${delayMs}ms`);
113
- rc.perf.counter("agent.provider_retry", 1, tags);
114
- rc.perf.observe("agent.provider_retry_delay_ms", delayMs, tags);
115
- attemptPlan = { kind: "off" };
116
- await agentRc.retryScheduler.sleep(delayMs, agentRc.signal);
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
- admit() {
155
- rc.agentLimiter.admit(controller.signal);
156
- if (timerStarted || rc.agentTimeoutMs === null) return;
157
- timerStarted = true;
158
- const timeoutMs = rc.agentTimeoutMs;
159
- timer = setTimeout(
160
- () => controller.abort(new WorkflowAgentTimeoutError(label, timeoutMs)),
161
- timeoutMs,
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);
@@ -1,11 +1,9 @@
1
1
  import type { AgentExecutionOptions, AgentProgress } from "./agent-runner-types.ts";
2
- import { unknownErrorMessage } from "./unknown-error.ts";
3
2
  import type { WorktreeRegistry } from "./worktree.ts";
4
3
 
5
4
  interface AgentWorkspaceBase {
6
5
  readonly cwd: string;
7
6
  wrapResult(result: unknown): Promise<unknown>;
8
- dispose(): Promise<void>;
9
7
  }
10
8
 
11
9
  export interface SharedAgentWorkspace extends AgentWorkspaceBase {
@@ -39,7 +37,6 @@ export async function createAgentWorkspace(
39
37
  async wrapResult(result) {
40
38
  return result;
41
39
  },
42
- async dispose() {},
43
40
  };
44
41
  }
45
42
 
@@ -78,26 +75,5 @@ export async function createAgentWorkspace(
78
75
  if ("error" in patch) throw new Error(`Failed to capture isolated worktree patch: ${patch.error}`);
79
76
  return { result, patch: patch.patch, changed: patch.changed, baselineOid: added.baselineOid };
80
77
  },
81
- async dispose() {
82
- const removed = await rc.worktrees.remove(worktreePath);
83
- if (!removed.ok) {
84
- rc.progress.log(
85
- `${label}: failed to remove isolated worktree (${removed.error ?? (removed.stderr.trim() || "unknown error")})`,
86
- );
87
- }
88
- },
89
78
  };
90
79
  }
91
-
92
- export async function disposeAgentWorkspace(
93
- rc: Pick<AgentWorkspaceContext, "progress">,
94
- label: string,
95
- workspace: AgentWorkspace | undefined,
96
- ): Promise<void> {
97
- if (!workspace) return;
98
- try {
99
- await workspace.dispose();
100
- } catch (error) {
101
- rc.progress.log(`${label}: failed to dispose isolated workspace (${unknownErrorMessage(error)})`);
102
- }
103
- }