@claudexor/orchestrator 3.5.0 → 3.8.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 (71) 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 +88 -0
  6. package/dist/account-resolution.d.ts.map +1 -0
  7. package/dist/account-resolution.js +327 -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/deepScanReducer.d.ts.map +1 -1
  38. package/dist/deepScanReducer.js +2 -1
  39. package/dist/deepScanReducer.js.map +1 -1
  40. package/dist/index.d.ts +2 -1
  41. package/dist/index.d.ts.map +1 -1
  42. package/dist/index.js +2 -1
  43. package/dist/index.js.map +1 -1
  44. package/dist/laneStreamEvents.d.ts +54 -0
  45. package/dist/laneStreamEvents.d.ts.map +1 -0
  46. package/dist/laneStreamEvents.js +81 -0
  47. package/dist/laneStreamEvents.js.map +1 -0
  48. package/dist/modelGovernance.d.ts +35 -4
  49. package/dist/modelGovernance.d.ts.map +1 -1
  50. package/dist/modelGovernance.js +71 -27
  51. package/dist/modelGovernance.js.map +1 -1
  52. package/dist/orchestrator-credentials.d.ts +93 -0
  53. package/dist/orchestrator-credentials.d.ts.map +1 -0
  54. package/dist/orchestrator-credentials.js +162 -0
  55. package/dist/orchestrator-credentials.js.map +1 -0
  56. package/dist/orchestrator.d.ts +17 -30
  57. package/dist/orchestrator.d.ts.map +1 -1
  58. package/dist/orchestrator.js +293 -344
  59. package/dist/orchestrator.js.map +1 -1
  60. package/dist/plannerAttempt.d.ts.map +1 -1
  61. package/dist/plannerAttempt.js +2 -1
  62. package/dist/plannerAttempt.js.map +1 -1
  63. package/dist/rotation-predicate.d.ts +71 -0
  64. package/dist/rotation-predicate.d.ts.map +1 -0
  65. package/dist/rotation-predicate.js +29 -0
  66. package/dist/rotation-predicate.js.map +1 -0
  67. package/dist/routeContext.d.ts +4 -1
  68. package/dist/routeContext.d.ts.map +1 -1
  69. package/dist/routeContext.js +8 -3
  70. package/dist/routeContext.js.map +1 -1
  71. 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,8 +15,8 @@ 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";
17
- import { assertRouteModelsAllowed } from "./modelGovernance.js";
18
+ import { acceptedTryOutput, AnswerAssembly, CLAUDEXOR_BROWSER_ARTIFACT_SUBDIR, countsAsAgentProgress, HarnessUnavailableError, summarizeDiffPaths as diffStats, withInactivityWatchdog, } from "@claudexor/core";
19
+ import { assertRouteModelsAllowed, runModelGovernedRoute } from "./modelGovernance.js";
18
20
  import { authModeForCredentialRoute, authModeForPreference } from "./auth-route-classification.js";
19
21
  import { governRouteEffort } from "./effortGovernance.js";
20
22
  import { isFullAccess, RequestRequirementsResolver } from "./requestRequirements.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`);
@@ -883,7 +850,7 @@ export class Orchestrator {
883
850
  }
884
851
  }
885
852
  // Strict pre-run model gate (INV-104) — see modelGovernance.ts.
886
- await assertRouteModelsAllowed(out, input.models, this.execRootOf(input));
853
+ await assertRouteModelsAllowed(out, input.models, this.execRootOf(input), (id) => routeContext ? (routeContext.envForHarness?.(id) ?? routeContext.env) : undefined);
887
854
  return out;
888
855
  }
