@claudexor/orchestrator 3.4.2 → 3.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.
Files changed (57) hide show
  1. package/dist/account-pool.d.ts +62 -0
  2. package/dist/account-pool.d.ts.map +1 -0
  3. package/dist/account-pool.js +103 -0
  4. package/dist/account-pool.js.map +1 -0
  5. package/dist/account-resolution.d.ts +85 -0
  6. package/dist/account-resolution.d.ts.map +1 -0
  7. package/dist/account-resolution.js +320 -0
  8. package/dist/account-resolution.js.map +1 -0
  9. package/dist/attemptOutputMarkers.d.ts +30 -0
  10. package/dist/attemptOutputMarkers.d.ts.map +1 -0
  11. package/dist/attemptOutputMarkers.js +31 -0
  12. package/dist/attemptOutputMarkers.js.map +1 -0
  13. package/dist/attemptTelemetry.d.ts +5 -4
  14. package/dist/attemptTelemetry.d.ts.map +1 -1
  15. package/dist/attemptTelemetry.js +4 -2
  16. package/dist/attemptTelemetry.js.map +1 -1
  17. package/dist/credential-cooldown.d.ts +131 -0
  18. package/dist/credential-cooldown.d.ts.map +1 -0
  19. package/dist/credential-cooldown.js +169 -0
  20. package/dist/credential-cooldown.js.map +1 -0
  21. package/dist/credential-differential.d.ts +81 -0
  22. package/dist/credential-differential.d.ts.map +1 -0
  23. package/dist/credential-differential.js +110 -0
  24. package/dist/credential-differential.js.map +1 -0
  25. package/dist/credential-preflight.d.ts +36 -0
  26. package/dist/credential-preflight.d.ts.map +1 -0
  27. package/dist/credential-preflight.js +78 -0
  28. package/dist/credential-preflight.js.map +1 -0
  29. package/dist/credential-profile-rotation.d.ts +80 -54
  30. package/dist/credential-profile-rotation.d.ts.map +1 -1
  31. package/dist/credential-profile-rotation.js +191 -163
  32. package/dist/credential-profile-rotation.js.map +1 -1
  33. package/dist/credential-profiles.d.ts +3 -49
  34. package/dist/credential-profiles.d.ts.map +1 -1
  35. package/dist/credential-profiles.js +2 -46
  36. package/dist/credential-profiles.js.map +1 -1
  37. package/dist/index.d.ts +2 -1
  38. package/dist/index.d.ts.map +1 -1
  39. package/dist/index.js +2 -1
  40. package/dist/index.js.map +1 -1
  41. package/dist/laneStreamEvents.d.ts +54 -0
  42. package/dist/laneStreamEvents.d.ts.map +1 -0
  43. package/dist/laneStreamEvents.js +81 -0
  44. package/dist/laneStreamEvents.js.map +1 -0
  45. package/dist/orchestrator-credentials.d.ts +93 -0
  46. package/dist/orchestrator-credentials.d.ts.map +1 -0
  47. package/dist/orchestrator-credentials.js +156 -0
  48. package/dist/orchestrator-credentials.js.map +1 -0
  49. package/dist/orchestrator.d.ts +17 -30
  50. package/dist/orchestrator.d.ts.map +1 -1
  51. package/dist/orchestrator.js +278 -324
  52. package/dist/orchestrator.js.map +1 -1
  53. package/dist/rotation-predicate.d.ts +71 -0
  54. package/dist/rotation-predicate.d.ts.map +1 -0
  55. package/dist/rotation-predicate.js +29 -0
  56. package/dist/rotation-predicate.js.map +1 -0
  57. package/package.json +17 -17
@@ -1,5 +1,7 @@
1
1
  import { existsSync } from "node:fs";
2
- import { effectiveAuthPreference, observeNativeSessionEvent, preflightCredentialProfile, preflightDefaultSubject, probeCredentialProfileStatus, profileHeadroomBreach, profileStatusAdmits, resolveCredentialProfile, resumeSessionForProfile, rotateSpecOnTypedLimit, selectedProfileAvailability, staticRotationCandidates, vendorVerifiedProfileStatus, } from "./credential-profiles.js";
2
+ import { effectiveAuthPreference, observeNativeSessionEvent, resumeSessionForProfile, rotateSpecOnTypedLimit, selectedProfileAvailability, } from "./credential-profiles.js";
3
+ import { OrchestratorCredentials, rotatedSpecInLaneHome } from "./orchestrator-credentials.js";
4
+ import { accountPoolRows } from "./account-pool.js";
3
5
  import { writeRunTelemetryArtifact } from "./runTelemetryWriter.js";
4
6
  import { buildFileBackedSynthesisInput, materializeWinnerOutputs, stageFileBackedContext, writeCandidateAttemptArtifacts, } from "./candidateOutputs.js";
5
7
  import { processAttemptUsage } from "./attemptUsage.js";
@@ -13,7 +15,7 @@ import { finalizePlanRun, runCouncilPlan, writePlanHarnessFailure, } from "./pla
13
15
  import { runPlannerAttempt as executePlannerAttempt, } from "./plannerAttempt.js";
14
16
  import { HarnessRunSpec, ModeKind as ModeKindSchema, QuotaSnapshot as QuotaSnapshotSchema, isBlocking, makeOutcomeFacts, normalizeUserOutputSchema, strictifyOutputSchema, estimateEffectiveAuthRoute, } from "@claudexor/schema";
15
17
  import { globalConfigDir, loadConfig } from "@claudexor/config";
16
- import { AnswerAssembly, CLAUDEXOR_BROWSER_ARTIFACT_SUBDIR, countsAsAgentProgress, HarnessUnavailableError, summarizeDiffPaths as diffStats, withInactivityWatchdog, } from "@claudexor/core";
18
+ import { acceptedTryOutput, AnswerAssembly, CLAUDEXOR_BROWSER_ARTIFACT_SUBDIR, countsAsAgentProgress, HarnessUnavailableError, summarizeDiffPaths as diffStats, withInactivityWatchdog, } from "@claudexor/core";
17
19
  import { assertRouteModelsAllowed } from "./modelGovernance.js";
18
20
  import { authModeForCredentialRoute, authModeForPreference } from "./auth-route-classification.js";
19
21
  import { governRouteEffort } from "./effortGovernance.js";
@@ -31,7 +33,8 @@ import { buildRevisePrompt } from "./revisePrompt.js";
31
33
  import { cancelledResult, declaredFailure, failTerminally, guardAnnouncedRun, writeFailure, } from "./runTerminals.js";
32
34
  import { budgetFailureRecord, classifyBudgetFailure } from "./budgetFailure.js";
33
35
  import { assertOutputSchemaCompiles, finalizeStructuredOutput } from "./structuredOutput.js";
34
- import { transientRetryDelayMs, promptWithEngineConstraints, sleep, redactHarnessEvent, harnessEventPayload, safeErrorMessage, renderSummary, observeBudgetSignals, rotateOnStall, recordCleanAttemptMetrics, envInheritance, transientRetryPolicy, reviewerTimeoutMs, harnessInactivityTimeoutMs, observeAuthSwitch, emitPrimaryDivergence, emitPoolDegraded, deliveryRefusalFailure, winnerNeedsHuman, writeRaceDeliveryDecision, } from "./runSupport.js";
36
+ import { dropDeltaPastBudget, emitPlanProgress, emitTransientExhausted, emitTransientRetryPlan, observeReadonlySpend, } from "./laneStreamEvents.js";
37
+ import { promptWithEngineConstraints, sleep, redactHarnessEvent, harnessEventPayload, safeErrorMessage, renderSummary, observeBudgetSignals, rotateOnStall, recordCleanAttemptMetrics, envInheritance, transientRetryPolicy, reviewerTimeoutMs, harnessInactivityTimeoutMs, observeAuthSwitch, emitPrimaryDivergence, emitPoolDegraded, deliveryRefusalFailure, winnerNeedsHuman, writeRaceDeliveryDecision, } from "./runSupport.js";
35
38
  import { candidateStatusInRouteContext, resolveReadOnlyRouteContext, } from "./routeContext.js";
36
39
  import { resolveAutoReviewerPanel, resolveExplicitReviewerPanel } from "./reviewerPanel.js";
37
40
  import { ensureWriteModeGitBoundary } from "./git-precondition.js";