889
856
  /**
@@ -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
@@ -1552,7 +1523,7 @@ export class Orchestrator {
1552
1523
  const rateLimitStart = telemetry.rateLimits.length;
1553
1524
  let rawPatch = null;
1554
1525
  try {
1555
- const watched = withInactivityWatchdog(adapter.run(runSpec), {
1526
+ const watched = withInactivityWatchdog(runModelGovernedRoute(routed, runSpec), {
1556
1527
  timeoutMs: inactivityMs,
1557
1528
  countsAsProgress: countsAsAgentProgress,
1558
1529
  onTimeout: () => {
@@ -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
@@ -4077,15 +4032,14 @@ export class Orchestrator {
4077
4032
  store.writeYaml(join(paths.contextDir, "task.yaml"), contract);
4078
4033
  log.emit("task.contract.created", { task_contract_hash: hashJson(contract) });
4079
4034
  // W3.3: ONE resolved read-only context — the routing point-probe and every
4080
- // planner spawn consume the SAME scoped env (see routeContext.ts). The
4081
- // probe home stays a disposable throwaway even for a thread lane turn (auth
4082
- // truth is home-independent); only the planner spawn swaps in the durable
4083
- // per-lane home below so its recorded native session survives.
4084
- const roHome = resolveReadOnlyRouteContext(this.execRootOf(input));
4035
+ // planner spawn consume the SAME scoped env (see routeContext.ts).
4085
4036
  // A thread PLAN turn is a chat turn (INV-034): plan candidates are distinct
4086
- // harnesses run sequentially, so each records its own lane's native session
4087
- // and the next lane turn resumes it via `sessionSpecFields.resume_session_id`.
4037
+ // harnesses run sequentially. Doctor, model inventory, and spawn share each
4038
+ // lane's durable HOME; the disposable context remains the non-thread fallback.
4088
4039
  const laneRun = Boolean(input.threadId);
4040
+ const roHome = resolveReadOnlyRouteContext(this.execRootOf(input), laneRun
4041
+ ? (id) => this.laneHomeEnvFor(input, id, input.credentialProfileId ?? null)
4042
+ : undefined);
4089
4043
  let adapters;
4090
4044
  try {
4091
4045
  adapters = await this.resolveCandidateAdapters({ ...input, n: undefined }, "plan", ledger, log, roHome, runId);
@@ -4348,7 +4302,7 @@ export class Orchestrator {
4348
4302
  }
4349
4303
  routeBillingKnowledge(input, harnessId) {
4350
4304
  // A selected profile's credential_kind decides billing (round-18 #2).
4351
- const profileRoute = this.profileAuthRoute(input, harnessId);
4305
+ const profileRoute = this.credentials.profileAuthRoute(input, harnessId);
4352
4306
  if (profileRoute)
4353
4307
  return profileRoute === "api_key" ? "metered" : "unknown";
4354
4308
  // Deps-closure site: no selected route exists yet, so the RESOLVED
@@ -4446,17 +4400,13 @@ export class Orchestrator {
4446
4400
  : Math.min(Math.max(input.n ?? 2, 1), 3);
4447
4401
  // W3.3: ONE resolved read-only context — the routing point-probe and every
4448
4402
  // read-only attempt spawn consume the SAME scoped env (see routeContext.ts).
4449
- // The point-probe home is a disposable throwaway even for a thread lane
4450
- // turn: readiness auth truth is home-INDEPENDENT (credentials come from the
4451
- // profile/keychain/default store, never the scoped home), so the probe and
4452
- // the run share the same auth source; only the ACTUAL spawn swaps in the
4453
- // durable per-lane home below so the recorded native session survives.
4454
- const roHome = resolveReadOnlyRouteContext(this.execRootOf(input));
4455
4403
  // A thread ASK turn is a chat turn: its native session is recorded per lane
4456
- // and the next lane turn resumes it (INV-034). Deep-scan (multi-scout
4457
- // research) and orchestrate (tool-belt planner, not the user's chat) are
4458
- // NOT lane chat turns — they keep the disposable home and record nothing.
4404
+ // and the next lane turn resumes it (INV-034). Its doctor/model/spawn path
4405
+ // shares that lane HOME; deep-scan and one-shot asks keep the disposable one.
4459
4406
  const laneRun = Boolean(input.threadId) && opts.mode === "ask" && !opts.deepScan;
4407
+ const roHome = resolveReadOnlyRouteContext(this.execRootOf(input), laneRun
4408
+ ? (id) => this.laneHomeEnvFor(input, id, input.credentialProfileId ?? null)
4409
+ : undefined);
4460
4410
  let adapters;
4461
4411
  try {
4462
4412
  adapters = await this.resolveCandidateAdapters({ ...input, prompt, n: width }, opts.intent, ledger, log, roHome, runId,
@@ -4640,7 +4590,9 @@ export class Orchestrator {
4640
4590
  // A thread lane turn spawns in its DURABLE per-lane home so the native
4641
4591
  // session it records is reachable for resume next turn; everything else
4642
4592
  // uses the disposable route-context home.
4643
- env: (laneRun ? this.laneHomeEnvFor(input, adapter.id) : null) ?? roHome.env,
4593
+ env: (laneRun
4594
+ ? this.laneHomeEnvFor(input, adapter.id, sessionFields.credential_profile?.profile_id ?? input.credentialProfileId ?? null)
4595
+ : null) ?? roHome.env,
4644
4596
  });
4645
4597
  const reportAbort = new AbortController();
4646
4598
  spec.extra["abortSignal"] = input.signal
@@ -4687,6 +4639,7 @@ export class Orchestrator {
4687
4639
  harnessErrored: true,
4688
4640
  webRequiredUnsatisfied: false,
4689
4641
  });
4642
+ const preDeclared = declaredFailure(preparation.error);
4690
4643
  attempts.push({
4691
4644
  attemptId,
4692
4645
  harnessId: adapter.id,
@@ -4694,6 +4647,7 @@ export class Orchestrator {
4694
4647
  report: "",
4695
4648
  error: message,
4696
4649
  telemetry,
4650
+ ...(preDeclared.code ? { declaredFailure: preDeclared } : {}), // typed pre-spawn refusal
4697
4651
  });
4698
4652
  attemptTelemetries.push({ attemptId, harnessId: adapter.id, telemetry });
4699
4653
  if (opts.deepScan) {
@@ -4701,7 +4655,8 @@ export class Orchestrator {
4701
4655
  }
4702
4656
  return { status: "launched" };
4703
4657
  }
4704
- const { knobs, spec: preparedSpec, reportAbort, reportInteraction, readonlyWorkMode, attemptEventsPath, answer, telemetry, } = preparation.value;
4658
+ const { knobs, spec: preparedSpec, reportAbort, reportInteraction, readonlyWorkMode, attemptEventsPath, telemetry, } = preparation.value;
4659
+ let { answer } = preparation.value;
4705
4660
  let spec = preparedSpec;
4706
4661
  const retryPolicy = transientRetryPolicy(this.config(input.repoRoot));
4707
4662
  let activeSessionId = spec.session_id;
@@ -4717,9 +4672,16 @@ export class Orchestrator {
4717
4672
  let cost = 0;
4718
4673
  let costEstimated = false;
4719
4674
  let harnessError = null;
4675
+ let poolExhausted = null; // A5: typed pool-exhausted refusal
4720
4676
  try {
4721
4677
  const triedProfiles = new Set(); // W5.4 failover: each profile at most once
4722
4678
  for (let nativeTry = 0; !input.signal?.aborted; nativeTry += 1) {
4679
+ // A3 per-try isolation (candidate-lane parity): neither a failed try's
4680
+ // output nor its progress markers leak into the next try's evidence.
4681
+ if (nativeTry > 0) {
4682
+ answer = new AnswerAssembly();
4683
+ telemetry.outputMarkers = newAttemptOutputMarkers();
4684
+ }
4723
4685
  const runSpec = nativeTry === 0
4724
4686
  ? spec
4725
4687
  : HarnessRunSpec.parse({
@@ -4740,7 +4702,7 @@ export class Orchestrator {
4740
4702
  ...(knobs.ignored.length > 0 ? { ignored_settings: knobs.ignored } : {}),
4741
4703
  });
4742
4704
  try {
4743
- const watchedReport = withInactivityWatchdog(adapter.run(runSpec), {
4705
+ const watchedReport = withInactivityWatchdog(runModelGovernedRoute(routed, runSpec), {
4744
4706
  timeoutMs: harnessInactivityTimeoutMs(this.config(input.repoRoot)),
4745
4707
  countsAsProgress: countsAsAgentProgress,
4746
4708
  onTimeout: () => {
@@ -4767,29 +4729,14 @@ export class Orchestrator {
4767
4729
  log.emit("harness.event", harnessEventPayload(adapter.id, attemptId, safeEv));
4768
4730
  appendLine(attemptEventsPath, JSON.stringify(safeEv));
4769
4731
  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
- }
4732
+ emitPlanProgress((t, p) => log.emit(t, p), adapter.id, attemptId, safeEv);
4777
4733
  // read-only routes burn quota too (the orchestrate PLANNER is
4778
4734
  // the loudest) — same single owner as the agent loop.
4779
4735
  observeBudgetSignals(ledger, log, adapter.id, attemptId, safeEv, budgetSignalState);
4780
4736
  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
- }
4737
+ const spend = observeReadonlySpend(safeEv, (t, p) => log.emit(t, p), adapter.id, attemptId);
4738
+ cost += spend.costUsd;
4739
+ costEstimated ||= spend.estimated;
4793
4740
  // A TYPED final message wins verbatim over joined narration.
4794
4741
  answer.observe(safeEv);
4795
4742
  if (safeEv.type === "error")
@@ -4800,8 +4747,7 @@ export class Orchestrator {
4800
4747
  }
4801
4748
  catch (err) {
4802
4749
  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).
4750
+ // #31: classify the throw (watchdog timeout vs process crash) as typed.
4805
4751
  telemetry.transientFailures.push(classifyAdapterThrow({ errorName: err instanceof Error ? err.name : null }));
4806
4752
  }
4807
4753
  const newTransients = telemetry.transientFailures.slice(transientStart);
@@ -4809,31 +4755,48 @@ export class Orchestrator {
4809
4755
  const sawRetryable = newTransients.some((f) => f.retryable);
4810
4756
  const sawTypedLimit = telemetry.rateLimits.length > rateLimitStart;
4811
4757
  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).
4758
+ // W5.4 + A2 reactive failover, READ-ONLY lane (same contract as the
4759
+ // candidate lane: typed limit or structural pre-progress death).
4814
4760
  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({
4761
+ const rotated = await rotateSpecOnTypedLimit({
4820
4762
  spec,
4821
4763
  harnessId: adapter.id,
4822
4764
  attemptId,
4823
- policy: rotationPolicy,
4765
+ policy: this.credentials.profilePolicy(input.repoRoot, adapter.id),
4824
4766
  registry: this.config(input.repoRoot)?.global.credential_profiles ?? [],
4825
4767
  snapshots: this.deps.quotaSnapshots?.() ?? [],
4826
- readyProfileIds,
4768
+ probeReadyProfiles: () => this.credentials.readyProfileIdsForRotation(input, adapter.id, spec.credential_profile ?? null, triedProfiles, spec.model_hint ?? null),
4769
+ ...this.credentials.rotationObservations(adapter, spec, newTransients),
4827
4770
  triedProfiles,
4771
+ markers: telemetry.outputMarkers,
4828
4772
  sawTypedLimit,
4829
- deliverableEmpty: reportSoFar.length === 0,
4773
+ sawRetryable,
4774
+ attemptErrored: harnessError !== null,
4775
+ // Same policy owner as the candidate lane: an errored try's
4776
+ // narration is not a report; the transient gate keeps reportSoFar.
4777
+ deliverableEmpty: acceptedTryOutput(answer, harnessError !== null).length === 0,
4830
4778
  lastLimit: telemetry.rateLimits.at(-1) ?? null,
4831
4779
  emit: (type, payload) => log.emit(type, payload),
4832
4780
  newSessionId: () => newId("ses"),
4833
4781
  defaultRouteWasVendorNative: routed.authRouteEstimate === "local_session",
4782
+ // D-U6: an explicit pin never rotates; pool-selected rows do.
4783
+ pinned: input.credentialProfileId != null,
4834
4784
  });
4785
+ // A5 ordering: same as the candidate lane — terminalize typed
4786
+ // before the transient gate burns same-profile retries. The pool
4787
+ // refusal APPENDS to the original harness error (candidate-lane
4788
+ // parity: errors.push) instead of erasing the true failure.
4789
+ if (rotated && "poolExhausted" in rotated) {
4790
+ poolExhausted = rotated.poolExhausted;
4791
+ harnessError = harnessError
4792
+ ? `${harnessError}; ${safeErrorMessage(poolExhausted)}`
4793
+ : safeErrorMessage(poolExhausted);
4794
+ break;
4795
+ }
4835
4796
  if (rotated) {
4836
- spec = rotated;
4797
+ // INV-137: the rotated row's session must land in ITS OWN lane
4798
+ // home — never the previous row's lane store.
4799
+ spec = rotatedSpecInLaneHome(spec, rotated, (id) => this.laneHomeEnvFor(input, adapter.id, id), input.credentialProfileId ?? null);
4837
4800
  harnessError = null;
4838
4801
  continue;
4839
4802
  }
@@ -4844,21 +4807,7 @@ export class Orchestrator {
4844
4807
  nativeTry >= retryPolicy.maxRetries ||
4845
4808
  input.signal?.aborted)
4846
4809
  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
- });
4810
+ const delayMs = emitTransientRetryPlan((t, p) => log.emit(t, p), adapter.id, attemptId, transient, nativeTry, retryPolicy);
4862
4811
  harnessError = null;
4863
4812
  await sleep(delayMs);
4864
4813
  }
@@ -4877,19 +4826,13 @@ export class Orchestrator {
4877
4826
  preStreamFailureSource: "readonly-pre-stream",
4878
4827
  });
4879
4828
  }
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
- });
4829
+ if (harnessError && !poolExhausted) {
4830
+ emitTransientExhausted((t, p) => log.emit(t, p), adapter.id, attemptId, telemetry, retryPolicy.maxRetries);
4887
4831
  }
4888
4832
  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
- });
4833
+ // D-16: the deliverable is the report. A3: an errored attempt with no
4834
+ // typed final contributes no report material (acceptedTryOutput).
4835
+ const roUnwrapped = unwrapWorkReportEnvelope(acceptedTryOutput(answer, harnessError !== null), readonlyWorkMode, { sideToolReport: telemetry.sideToolWorkReport ?? undefined });
4893
4836
  // Trim symmetrically with the plan path: a whitespace-only answer is not
4894
4837
  // a delivered report (the final-artifact wrapper heading would otherwise
4895
4838
  // make it read as present content by construction).
@@ -4944,6 +4887,7 @@ export class Orchestrator {
4944
4887
  report,
4945
4888
  error: harnessError,
4946
4889
  telemetry,
4890
+ ...(poolExhausted ? { declaredFailure: declaredFailure(poolExhausted) } : {}),
4947
4891
  });
4948
4892
  if (opts.deepScan) {
4949
4893
  store.writeText(join(paths.findingsDir, `${attemptId}-error.md`), `# Explorer ${attemptId} failed\n\n${harnessError}\n`);
@@ -5169,14 +5113,19 @@ export class Orchestrator {
5169
5113
  // #31: classify the harness cause across the read-only attempts so auth
5170
5114
  // guidance appears only on a real auth failure.
5171
5115
  const roCategory = dominantHarnessFailureCategory(attemptTelemetries.flatMap((a) => a.telemetry.transientFailures));
5116
+ // A5: the terminal already speaks with the LAST attempt's message; its
5117
+ // typed refusal rides along machine-readably instead of reducing to it.
5118
+ const roDeclared = webBlocked ? undefined : last?.declaredFailure;
5172
5119
  writeFailure(store, paths, {
5173
5120
  phase: "harness",
5174
- category: webBlocked ? "policy" : "harness_error",
5121
+ category: webBlocked ? "policy" : (roDeclared?.category ?? "harness_error"),
5122
+ code: roDeclared?.code ?? null,
5175
5123
  harnessId: last?.harnessId,
5176
5124
  attemptId: last?.attemptId,
5177
5125
  safeMessage: singleError,
5178
5126
  eventRefs: roEventRefs,
5179
5127
  runDir: paths.root,
5128
+ resetsAt: roDeclared?.resetsAt ?? null,
5180
5129
  nextActions: harnessFailureNextActions(roCategory),
5181
5130
  });
5182
5131
  }