@@ -40,6 +43,7 @@ import { activePlanPointer, resolveContinuitySummary, workspaceAnchor, } from ".
40
43
  import { runDiffReview } from "./diffReview.js";
41
44
  import { rawScoutBundle, resolveDeepScanSynthesis, } from "./deepScanReducer.js";
42
45
  import { classifyAdapterThrow, createAttemptTelemetry, observeAttemptTelemetry, setAttemptOutcome, telemetrySummary, toolWarnings, unrecoveredToolErrors, webUnsatisfied, } from "./attemptTelemetry.js";
46
+ import { newAttemptOutputMarkers } from "./attemptOutputMarkers.js";
43
47
  import * as delegateFailure from "./delegationFailure.js";
44
48
  import * as secretDiff from "./secretDiff.js";
45
49
  import { dominantHarnessFailureCategory, harnessFailureNextActions } from "./harnessFailure.js";
@@ -332,11 +336,21 @@ export class Orchestrator {
332
336
  const cfg = this.config(input.repoRoot)?.global;
333
337
  const profile = quotaAdmission?.model === model
334
338
  ? quotaAdmission.profile
335
- : await this.preflightProfile(input, harnessId, model, log, defaultRoute);
339
+ : await this.credentials.preflightProfile(input, harnessId, model, log, defaultRoute);
340
+ // Q3=A: the PAID api_key route serves ONLY the EXPLICIT api_key
341
+ // preference (the user's paid election, or the sole permitted service of
342
+ // an empty/exhausted pool) — never silently under auto, which terminalizes
343
+ // typed instead; quotaAdmission.route carries the admission verdict.
344
+ const forcedApiKeyRoute = profile === null &&
345
+ (quotaAdmission?.model === model
346
+ ? quotaAdmission.route === "managed_api_key"
347
+ : this.credentials.poolApiKeyRoute(input, harnessId));
336
348
  return {
337
349
  // "auto" at ANY level falls through (thread turns send the thread default
338
350
  // "auto" as a per-run value; it must not shadow a configured preference).
339
- auth_preference: effectiveAuthPreference(input.authPreference, cfg?.harnesses?.[harnessId]?.auth_preference, cfg?.routing?.auth_preference),
351
+ auth_preference: forcedApiKeyRoute
352
+ ? "api_key"
353
+ : effectiveAuthPreference(input.authPreference, cfg?.harnesses?.[harnessId]?.auth_preference, cfg?.routing?.auth_preference),
340
354
  resume_session_id: resumeSessionForProfile(input.resumeSessions?.[harnessId], profile),
341
355
  credential_profile: profile,
342
356
  };
@@ -350,138 +364,26 @@ export class Orchestrator {
350
364
  * Keyed by the run's REQUESTED credential profile — the same key the daemon's
351
365
  * `resumeMap` lookup uses (INV-135), so record and resume land in one home.
352
366
  */
353
- laneHomeEnvFor(input, harnessId) {
367
+ laneHomeEnvFor(input, harnessId,
368
+ // Keyed by the RESOLVED account (INV-135/137): the same key the recorded
369
+ // native session carries, so record and resume share one home.
370
+ resolvedProfileId) {
354
371
  if (!input.threadId)
355
372
  return null;
356
- return new WorkspaceManager(input.repoRoot).laneHomeEnv(input.threadId, harnessId,
357
- // The lane is keyed by the EFFECTIVE account (INV-135): an explicit pin,
358
- // else null resolves the same home the recorded native session lives in.
359
- this.effectiveProfileId(input, harnessId)).env;
360
- }
361
- /**
362
- * The per-harness EFFECTIVE credential profile id (INV-135 accounts
363
- * authority): an explicit per-run/per-thread pin wins; else null — POOL AUTO,
364
- * the native/CLI login default subject (enabled profiles route only by
365
- * explicit pin or quota rotation, never as a silent Active default).
366
- */
367
- effectiveProfileId(input, _harnessId) {
368
- return input.credentialProfileId ?? null;
369
- }
370
- /** Whether the native/CLI login is EXCLUDED from this harness's credential
371
- * ladder (INV-135). When excluded, a harness with no effective profile has
372
- * nothing routable and must refuse — never silently fall back into it. */
373
- nativeCredentialsDisabled(repoRoot, harnessId) {
374
- return (this.config(repoRoot)?.global.harnesses?.[harnessId]?.native_credentials_enabled === false);
375
- }
376
- resolveCredentialProfile(input, harnessId) {
377
- const explicit = input.credentialProfileId ?? null;
378
- const wanted = this.effectiveProfileId(input, harnessId);
379
- if (!wanted)
380
- return null;
381
- const registry = this.config(input.repoRoot)?.global.credential_profiles ?? [];
382
- try {
383
- return resolveCredentialProfile(registry, wanted, harnessId);
384
- }
385
- catch (err) {
386
- // With Active removed, `wanted` is always the explicit pin; keep the
387
- // fail-closed guard so any future non-pin source still refuses loudly.
388
- if (!explicit) {
389
- throw new Error(`harness "${harnessId}" credential profile "${wanted}" is unusable: ${err instanceof Error ? err.message : String(err)}`);
390
- }
391
- throw err;
392
- }
393
- }
394
- /** The typed effective auth route for a SELECTED credential profile
395
- * (round-18 #2): adapters execute strictly by credential_kind, so routing,
396
- * billing classification, model truth, and quota lookup must share this
397
- * one fact — never the default store's sources or a previous default-route
398
- * metric. null = no profile selected or it does not resolve here. */
399
- profileAuthRoute(input, harnessId) {
400
- try {
401
- const profile = this.resolveCredentialProfile(input, harnessId);
402
- if (!profile)
403
- return null;
404
- return profile.credential_kind === "api_key" ? "api_key" : "local_session";
405
- }
406
- catch {
407
- return null;
408
- }
409
- }
410
- profilePolicy(repoRoot, harnessId) {
411
- const policy = this.config(repoRoot)?.global.harnesses?.[harnessId]?.profile_policy;
412
- return policy ?? { limit_action: "fail", rotation_eligible: [], headroom_threshold: 0.9 };
413
- }
414
- /** The quota poller's authenticated vendor evidence for THIS decision epoch,
415
- * read once so every profile in the epoch is judged against one observation
416
- * set. This is what makes profile readiness at admission mean the same thing
417
- * it means on the Accounts card (INV-135 honesty). */
418
- vendorQuotaObservations() {
419
- return {
420
- snapshots: this.deps.quotaSnapshots?.() ?? [],
421
- absences: this.deps.quotaAbsences?.() ?? [],
422
- };
423
- }
424
- /** Fresh profile readiness for one rotation decision epoch. Accounts uses
425
- * the same probe wrapper + vendor overlay + admission predicate when
426
- * projecting next_up. */
427
- async readyProfileIdsForRotation(input, harnessId, current, excluded = new Set()) {
428
- const profiles = staticRotationCandidates({
429
- registry: this.config(input.repoRoot)?.global.credential_profiles ?? [],
430
- harnessId,
431
- policy: this.profilePolicy(input.repoRoot, harnessId),
432
- current,
433
- excluded,
434
- });
435
- const adapter = this.deps.registry.get(harnessId);
436
- const quota = this.vendorQuotaObservations();
437
- const entries = await Promise.all(profiles.map(async (profile) => ({
438
- profile,
439
- // Rotating INTO a profile the vendor has already rejected would spend a
440
- // whole attempt to rediscover the 401 the poller reported a minute ago.
441
- status: vendorVerifiedProfileStatus(await probeCredentialProfileStatus(profile, adapter?.probeCredentialProfile?.bind(adapter)), quota),
442
- })));
443
- return new Set(entries
444
- .filter(({ profile, status }) => profileStatusAdmits(profile, status))
445
- .map(({ profile }) => profile.profile_id));
446
- }
447
- async preflightProfile(input, harnessId, model, log, defaultRoute) {
448
- const profile = this.resolveCredentialProfile(input, harnessId);
449
- const policy = this.profilePolicy(input.repoRoot, harnessId);
450
- const registry = this.config(input.repoRoot)?.global.credential_profiles ?? [];
451
- const snapshots = this.deps.quotaSnapshots?.() ?? [];
452
- const emit = (type, payload) => log?.emit(type, payload);
453
- const breach = profileHeadroomBreach(snapshots, harnessId, profile?.profile_id ?? null, policy.headroom_threshold, model);
454
- const readyProfileIds = policy.limit_action === "rotate" &&
455
- breach !== null &&
456
- (profile !== null || defaultRoute === "local_session")
457
- ? await this.readyProfileIdsForRotation(input, harnessId, profile)
458
- : new Set();
459
- if (!profile) {
460
- // Unpinned runs (INV-135 auto-balance): under `rotate`, a fresh
461
- // default-subject headroom breach starts on the next eligible
462
- // subscription profile instead; `fail`/`ask` change nothing.
463
- return preflightDefaultSubject({
464
- harnessId,
465
- policy,
466
- registry,
467
- snapshots,
468
- readyProfileIds,
469
- defaultRoute,
470
- model,
471
- emit,
472
- });
473
- }
474
- return preflightCredentialProfile({
475
- profile,
476
- harnessId,
477
- policy,
478
- registry,
479
- snapshots,
480
- readyProfileIds,
481
- model,
482
- emit,
483
- });
373
+ return new WorkspaceManager(input.repoRoot).laneHomeEnv(input.threadId, harnessId, resolvedProfileId).env;
484
374
  }
375
+ /** Credential-resolution cluster (INV-135), split to orchestrator-credentials.ts. */
376
+ credentials = new OrchestratorCredentials({
377
+ // Accessor functions: the field initializer runs before the constructor
378
+ // assigns this.deps, so every read defers to call time.
379
+ config: (repoRoot) => this.config(repoRoot),
380
+ registry: () => this.deps.registry,
381
+ quotaSnapshots: () => this.deps.quotaSnapshots?.() ?? [],
382
+ quotaAbsences: () => this.deps.quotaAbsences?.() ?? [],
383
+ credentialUnusable: () => this.deps.credentialUnusable?.() ?? [],
384
+ recordCredentialUnusable: (obs) => this.deps.recordCredentialUnusable?.(obs),
385
+ authPreferenceForHarness: (repoRoot, harnessId, runPreference) => this.authPreferenceForHarness(repoRoot, harnessId, runPreference),
386
+ });
485
387
  /**
486
388
  * Resolve candidate adapters: explicit `--harness`, else available real harnesses, then
487
389
  * **capability-gate** to those that can actually produce work for `intent` (e.g. a
@@ -547,10 +449,18 @@ export class Orchestrator {
547
449
  : `credential profile "${input.credentialProfileId}" belongs to unavailable harness(es): ${registered.map((p) => p.harness_id).join(", ")}`);
548
450
  }
549
451
  else {
550
- // Auto-pools take only doctor-OK harnesses (BIBLE §2: doctor decides
551
- // readiness; a key string or degraded route is visible but not routable).
452
+ // Auto-pools take doctor-OK harnesses (BIBLE §2: doctor decides
453
+ // readiness; a key string or degraded route is visible but not
454
+ // routable) PLUS harnesses with enabled account rows: the default
455
+ // doctor only sees the default store, and a signed-in registry row is
456
+ // invisible to it (cursor after the host-Keychain retirement, or a
457
+ // named-rows-only claude/codex install). Row-bearing lanes still pass
458
+ // the per-lane row probe below before they are admitted.
459
+ const registry = this.config(input.repoRoot)?.global.credential_profiles ?? [];
552
460
  ids = statuses
553
- .filter((s) => s.manifest?.kind !== "fake" && s.status === "ok" && s.enabledIntents.includes(intent))
461
+ .filter((s) => s.manifest?.kind !== "fake" &&
462
+ ((s.status === "ok" && s.enabledIntents.includes(intent)) ||
463
+ accountPoolRows(registry, s.id).length > 0))
554
464
  .map((s) => s.id);
555
465
  if (ids.length === 0) {
556
466
  throw new HarnessUnavailableError("no doctor-ok harness for this mode; install/login codex/claude/cursor/opencode (see `claudexor doctor`), or pass --harness explicitly");
@@ -598,7 +508,7 @@ export class Orchestrator {
598
508
  };
599
509
  // One observation set for the whole admission pass, so two lanes cannot be
600
510
  // judged against different vendor epochs.
601
- const vendorQuota = this.vendorQuotaObservations();
511
+ const vendorQuota = this.credentials.vendorQuotaObservations();
602
512
  for (const id of ids) {
603
513
  const adapter = this.deps.registry.get(id);
604
514
  if (!adapter) {
@@ -621,13 +531,22 @@ export class Orchestrator {
621
531
  dropLane(id, "settings", why);
622
532
  continue;
623
533
  }
624
- // INV-135 accounts authority: with the native/CLI login excluded and no
625
- // explicit pin, an unpinned run has nothing routable. Refuse an explicit
626
- // request naming the setting; drop it from an auto pool — never silently
627
- // fall back INTO the disabled login.
628
- if (this.effectiveProfileId(input, id) === null &&
629
- this.nativeCredentialsDisabled(input.repoRoot, id)) {
630
- const why = `${id} has no routable credential: the CLI login is disabled (harnesses.${id}.native_credentials_enabled=false) and no account is pinned (--profile)`;
534
+ // Incomplete unified-accounts migration refuses THIS harness only.
535
+ const migrationBlock = this.deps.accountsMigrationGate?.(id) ?? null;
536
+ if (migrationBlock) {
537
+ dropLane(id, "credential", migrationBlock.reason);
538
+ continue;
539
+ }
540
+ // INV-135: native login excluded + no rows + no pin only the
541
+ // EXPLICITLY-requested paid route can serve (Q3=A — `auto` never
542
+ // silently takes the API key); everything else refuses/drops here,
543
+ // never a silent spawn INTO the disabled login.
544
+ if (this.credentials.effectiveProfileId(input, id) === null &&
545
+ this.credentials.nativeCredentialsDisabled(input.repoRoot, id) &&
546
+ accountPoolRows(this.config(input.repoRoot)?.global.credential_profiles ?? [], id)
547
+ .length === 0 &&
548
+ this.authPreferenceForHarness(input.repoRoot, id, input.authPreference) !== "api_key") {
549
+ const why = `${id} has no routable credential: the CLI login is disabled (harnesses.${id}.native_credentials_enabled=false), no account is signed in, and the paid API-key route requires the explicit api_key preference (pin an account with --profile or connect one)`;
631
550
  dropLane(id, "credential", why);
632
551
  continue;
633
552
  }
@@ -656,37 +575,64 @@ export class Orchestrator {
656
575
  // routing truth). Capability/manifest gating above still applies.
657
576
  let profileAdmitted = false;
658
577
  const profileAdapter = this.deps.registry.get(id);
659
- const profileVerdict = await selectedProfileAvailability({
660
- registry: this.config(input.repoRoot)?.global.credential_profiles ?? [],
661
- // The EFFECTIVE account (INV-135): an explicit pin is authenticated by ITS store.
662
- profileId: this.effectiveProfileId(input, id),
663
- harnessId: id,
664
- probe: profileAdapter?.probeCredentialProfile?.bind(profileAdapter),
665
- // `verification: passed` from the local store only means a login file
666
- // is present. The poller's authenticated vendor call is the only
667
- // liveness evidence we have, and admission is the surface that must
668
- // act on it — otherwise the run dispatches into a revoked token.
669
- quota: vendorQuota,
670
- });
671
- if (profileVerdict !== null) {
672
- if (profileVerdict === "available") {
578
+ const profileProbe = profileAdapter?.probeCredentialProfile?.bind(profileAdapter);
579
+ const explicitPin = this.credentials.effectiveProfileId(input, id);
580
+ // The account candidates whose readiness may overlay the default-store
581
+ // verdict: the explicit pin when set; otherwise — row-aware UNPINNED
582
+ // admission — the thread-bound row followed by the harness's enabled
583
+ // pool rows, consulted only when the default doctor did not already
584
+ // admit the lane. A signed-in registry row is invisible to the default
585
+ // doctor (cursor after the host-Keychain retirement, or a named-rows-
586
+ // only claude/codex install), so an unpinned run must be admitted on
587
+ // row readiness exactly the way explicit pins already are.
588
+ const rowCandidateIds = [];
589
+ if (explicitPin) {
590
+ rowCandidateIds.push(explicitPin);
591
+ }
592
+ else if (status.status !== "ok") {
593
+ const pool = accountPoolRows(this.config(input.repoRoot)?.global.credential_profiles ?? [], id);
594
+ const bound = input.threadAccountBindings?.[id] ?? null;
595
+ rowCandidateIds.push(...(bound && pool.some((row) => row.profile_id === bound) ? [bound] : []), ...pool.map((row) => row.profile_id).filter((rowId) => rowId !== bound));
596
+ }
597
+ let lastRowVerdict = null;
598
+ for (const candidateId of rowCandidateIds) {
599
+ const verdict = await selectedProfileAvailability({
600
+ registry: this.config(input.repoRoot)?.global.credential_profiles ?? [],
601
+ profileId: candidateId,
602
+ harnessId: id,
603
+ probe: profileProbe,
604
+ // `verification: passed` from the local store only means a login file
605
+ // is present. The poller's authenticated vendor call is the only
606
+ // liveness evidence we have, and admission is the surface that must
607
+ // act on it — otherwise the run dispatches into a revoked token.
608
+ quota: vendorQuota,
609
+ });
610
+ if (verdict === "available") {
673
611
  profileAdmitted = true;
674
- // A valid profile restores manifest intent truth when the default store failed.
675
- if (status.status !== "ok") {
676
- status = {
677
- ...status,
678
- status: "degraded",
679
- enabledIntents: capabilityIntents(manifest.capabilities),
680
- };
681
- statusById.set(id, status);
682
- }
612
+ break;
683
613
  }
684
- else {
685
- const why = `${id} credential profile is not ready: ${profileVerdict}`;
686
- dropLane(id, "credential", why);
687
- continue;
614
+ lastRowVerdict = verdict;
615
+ }
616
+ if (profileAdmitted) {
617
+ // A valid account row restores manifest intent truth when the default store failed.
618
+ if (status.status !== "ok") {
619
+ status = {
620
+ ...status,
621
+ status: "degraded",
622
+ enabledIntents: capabilityIntents(manifest.capabilities),
623
+ };
624
+ statusById.set(id, status);
688
625
  }
689
626
  }
627
+ else if (explicitPin) {
628
+ // An explicit pin that is not ready refuses/drops the lane; a
629
+ // not-ready UNPINNED row merely leaves the default doctor verdict in
630
+ // charge (the gates below keep the refusal honest when neither a row
631
+ // nor a default login exists).
632
+ const why = `${id} credential profile is not ready: ${lastRowVerdict}`;
633
+ dropLane(id, "credential", why);
634
+ continue;
635
+ }
690
636
  if (status.status === "unavailable" && !profileAdmitted) {
691
637
  const why = `${id} is unavailable${status.reasons.length ? `: ${status.reasons.join("; ")}` : ""}`;
692
638
  dropLane(id, "doctor", why);
@@ -749,7 +695,7 @@ export class Orchestrator {
749
695
  knownModels: manifest.capabilities.known_models,
750
696
  // A selected profile's credential_kind IS the route (round-18 #2);
751
697
  // the default store's sources apply only to profile-less runs.
752
- authRouteEstimate: this.profileAuthRoute(input, id) ??
698
+ authRouteEstimate: this.credentials.profileAuthRoute(input, id) ??
753
699
  estimateEffectiveAuthRoute(this.authPreferenceForHarness(input.repoRoot, id, input.authPreference), status.authSources),
754
700
  quotaAdmission: { model: null, profile: null, route: null },
755
701
  supportsSynthesize: manifest.capabilities.synthesize,
@@ -790,20 +736,41 @@ export class Orchestrator {
790
736
  // particular, an opt-in default-subject rotation has to select its ready
791
737
  // profile before the budget router filters the exhausted default away.
792
738
  // The same resolved profile is reused by the first spec build below.
793
- const quotaPreparedPool = await Promise.all(pool.map(async (routed) => {
739
+ const quotaPrepared = await Promise.all(pool.map(async (routed) => {
794
740
  const model = input.models?.[routed.adapter.id] ?? routed.settings?.defaultModel ?? null;
795
- const profile = await this.preflightProfile(input, routed.adapter.id, model, log, routed.authRouteEstimate);
741
+ let profile;
742
+ try {
743
+ profile = await this.credentials.preflightProfile(input, routed.adapter.id, model, log, routed.authRouteEstimate);
744
+ }
745
+ catch (err) {
746
+ // Q3=A: ONE harness's exhausted account pool must not kill an AUTO
747
+ // multi-harness run — the lane drops with the typed disclosure and a
748
+ // surviving sibling serves. An EXPLICIT pool (and the last remaining
749
+ // auto lane) keeps the typed `credential_pool_exhausted` terminal so
750
+ // its code + earliest reset reach failure.yaml intact.
751
+ if (!explicitPool &&
752
+ pool.length > 1 &&
753
+ err.code === "credential_pool_exhausted") {
754
+ dropLane(routed.adapter.id, "credential", safeErrorMessage(err));
755
+ return null;
756
+ }
757
+ throw err;
758
+ }
796
759
  const route = profile
797
760
  ? profile.credential_kind === "api_key"
798
761
  ? "managed_api_key"
799
762
  : "vendor_native"
800
- : routed.authRouteEstimate === "api_key"
801
- ? "managed_api_key"
802
- : routed.authRouteEstimate === "local_session"
803
- ? "vendor_native"
804
- : null;
763
+ : // The explicitly-opted PAID route (Q3=A) classifies as managed_api_key.
764
+ this.credentials.poolApiKeyRoute(input, routed.adapter.id)
765
+ ? "managed_api_key"
766
+ : routed.authRouteEstimate === "api_key"
767
+ ? "managed_api_key"
768
+ : routed.authRouteEstimate === "local_session"
769
+ ? "vendor_native"
770
+ : null;
805
771
  return { ...routed, quotaAdmission: { model, profile, route } };
806
772
  }));
773
+ const quotaPreparedPool = quotaPrepared.filter((routed) => routed !== null);
807
774
  const ordered = this.orderPool(quotaPreparedPool, input, intent, statusById, ledger, runId);
808
775
  if (ordered.length === 0) {
809
776
  throw new HarnessUnavailableError(`no harness remains eligible for '${intent}' after budget and quota routing`);
@@ -1347,7 +1314,7 @@ export class Orchestrator {
1347
1314
  adapter: this.deps.registry.get(harnessId),
1348
1315
  credentialProfile: sessionFields.credential_profile,
1349
1316
  authPreference: sessionFields.auth_preference ?? "auto",
1350
- laneEnv: this.laneHomeEnvFor(runInput, harnessId) ?? {},
1317
+ laneEnv: this.laneHomeEnvFor(runInput, harnessId, profileId) ?? {},
1351
1318
  envInheritance: envInheritance(this.config(runInput.repoRoot)),
1352
1319
  signal: runInput.signal,
1353
1320
  });
@@ -1501,13 +1468,12 @@ export class Orchestrator {
1501
1468
  let cost = 0;
1502
1469
  let costEstimated = false;
1503
1470
  let harnessErrored = false;
1504
- // W-C4 delta flood budget (per attempt): counts forwarded delta chunks.
1505
- let deltaCount = 0;
1506
- let deltaCutoffDisclosed = false;
1471
+ let poolExhausted = null; // A5: typed pool-exhausted refusal
1472
+ const deltaFlood = { count: 0, disclosed: false }; // W-C4 per-attempt delta budget
1507
1473
  // QA-024: emit the belt-failure disclosure event at most once per attempt.
1508
1474
  let beltFailureDisclosed = false;
1509
1475
  const errors = [];
1510
- const answer = new AnswerAssembly();
1476
+ let answer = new AnswerAssembly();
1511
1477
  const retryPolicy = transientRetryPolicy(this.config(contract.repo.root));
1512
1478
  // QA-024: the delegation belt is the ONLY engine-owned extra MCP server
1513
1479
  // injected into an agent lane (the browser MCP rides its own field), so its
@@ -1533,6 +1499,11 @@ export class Orchestrator {
1533
1499
  }
1534
1500
  try {
1535
1501
  for (let nativeTry = 0; !signal?.aborted; nativeTry += 1) {
1502
+ // A3 per-try isolation: neither output nor progress markers leak across tries.
1503
+ if (nativeTry > 0) {
1504
+ answer = new AnswerAssembly();
1505
+ telemetry.outputMarkers = newAttemptOutputMarkers();
1506
+ }
1536
1507
  const clearFileBackedContext = stageFileBackedContext(envelope.worktree_path, fileBackedContext);
1537
1508
  const runSpec = nativeTry === 0
1538
1509
  ? spec
@@ -1572,27 +1543,8 @@ export class Orchestrator {
1572
1543
  if (ev.type === "patch_produced")
1573
1544
  continue;
1574
1545
  const safeEv = redactHarnessEvent(ev);
1575
- // W-C4 flood guard (review sol #10): a per-character delta stream
1576
- // would otherwise persist/SSE one journal event PER CHUNK without
1577
- // bound. Delta messages are DISPLAY-only (the complete message
1578
- // still follows and carries the authoritative text), so past a
1579
- // per-attempt budget we DROP further deltas and disclose the
1580
- // cutoff ONCE — the final answer is unaffected.
1581
- if (safeEv.type === "message" && safeEv.payload?.["delta"] === true) {
1582
- deltaCount += 1;
1583
- if (deltaCount > Orchestrator.MAX_DELTAS_PER_ATTEMPT) {
1584
- if (!deltaCutoffDisclosed) {
1585
- deltaCutoffDisclosed = true;
1586
- log?.emit("harness.event", {
1587
- harness_id: adapter.id,
1588
- attempt_id: attemptId,
1589
- type: "status",
1590
- title: `live delta stream capped at ${Orchestrator.MAX_DELTAS_PER_ATTEMPT} chunks; the complete message still lands`,
1591
- });
1592
- }
1593
- continue; // drop this delta; never journal past the budget
1594
- }
1595
- }
1546
+ if (dropDeltaPastBudget(safeEv, deltaFlood, Orchestrator.MAX_DELTAS_PER_ATTEMPT, (t, p) => log?.emit(t, p), adapter.id, attemptId))
1547
+ continue;
1596
1548
  safeInvoke(onHarnessEvent, safeEv);
1597
1549
  // In-place turns run in the live tree under the native environment, so
1598
1550
  // the session they emit IS reachable for the next turn: record it. An
@@ -1619,15 +1571,7 @@ export class Orchestrator {
1619
1571
  reason: "mcp_server_failed_to_start",
1620
1572
  });
1621
1573
  }
1622
- // Live plan checklist: forward the adapter's typed plan
1623
- // progress as a run event (LAST WINS; the UI renders the latest).
1624
- if (safeEv.plan_progress) {
1625
- log?.emit("plan.progress", {
1626
- attempt_id: attemptId,
1627
- harness_id: adapter.id,
1628
- items: safeEv.plan_progress.items,
1629
- });
1630
- }
1574
+ emitPlanProgress((t, p) => log?.emit(t, p), adapter.id, attemptId, safeEv);
1631
1575
  if (safeEv.type === "usage") {
1632
1576
  const usage = processAttemptUsage({
1633
1577
  event: safeEv,
@@ -1683,38 +1627,53 @@ export class Orchestrator {
1683
1627
  }
1684
1628
  const newTransients = telemetry.transientFailures.slice(transientStart);
1685
1629
  const transient = newTransients.at(-1) ?? null;
1686
- // #31: the centralized retry gate reads the classified `retryable`, not a
1687
- // bare "saw any transient" boolean.
1630
+ // #31: the centralized retry gate reads the classified `retryable` verdict.
1688
1631
  const sawRetryable = newTransients.some((f) => f.retryable);
1689
1632
  const sawTypedLimit = telemetry.rateLimits.length > rateLimitStart;
1690
1633
  const currentDiff = await wsm.diff(envelope);
1691
- const currentAnswer = answer.text();
1692
- const deliverableEmpty = currentDiff.trim().length === 0 && currentAnswer.length === 0;
1693
- // W5.4 failover: a typed-limit hit rebuilds the spec on a NEW vendor
1694
- // session under the next profile with provenance (vendor_limit_rejected).
1634
+ const deliverableEmpty = currentDiff.trim().length === 0 && answer.text().length === 0;
1635
+ // W5.4 + A2 failover: a typed-limit hit OR a structural pre-progress
1636
+ // death rebuilds the spec on a NEW session under the next profile.
1695
1637
  if (harnessErrored && runInput && !signal?.aborted) {
1696
- const rotationPolicy = this.profilePolicy(contract.repo.root, adapter.id);
1697
- const readyProfileIds = sawTypedLimit && deliverableEmpty && rotationPolicy.limit_action === "rotate"
1698
- ? await this.readyProfileIdsForRotation(runInput, adapter.id, spec.credential_profile ?? null, triedProfiles)
1699
- : new Set();
1700
- const rotated = rotateSpecOnTypedLimit({
1638
+ const rotated = await rotateSpecOnTypedLimit({
1701
1639
  spec,
1702
1640
  harnessId: adapter.id,
1703
1641
  attemptId,
1704
- policy: rotationPolicy,
1642
+ policy: this.credentials.profilePolicy(contract.repo.root, adapter.id),
1705
1643
  registry: this.config(contract.repo.root)?.global.credential_profiles ?? [],
1706
1644
  snapshots: this.deps.quotaSnapshots?.() ?? [],
1707
- readyProfileIds,
1645
+ probeReadyProfiles: () => this.credentials.readyProfileIdsForRotation(runInput, adapter.id, spec.credential_profile ?? null, triedProfiles, spec.model_hint ?? null),
1646
+ ...this.credentials.rotationObservations(adapter, spec, newTransients),
1708
1647
  triedProfiles,
1648
+ markers: telemetry.outputMarkers,
1709
1649
  sawTypedLimit,
1710
- deliverableEmpty,
1650
+ sawRetryable,
1651
+ attemptErrored: harnessErrored,
1652
+ // Rotation evidence reads the POLICY-accepted try output: refusal
1653
+ // prose arriving as mid-stream MESSAGE events (claude org-disabled)
1654
+ // is no deliverable; the transient gate keeps RAW deliverableEmpty.
1655
+ deliverableEmpty: currentDiff.trim().length === 0 &&
1656
+ acceptedTryOutput(answer, harnessErrored).length === 0,
1657
+ workspaceDiffNonEmpty: currentDiff.trim().length > 0,
1711
1658
  lastLimit: telemetry.rateLimits.at(-1) ?? null,
1712
1659
  emit: (type, payload) => log?.emit(type, payload),
1713
1660
  newSessionId: () => newId("ses"),
1714
1661
  defaultRouteWasVendorNative: routed.authRouteEstimate === "local_session",
1662
+ // D-U6: an explicit pin never rotates; pool-selected rows do.
1663
+ pinned: runInput.credentialProfileId != null,
1715
1664
  });
1665
+ // A5 ordering: an exhausted pool terminalizes TYPED here, BEFORE the
1666
+ // transient gate below burns same-profile retries (limits classify
1667
+ // as retryable) on the already-refused subject.
1668
+ if (rotated && "poolExhausted" in rotated) {
1669
+ poolExhausted = rotated.poolExhausted;
1670
+ errors.push(safeErrorMessage(poolExhausted));
1671
+ break;
1672
+ }
1716
1673
  if (rotated) {
1717
- spec = rotated;
1674
+ // INV-137: the rotated row runs in ITS OWN lane home (no-op for
1675
+ // isolated envelopes and native-env in-place turns).
1676
+ spec = rotatedSpecInLaneHome(spec, rotated, (id) => this.laneHomeEnvFor(runInput, adapter.id, id), runInput.credentialProfileId ?? null);
1718
1677
  applied = appliedNow();
1719
1678
  errors.length = 0;
1720
1679
  harnessErrored = false;
@@ -1727,21 +1686,7 @@ export class Orchestrator {
1727
1686
  nativeTry >= retryPolicy.maxRetries ||
1728
1687
  signal?.aborted)
1729
1688
  break;
1730
- const nextTry = nativeTry + 1;
1731
- const delayMs = transientRetryDelayMs(transient?.retryDelayMs ?? null, retryPolicy, nativeTry);
1732
- log?.emit("route.transient.detected", {
1733
- harness_id: adapter.id,
1734
- attempt_id: attemptId,
1735
- kind: transient?.kind ?? "unknown",
1736
- category: transient?.category ?? "unknown_harness_error",
1737
- native_try: nativeTry + 1,
1738
- });
1739
- log?.emit("route.transient.retry_scheduled", {
1740
- harness_id: adapter.id,
1741
- attempt_id: attemptId,
1742
- retry: nextTry,
1743
- delay_ms: delayMs,
1744
- });
1689
+ const delayMs = emitTransientRetryPlan((t, p) => log?.emit(t, p), adapter.id, attemptId, transient, nativeTry, retryPolicy);
1745
1690
  errors.length = 0;
1746
1691
  harnessErrored = false;
1747
1692
  await sleep(delayMs);
@@ -1750,22 +1695,18 @@ export class Orchestrator {
1750
1695
  finally {
1751
1696
  signal?.removeEventListener("abort", onAbort);
1752
1697
  }
1753
- if (harnessErrored && telemetry.transientFailures.length > 0) {
1754
- log?.emit("route.transient.exhausted", {
1755
- harness_id: adapter.id,
1756
- attempt_id: attemptId,
1757
- category: telemetry.transientFailures.at(-1)?.category ?? "unknown_harness_error",
1758
- retries: retryPolicy.maxRetries,
1759
- });
1698
+ // A pool-exhausted terminal is rotation's verdict, not the transient
1699
+ // machinery's — no `route.transient.exhausted` rides along with it.
1700
+ if (harnessErrored && !poolExhausted) {
1701
+ emitTransientExhausted((t, p) => log?.emit(t, p), adapter.id, attemptId, telemetry, retryPolicy.maxRetries);
1760
1702
  }
1761
1703
  const attemptStreamEndedMs = Date.now();
1762
1704
  if (webUnsatisfied(telemetry)) {
1763
1705
  errors.push(webEvidenceFailure(telemetry.web));
1764
1706
  }
1765
- // D-16: remove the WorkReport transport so answer.md persists only the deliverable.
1766
- const unwrapped = unwrapWorkReportEnvelope(answer.machineText() ?? "", workReportMode, {
1767
- sideToolReport: telemetry.sideToolWorkReport ?? undefined,
1768
- });
1707
+ // D-16: answer.md persists only the deliverable. A3: an errored attempt
1708
+ // with no typed final contributes no answer material (acceptedTryOutput).
1709
+ const unwrapped = unwrapWorkReportEnvelope(acceptedTryOutput(answer, harnessErrored), workReportMode, { sideToolReport: telemetry.sideToolWorkReport ?? undefined });
1769
1710
  const redacted = redactSecrets(unwrapped.deliverable);
1770
1711
  const candidateAnswer = redacted.trim().length > 0 ? redacted : undefined;
1771
1712
  const { diff, refusal: secretDiffRefusal } = await secretDiff.quarantineCandidateWorkspace(wsm, envelope, inPlaceEnvelope, candidateAnswer);
@@ -1892,6 +1833,8 @@ export class Orchestrator {
1892
1833
  errors: errors.slice(0, 8),
1893
1834
  telemetry,
1894
1835
  ...(secretDiffRefusal ? { secretDiffRefusal } : {}),
1836
+ // A5: the typed refusal survives NORMAL attempt finalization (no throw).
1837
+ ...(poolExhausted ? { declaredFailure: declaredFailure(poolExhausted) } : {}),
1895
1838
  outcomeClass: finalized.outcomeClass,
1896
1839
  applied,
1897
1840
  };
@@ -2313,7 +2256,7 @@ export class Orchestrator {
2313
2256
  error: message,
2314
2257
  phase: infraPhase,
2315
2258
  });
2316
- store.writeYaml(join(paths.attemptsDir, slot.attemptId, "attempt.yaml"), AC.attemptFailureRecord(slot.attemptId, adapter.id, failureCost, infraPhase, message, appliedAttemptFacts(harnessHome, slot.routed.adapterAccess, this.effectiveProfileId(input, adapter.id))));
2259
+ store.writeYaml(join(paths.attemptsDir, slot.attemptId, "attempt.yaml"), AC.attemptFailureRecord(slot.attemptId, adapter.id, failureCost, infraPhase, message, appliedAttemptFacts(harnessHome, slot.routed.adapterAccess, this.credentials.effectiveProfileId(input, adapter.id))));
2317
2260
  runsBySlot[slotIdx] = {
2318
2261
  attemptId: slot.attemptId,
2319
2262
  harnessId: adapter.id,
@@ -2334,7 +2277,7 @@ export class Orchestrator {
2334
2277
  // alive past this catch; `message` alone would force the terminal to
2335
2278
  // read prose back out.
2336
2279
  ...(declared.code ? { declaredFailure: declared } : {}),
2337
- applied: appliedAttemptFacts(harnessHome, slot.routed.adapterAccess, this.effectiveProfileId(input, adapter.id)),
2280
+ applied: appliedAttemptFacts(harnessHome, slot.routed.adapterAccess, this.credentials.effectiveProfileId(input, adapter.id)),
2338
2281
  };
2339
2282
  }
2340
2283
  finally {
@@ -3466,6 +3409,8 @@ export class Orchestrator {
3466
3409
  // carry their route-specific settlement from runCandidateInEnvelope.
3467
3410
  const failureCost = AC.attemptFailureCost(err, "attempt-error");
3468
3411
  const message = safeErrorMessage(err);
3412
+ // A5: keep a TYPED refusal alive past this catch (race-lane parity).
3413
+ const declared = declaredFailure(err);
3469
3414
  ledger.settle(lease.lease?.lease_id ?? "", failureCost.settlement);
3470
3415
  log.emit("harness.completed", {
3471
3416
  harness_id: adapter.id,
@@ -3473,9 +3418,9 @@ export class Orchestrator {
3473
3418
  status: "failed",
3474
3419
  error: message,
3475
3420
  });
3476
- store.writeYaml(join(paths.attemptsDir, attemptId, "attempt.yaml"), AC.attemptFailureRecord(attemptId, adapter.id, failureCost, "harness", message, appliedAttemptFacts(harnessHome, routed.adapterAccess, this.effectiveProfileId(input, adapter.id))));
3421
+ store.writeYaml(join(paths.attemptsDir, attemptId, "attempt.yaml"), AC.attemptFailureRecord(attemptId, adapter.id, failureCost, "harness", message, appliedAttemptFacts(harnessHome, routed.adapterAccess, this.credentials.effectiveProfileId(input, adapter.id))));
3477
3422
  run = {
3478
- applied: appliedAttemptFacts(harnessHome, routed.adapterAccess, this.effectiveProfileId(input, adapter.id)),
3423
+ applied: appliedAttemptFacts(harnessHome, routed.adapterAccess, this.credentials.effectiveProfileId(input, adapter.id)),
3479
3424
  attemptId,
3480
3425
  harnessId: adapter.id,
3481
3426
  label: `Attempt ${attempt}`,
@@ -3485,6 +3430,7 @@ export class Orchestrator {
3485
3430
  errored: true,
3486
3431
  costEstimated: failureCost.estimated,
3487
3432
  errors: [message],
3433
+ ...(declared.code ? { declaredFailure: declared } : {}),
3488
3434
  telemetry: createAttemptTelemetry(knobs.webPolicy, contract.external_context.web_required, effectiveWeb, [routed.browserRequirement, routed.denyRequirement, routed.delegationRequirement], knobs.model),
3489
3435
  };
3490
3436
  }
@@ -3884,6 +3830,11 @@ export class Orchestrator {
3884
3830
  // failure record + fires run.blocked even though the lifecycle succeeded.
3885
3831
  const convIsFailureTerminal = facts.lifecycle !== "succeeded" || convNeedsDecision;
3886
3832
  if (convIsFailureTerminal) {
3833
+ // A5 last-result lift: the LAST attempt's typed mid-attempt refusal
3834
+ // survives onto the terminal; budget/cancel/needs-decision outrank it.
3835
+ const convDeclared = !convNeedsDecision && facts.lifecycle === "failed" && !isBudgetTerminal(facts.reason)
3836
+ ? lastRun?.declaredFailure
3837
+ : undefined;
3887
3838
  writeFailure(store, paths, {
3888
3839
  phase: convNeedsDecision ? "review" : "convergence",
3889
3840
  category: isBudgetTerminal(facts.reason)
@@ -3892,7 +3843,9 @@ export class Orchestrator {
3892
3843
  ? "cancelled"
3893
3844
  : convNeedsDecision
3894
3845
  ? "policy"
3895
- : "internal",
3846
+ : (convDeclared?.category ?? "internal"),
3847
+ code: convDeclared?.code ?? null,
3848
+ resetsAt: convDeclared?.resetsAt ?? null,
3896
3849
  safeMessage: convNeedsDecision
3897
3850
  ? `review escalated to a human decision after ${attempt} attempt(s)`
3898
3851
  : facts.reason === "stuck_no_progress"
@@ -4030,7 +3983,9 @@ export class Orchestrator {
4030
3983
  ...sessionFields,
4031
3984
  ...this.harnessSpecKnobs(contract, knobs, args.intent),
4032
3985
  env_inheritance: envInheritance(this.config(input.repoRoot)),
4033
- env: (args.laneRun ? this.laneHomeEnvFor(input, adapter.id) : null) ?? args.fallbackHome,
3986
+ env: (args.laneRun
3987
+ ? this.laneHomeEnvFor(input, adapter.id, sessionFields.credential_profile?.profile_id ?? input.credentialProfileId ?? null)
3988
+ : null) ?? args.fallbackHome,
4034
3989
  });
4035
3990
  const plannerAbort = new AbortController();
4036
3991
  spec.extra["abortSignal"] = input.signal
@@ -4348,7 +4303,7 @@ export class Orchestrator {
4348
4303
  }
4349
4304
  routeBillingKnowledge(input, harnessId) {
4350
4305
  // A selected profile's credential_kind decides billing (round-18 #2).
4351
- const profileRoute = this.profileAuthRoute(input, harnessId);
4306
+ const profileRoute = this.credentials.profileAuthRoute(input, harnessId);
4352
4307
  if (profileRoute)
4353
4308
  return profileRoute === "api_key" ? "metered" : "unknown";
4354
4309
  // Deps-closure site: no selected route exists yet, so the RESOLVED
@@ -4640,7 +4595,9 @@ export class Orchestrator {
4640
4595
  // A thread lane turn spawns in its DURABLE per-lane home so the native
4641
4596
  // session it records is reachable for resume next turn; everything else
4642
4597
  // uses the disposable route-context home.
4643
- env: (laneRun ? this.laneHomeEnvFor(input, adapter.id) : null) ?? roHome.env,
4598
+ env: (laneRun
4599
+ ? this.laneHomeEnvFor(input, adapter.id, sessionFields.credential_profile?.profile_id ?? input.credentialProfileId ?? null)
4600
+ : null) ?? roHome.env,
4644
4601
  });
4645
4602
  const reportAbort = new AbortController();
4646
4603
  spec.extra["abortSignal"] = input.signal
@@ -4687,6 +4644,7 @@ export class Orchestrator {
4687
4644
  harnessErrored: true,
4688
4645
  webRequiredUnsatisfied: false,
4689
4646
  });
4647
+ const preDeclared = declaredFailure(preparation.error);
4690
4648
  attempts.push({
4691
4649
  attemptId,
4692
4650
  harnessId: adapter.id,
@@ -4694,6 +4652,7 @@ export class Orchestrator {
4694
4652
  report: "",
4695
4653
  error: message,
4696
4654
  telemetry,
4655
+ ...(preDeclared.code ? { declaredFailure: preDeclared } : {}), // typed pre-spawn refusal
4697
4656
  });
4698
4657
  attemptTelemetries.push({ attemptId, harnessId: adapter.id, telemetry });
4699
4658
  if (opts.deepScan) {
@@ -4701,7 +4660,8 @@ export class Orchestrator {
4701
4660
  }
4702
4661
  return { status: "launched" };
4703
4662
  }
4704
- const { knobs, spec: preparedSpec, reportAbort, reportInteraction, readonlyWorkMode, attemptEventsPath, answer, telemetry, } = preparation.value;
4663
+ const { knobs, spec: preparedSpec, reportAbort, reportInteraction, readonlyWorkMode, attemptEventsPath, telemetry, } = preparation.value;
4664
+ let { answer } = preparation.value;
4705
4665
  let spec = preparedSpec;
4706
4666
  const retryPolicy = transientRetryPolicy(this.config(input.repoRoot));
4707
4667
  let activeSessionId = spec.session_id;
@@ -4717,9 +4677,16 @@ export class Orchestrator {
4717
4677
  let cost = 0;
4718
4678
  let costEstimated = false;
4719
4679
  let harnessError = null;
4680
+ let poolExhausted = null; // A5: typed pool-exhausted refusal
4720
4681
  try {
4721
4682
  const triedProfiles = new Set(); // W5.4 failover: each profile at most once
4722
4683
  for (let nativeTry = 0; !input.signal?.aborted; nativeTry += 1) {
4684
+ // A3 per-try isolation (candidate-lane parity): neither a failed try's
4685
+ // output nor its progress markers leak into the next try's evidence.
4686
+ if (nativeTry > 0) {
4687
+ answer = new AnswerAssembly();
4688
+ telemetry.outputMarkers = newAttemptOutputMarkers();
4689
+ }
4723
4690
  const runSpec = nativeTry === 0
4724
4691
  ? spec
4725
4692
  : HarnessRunSpec.parse({
@@ -4767,29 +4734,14 @@ export class Orchestrator {
4767
4734
  log.emit("harness.event", harnessEventPayload(adapter.id, attemptId, safeEv));
4768
4735
  appendLine(attemptEventsPath, JSON.stringify(safeEv));
4769
4736
  observeAttemptTelemetry(telemetry, safeEv);
4770
- if (safeEv.plan_progress) {
4771
- log.emit("plan.progress", {
4772
- attempt_id: attemptId,
4773
- harness_id: adapter.id,
4774
- items: safeEv.plan_progress.items,
4775
- });
4776
- }
4737
+ emitPlanProgress((t, p) => log.emit(t, p), adapter.id, attemptId, safeEv);
4777
4738
  // read-only routes burn quota too (the orchestrate PLANNER is
4778
4739
  // the loudest) — same single owner as the agent loop.
4779
4740
  observeBudgetSignals(ledger, log, adapter.id, attemptId, safeEv, budgetSignalState);
4780
4741
  this.deps.quotaEventSink?.(adapter.id, safeEv);
4781
- if (safeEv.type === "usage" && safeEv.usage?.cost_usd) {
4782
- cost += safeEv.usage.cost_usd;
4783
- if (safeEv.usage.estimated)
4784
- costEstimated = true;
4785
- log.emit("budget.observation", {
4786
- harness_id: adapter.id,
4787
- attempt_id: attemptId,
4788
- kind: "spend",
4789
- usd: safeEv.usage.cost_usd,
4790
- estimated: safeEv.usage.estimated === true,
4791
- });
4792
- }
4742
+ const spend = observeReadonlySpend(safeEv, (t, p) => log.emit(t, p), adapter.id, attemptId);
4743
+ cost += spend.costUsd;
4744
+ costEstimated ||= spend.estimated;
4793
4745
  // A TYPED final message wins verbatim over joined narration.
4794
4746
  answer.observe(safeEv);
4795
4747
  if (safeEv.type === "error")
@@ -4800,8 +4752,7 @@ export class Orchestrator {
4800
4752
  }
4801
4753
  catch (err) {
4802
4754
  harnessError = safeErrorMessage(err);
4803
- // #31: classify the throw so the retry gate and required-actions read
4804
- // a typed category (watchdog timeout vs process crash).
4755
+ // #31: classify the throw (watchdog timeout vs process crash) as typed.
4805
4756
  telemetry.transientFailures.push(classifyAdapterThrow({ errorName: err instanceof Error ? err.name : null }));
4806
4757
  }
4807
4758
  const newTransients = telemetry.transientFailures.slice(transientStart);
@@ -4809,31 +4760,48 @@ export class Orchestrator {
4809
4760
  const sawRetryable = newTransients.some((f) => f.retryable);
4810
4761
  const sawTypedLimit = telemetry.rateLimits.length > rateLimitStart;
4811
4762
  const reportSoFar = answer.text();
4812
- // W5.4 reactive failover, READ-ONLY lane (same contract as the
4813
- // candidate lane; typed limits only, never plain transients).
4763
+ // W5.4 + A2 reactive failover, READ-ONLY lane (same contract as the
4764
+ // candidate lane: typed limit or structural pre-progress death).
4814
4765
  if (harnessError && !input.signal?.aborted) {
4815
- const rotationPolicy = this.profilePolicy(input.repoRoot, adapter.id);
4816
- const readyProfileIds = sawTypedLimit && reportSoFar.length === 0 && rotationPolicy.limit_action === "rotate"
4817
- ? await this.readyProfileIdsForRotation(input, adapter.id, spec.credential_profile ?? null, triedProfiles)
4818
- : new Set();
4819
- const rotated = rotateSpecOnTypedLimit({
4766
+ const rotated = await rotateSpecOnTypedLimit({
4820
4767
  spec,
4821
4768
  harnessId: adapter.id,
4822
4769
  attemptId,
4823
- policy: rotationPolicy,
4770
+ policy: this.credentials.profilePolicy(input.repoRoot, adapter.id),
4824
4771
  registry: this.config(input.repoRoot)?.global.credential_profiles ?? [],
4825
4772
  snapshots: this.deps.quotaSnapshots?.() ?? [],
4826
- readyProfileIds,
4773
+ probeReadyProfiles: () => this.credentials.readyProfileIdsForRotation(input, adapter.id, spec.credential_profile ?? null, triedProfiles, spec.model_hint ?? null),
4774
+ ...this.credentials.rotationObservations(adapter, spec, newTransients),
4827
4775
  triedProfiles,
4776
+ markers: telemetry.outputMarkers,
4828
4777
  sawTypedLimit,
4829
- deliverableEmpty: reportSoFar.length === 0,
4778
+ sawRetryable,
4779
+ attemptErrored: harnessError !== null,
4780
+ // Same policy owner as the candidate lane: an errored try's
4781
+ // narration is not a report; the transient gate keeps reportSoFar.
4782
+ deliverableEmpty: acceptedTryOutput(answer, harnessError !== null).length === 0,
4830
4783
  lastLimit: telemetry.rateLimits.at(-1) ?? null,
4831
4784
  emit: (type, payload) => log.emit(type, payload),
4832
4785
  newSessionId: () => newId("ses"),
4833
4786
  defaultRouteWasVendorNative: routed.authRouteEstimate === "local_session",
4787
+ // D-U6: an explicit pin never rotates; pool-selected rows do.
4788
+ pinned: input.credentialProfileId != null,
4834
4789
  });
4790
+ // A5 ordering: same as the candidate lane — terminalize typed
4791
+ // before the transient gate burns same-profile retries. The pool
4792
+ // refusal APPENDS to the original harness error (candidate-lane
4793
+ // parity: errors.push) instead of erasing the true failure.
4794
+ if (rotated && "poolExhausted" in rotated) {
4795
+ poolExhausted = rotated.poolExhausted;
4796
+ harnessError = harnessError
4797
+ ? `${harnessError}; ${safeErrorMessage(poolExhausted)}`
4798
+ : safeErrorMessage(poolExhausted);
4799
+ break;
4800
+ }
4835
4801
  if (rotated) {
4836
- spec = rotated;
4802
+ // INV-137: the rotated row's session must land in ITS OWN lane
4803
+ // home — never the previous row's lane store.
4804
+ spec = rotatedSpecInLaneHome(spec, rotated, (id) => this.laneHomeEnvFor(input, adapter.id, id), input.credentialProfileId ?? null);
4837
4805
  harnessError = null;
4838
4806
  continue;
4839
4807
  }
@@ -4844,21 +4812,7 @@ export class Orchestrator {
4844
4812
  nativeTry >= retryPolicy.maxRetries ||
4845
4813
  input.signal?.aborted)
4846
4814
  break;
4847
- const nextTry = nativeTry + 1;
4848
- const delayMs = transientRetryDelayMs(transient?.retryDelayMs ?? null, retryPolicy, nativeTry);
4849
- log.emit("route.transient.detected", {
4850
- harness_id: adapter.id,
4851
- attempt_id: attemptId,
4852
- kind: transient?.kind ?? "unknown",
4853
- category: transient?.category ?? "unknown_harness_error",
4854
- native_try: nativeTry + 1,
4855
- });
4856
- log.emit("route.transient.retry_scheduled", {
4857
- harness_id: adapter.id,
4858
- attempt_id: attemptId,
4859
- retry: nextTry,
4860
- delay_ms: delayMs,
4861
- });
4815
+ const delayMs = emitTransientRetryPlan((t, p) => log.emit(t, p), adapter.id, attemptId, transient, nativeTry, retryPolicy);
4862
4816
  harnessError = null;
4863
4817
  await sleep(delayMs);
4864
4818
  }
@@ -4877,19 +4831,13 @@ export class Orchestrator {
4877
4831
  preStreamFailureSource: "readonly-pre-stream",
4878
4832
  });
4879
4833
  }
4880
- if (harnessError && telemetry.transientFailures.length > 0) {
4881
- log.emit("route.transient.exhausted", {
4882
- harness_id: adapter.id,
4883
- attempt_id: attemptId,
4884
- category: telemetry.transientFailures.at(-1)?.category ?? "unknown_harness_error",
4885
- retries: retryPolicy.maxRetries,
4886
- });
4834
+ if (harnessError && !poolExhausted) {
4835
+ emitTransientExhausted((t, p) => log.emit(t, p), adapter.id, attemptId, telemetry, retryPolicy.maxRetries);
4887
4836
  }
4888
4837
  attemptTelemetries.push({ attemptId, harnessId: adapter.id, telemetry });
4889
- // D-16: remove the WorkReport transport; the deliverable is the report.
4890
- const roUnwrapped = unwrapWorkReportEnvelope(answer.machineText() ?? "", readonlyWorkMode, {
4891
- sideToolReport: telemetry.sideToolWorkReport ?? undefined,
4892
- });
4838
+ // D-16: the deliverable is the report. A3: an errored attempt with no
4839
+ // typed final contributes no report material (acceptedTryOutput).
4840
+ const roUnwrapped = unwrapWorkReportEnvelope(acceptedTryOutput(answer, harnessError !== null), readonlyWorkMode, { sideToolReport: telemetry.sideToolWorkReport ?? undefined });
4893
4841
  // Trim symmetrically with the plan path: a whitespace-only answer is not
4894
4842
  // a delivered report (the final-artifact wrapper heading would otherwise
4895
4843
  // make it read as present content by construction).
@@ -4944,6 +4892,7 @@ export class Orchestrator {
4944
4892
  report,
4945
4893
  error: harnessError,
4946
4894
  telemetry,
4895
+ ...(poolExhausted ? { declaredFailure: declaredFailure(poolExhausted) } : {}),
4947
4896
  });
4948
4897
  if (opts.deepScan) {
4949
4898
  store.writeText(join(paths.findingsDir, `${attemptId}-error.md`), `# Explorer ${attemptId} failed\n\n${harnessError}\n`);
@@ -5169,14 +5118,19 @@ export class Orchestrator {
5169
5118
  // #31: classify the harness cause across the read-only attempts so auth
5170
5119
  // guidance appears only on a real auth failure.
5171
5120
  const roCategory = dominantHarnessFailureCategory(attemptTelemetries.flatMap((a) => a.telemetry.transientFailures));
5121
+ // A5: the terminal already speaks with the LAST attempt's message; its
5122
+ // typed refusal rides along machine-readably instead of reducing to it.
5123
+ const roDeclared = webBlocked ? undefined : last?.declaredFailure;
5172
5124
  writeFailure(store, paths, {
5173
5125
  phase: "harness",
5174
- category: webBlocked ? "policy" : "harness_error",
5126
+ category: webBlocked ? "policy" : (roDeclared?.category ?? "harness_error"),
5127
+ code: roDeclared?.code ?? null,
5175
5128
  harnessId: last?.harnessId,
5176
5129
  attemptId: last?.attemptId,
5177
5130
  safeMessage: singleError,
5178
5131
  eventRefs: roEventRefs,
5179
5132
  runDir: paths.root,
5133
+ resetsAt: roDeclared?.resetsAt ?? null,
5180
5134
  nextActions: harnessFailureNextActions(roCategory),
5181
5135
  });
5182
5136
  }