@signalridge/pi-subagents 1.8.0 → 1.9.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.
@@ -12,20 +12,21 @@ import { realpathSync, statSync } from "node:fs";
12
12
  import { isAbsolute, resolve } from "node:path";
13
13
  import type { Model } from "@earendil-works/pi-ai";
14
14
  import type { AgentSession, ExtensionAPI, ExtensionContext } from "@earendil-works/pi-coding-agent";
15
- import type { ManagedSpawnRequest as ProtocolManagedSpawnRequest, WorkflowTier } from "@signalridge/pi-subagents-protocol";
16
- import { isWorkflowTier, parseManagedSpawnRequest } from "@signalridge/pi-subagents-protocol";
15
+ import type { ManagedSpawnRequest as ProtocolManagedSpawnRequest } from "@signalridge/pi-subagents-protocol";
16
+ import { isManagedAgentTier, parseManagedSpawnRequest } from "@signalridge/pi-subagents-protocol";
17
17
  import { resumeAgent, runAgent, type ToolActivity } from "./agent-runner.js";
18
- import type { AgentTierResolutionSnapshot } from "./agent-tiers.js";
18
+ import { type AgentTierResolutionSnapshot, getAgentTiersSettings } from "./agent-tiers.js";
19
19
  import {
20
20
  INTERNAL_AGENT_CONFIG_OVERRIDE,
21
+ INTERNAL_PARENT_POLICY_SNAPSHOT,
21
22
  type InternalAgentConfigOverride,
23
+ type ParentPolicySnapshot,
22
24
  } from "./internal-run.js";
23
25
  import { assignHandle, handleBase } from "./mention.js";
24
26
  import { shutdownAndDisposeSession } from "./session-lifecycle.js";
25
- import type { WorkflowThinking } from "./settings.js";
27
+ import type { TierThinking } from "./settings.js";
26
28
  import type { AgentInvocation, AgentOwner, AgentRecord, AgentRecordSnapshot, IsolationMode, ResumableAgentEntry, SubagentType, ThinkingLevel } from "./types.js";
27
29
  import { addUsage } from "./usage.js";
28
- import type { WorkflowTierResolutionSnapshot } from "./workflow-tiers.js";
29
30
  import {
30
31
  cleanupWorktree,
31
32
  cleanupWorktreeAsync,
@@ -241,7 +242,14 @@ interface SpawnArgs {
241
242
  export type ManagedSpawnRequest = ProtocolManagedSpawnRequest;
242
243
 
243
244
  export const MANAGED_SPAWN_ENTRY_TYPE = "subagents:managed-spawn";
244
- export const MANAGED_SPAWN_SCHEMA_VERSION = 1;
245
+ /** Managed tombstone schema v2; schema-v1 facts are quarantined rather than reused. */
246
+ export const MANAGED_SPAWN_SCHEMA_VERSION = 2;
247
+ /**
248
+ * How many retired spawn keys to remember. Generous — a run rotates its attempt
249
+ * id on resume, so collisions need a key that was live across a branch
250
+ * replacement and is then re-requested verbatim.
251
+ */
252
+ const MANAGED_RETIRED_KEY_LIMIT = 4_096;
245
253
  const MANAGED_TEXT_LIMIT = 8_000;
246
254
  const MANAGED_ERROR_LIMIT = 2_000;
247
255
  const MANAGED_PATH_LIMIT = 2_000;
@@ -276,14 +284,14 @@ export interface ManagedSpawnTombstone {
276
284
  type: string;
277
285
  description: string;
278
286
  owner: AgentOwner;
279
- /** Semantic tier requested by the workflow; resolution remains internal. */
280
- tier?: WorkflowTier;
287
+ /** Agent tier this spawn resolved to, including one the host defaulted to. */
288
+ tier?: string;
281
289
  /** Named managed thread, when this spawn re-enters a sequential session. */
282
290
  thread?: string;
283
291
  /** Effective policy fingerprint that makes thread reuse safe across calls/reloads. */
284
292
  threadPolicyFingerprint?: string;
285
- /** Internal audit snapshot of the resolved model/thinking policy. */
286
- tierSnapshot?: WorkflowTierResolutionSnapshot;
293
+ /** Internal audit snapshot of the resolved tier policy. */
294
+ tierSnapshot?: AgentTierResolutionSnapshot;
287
295
  state: ManagedSpawnState;
288
296
  createdAt: number;
289
297
  updatedAt: number;
@@ -294,6 +302,8 @@ export interface ManagedSpawnTombstone {
294
302
  /** Response returned by managed spawn, including an already-settled outcome. */
295
303
  export interface ManagedSpawnResult {
296
304
  id: string;
305
+ /** Agent tier the manager selected, echoed so the caller can journal it. */
306
+ tier?: string;
297
307
  state: ManagedSpawnState;
298
308
  /** True only when this call allocated a new tombstone/Agent record. */
299
309
  created: boolean;
@@ -352,19 +362,13 @@ function normalizeManagedOwner(raw: unknown, requireAttempt = false): AgentOwner
352
362
  };
353
363
  }
354
364
 
355
- function normalizeManagedSpawnRequest(raw: unknown): ManagedSpawnRequest {
356
- return parseManagedSpawnRequest(raw);
357
- }
358
-
359
365
  function managedFingerprint(request: ManagedSpawnRequest, policyFingerprint?: string): string {
360
366
  return createHash("sha256")
361
367
  .update(JSON.stringify([
362
368
  request.type,
363
369
  request.prompt,
364
370
  request.description,
365
- ...(request.tier === undefined ? [] : [request.tier]),
366
- request.model,
367
- request.thinking,
371
+ request.tier,
368
372
  request.toolset,
369
373
  request.excludeTools,
370
374
  request.isolation,
@@ -378,50 +382,73 @@ function managedFingerprint(request: ManagedSpawnRequest, policyFingerprint?: st
378
382
  .digest("hex");
379
383
  }
380
384
 
381
- /** Exact schema-v1 identity; do not add newer policy fields to this algorithm. */
382
- function managedLegacyFingerprintV1(request: ManagedSpawnRequest): string {
383
- return createHash("sha256")
384
- .update(JSON.stringify([
385
- request.type,
386
- request.prompt,
387
- request.description,
388
- ...(request.tier === undefined ? [] : [request.tier]),
389
- request.owner.extension,
390
- request.owner.runId,
391
- request.owner.nodeId,
392
- request.owner.attemptId,
393
- ]))
394
- .digest("hex");
385
+
386
+ function currentParentThinkingLevel(pi: ExtensionAPI): ThinkingLevel | undefined {
387
+ const level = pi.getThinkingLevel?.();
388
+ return level === "minimal" || level === "low" || level === "medium" || level === "high" || level === "xhigh" || level === "max"
389
+ ? level
390
+ : undefined;
395
391
  }
396
392
 
397
393
  /**
398
394
  * Policy identity for a managed spawn. Prompts and owner attempt identities
399
395
  * intentionally do not participate: a thread is allowed to receive new work,
400
396
  * but its session cannot safely change model/tool/isolation policy.
397
+ *
398
+ * The tier's *resolved* model and thinking participate, not just its key: two
399
+ * calls naming the same tier are only the same policy while that tier still
400
+ * means the same thing. A profile that inherits folds in the parent identity
401
+ * for the same reason — it is the value that will actually run.
401
402
  */
402
- function managedThreadPolicyFingerprint(request: ManagedSpawnRequest, policy: ManagedSpawnPolicy): string {
403
- const model = policy.model as { provider?: unknown; id?: unknown } | undefined;
403
+ function managedThreadPolicyFingerprint(
404
+ request: ManagedSpawnRequest,
405
+ policy: ManagedSpawnPolicy,
406
+ parentModel: { provider?: unknown; id?: unknown } | undefined,
407
+ parentThinking: ThinkingLevel | undefined,
408
+ ): string {
409
+ const effectiveTier = request.tier ?? policy.invocation?.agentTier;
410
+ const profile = effectiveTier ? getAgentTiersSettings().profiles?.[effectiveTier] : undefined;
411
+ const inheritedModelIdentity =
412
+ profile?.model === "inherit" ? { provider: parentModel?.provider, id: parentModel?.id } : undefined;
413
+ const inheritedThinkingIdentity = profile?.thinking === "inherit" ? parentThinking : undefined;
404
414
  const excludeTools = [...new Set([...(request.excludeTools ?? []), ...(policy.excludeTools ?? [])])].sort();
405
415
  return createHash("sha256")
406
416
  .update(JSON.stringify([
407
417
  request.type,
408
- request.tier,
409
- request.model,
410
- request.thinking === "off" ? undefined : request.thinking,
418
+ effectiveTier,
419
+ profile?.model,
420
+ profile?.thinking,
421
+ inheritedModelIdentity,
422
+ inheritedThinkingIdentity,
411
423
  request.toolset ?? policy.toolset,
412
424
  excludeTools,
413
425
  request.isolation ?? policy.isolation,
414
426
  policy.maxTurns,
415
427
  policy.isolated,
416
428
  policy.inheritContext,
417
- policy.thinkingLevel,
418
429
  policy.policyFingerprint,
419
- model?.provider,
420
- model?.id,
421
430
  ]))
422
431
  .digest("hex");
423
432
  }
424
433
 
434
+ /**
435
+ * Name what a caller can actually act on.
436
+ *
437
+ * The common cause is not a changed script: every shipped tier profile inherits
438
+ * its model, so switching the session's model changes what the thread's tier
439
+ * resolves to and invalidates the session it was reusing. A message that only
440
+ * says "policy changed" sends the reader looking in the wrong file.
441
+ */
442
+ function threadPolicyConflictMessage(thread: string, tier: string | undefined): string {
443
+ const cause = tier
444
+ ? `Its tier "${tier}", the session model or thinking level that tier inherits, or its tool/isolation policy changed since the thread's last call.`
445
+ : "Its model, thinking, tool, or isolation policy changed since the thread's last call.";
446
+ return (
447
+ `Managed workflow thread policy conflict: "${thread}". ${cause} ` +
448
+ "A thread reuses one session, which cannot safely change policy mid-conversation; use a new thread name."
449
+ );
450
+ }
451
+
425
452
  function isManagedState(value: unknown): value is ManagedSpawnState {
426
453
  return value === "queued" || value === "running" || value === "completed" || value === "failed" || value === "stopped" || value === "interrupted";
427
454
  }
@@ -450,62 +477,50 @@ function cloneManagedTombstone(tombstone: ManagedSpawnTombstone): ManagedSpawnTo
450
477
  };
451
478
  }
452
479
 
453
- const WORKFLOW_THINKING_LEVELS = new Set<ThinkingLevel>(["minimal", "low", "medium", "high", "xhigh", "max"]);
454
- const WORKFLOW_CONFIGURED_THINKING = new Set<WorkflowThinking>([...WORKFLOW_THINKING_LEVELS, "inherit"]);
480
+ const THINKING_LEVELS = new Set<ThinkingLevel>(["minimal", "low", "medium", "high", "xhigh", "max"]);
455
481
  const TIER_SNAPSHOT_KEYS = new Set([
456
482
  "tier",
483
+ "source",
457
484
  "model",
458
485
  "thinking",
459
486
  "configuredModel",
460
487
  "configuredThinking",
461
488
  "requestedThinking",
462
- "modelSource",
463
- "thinkingSource",
464
489
  "clamped",
465
490
  "diagnostic",
466
491
  ]);
467
492
 
468
- function parseTierSnapshot(raw: unknown, tier: WorkflowTier): WorkflowTierResolutionSnapshot | undefined {
469
- if (!isRecord(raw) || raw.tier !== tier) return undefined;
493
+ function parseTierSnapshot(raw: unknown, tier: string): AgentTierResolutionSnapshot | undefined {
494
+ if (!isRecord(raw) || raw.tier !== tier || !isManagedAgentTier(tier)) return undefined;
470
495
  try {
471
496
  rejectManagedKeys(raw, TIER_SNAPSHOT_KEYS, "tierSnapshot");
472
- const source = raw.modelSource;
473
- const thinkingSource = raw.thinkingSource;
474
- if ((source !== "frontmatter" && source !== "tier" && source !== "parent") ||
475
- (thinkingSource !== "frontmatter" && thinkingSource !== "tier" && thinkingSource !== "parent")) return undefined;
497
+ const source = raw.source;
498
+ if (source !== "call" && source !== "frontmatter" && source !== "default") return undefined;
476
499
  const optionalThinking = (value: unknown): ThinkingLevel | undefined => {
477
500
  if (value === undefined) return undefined;
478
- return typeof value === "string" && WORKFLOW_THINKING_LEVELS.has(value as ThinkingLevel)
501
+ return typeof value === "string" && THINKING_LEVELS.has(value as ThinkingLevel)
479
502
  ? value as ThinkingLevel
480
503
  : undefined;
481
504
  };
482
505
  const thinking = optionalThinking(raw.thinking);
483
- const configuredThinking = raw.configuredThinking === undefined
484
- ? undefined
485
- : typeof raw.configuredThinking === "string" && WORKFLOW_CONFIGURED_THINKING.has(raw.configuredThinking as WorkflowThinking)
486
- ? raw.configuredThinking as WorkflowThinking
487
- : undefined;
488
506
  const requestedThinking = optionalThinking(raw.requestedThinking);
489
- for (const [label, value] of [["model", raw.model], ["configuredModel", raw.configuredModel]] as const) {
490
- if (value !== undefined && (typeof value !== "string" || !value.trim() || value.length > MANAGED_PATH_LIMIT)) {
491
- return undefined;
492
- }
493
- if (label === "model" && typeof value === "string" && !value.includes("/")) return undefined;
494
- }
507
+ const configuredThinking = raw.configuredThinking;
508
+ if (typeof raw.configuredModel !== "string" || !raw.configuredModel.trim() || raw.configuredModel.length > MANAGED_PATH_LIMIT) return undefined;
509
+ if (typeof configuredThinking !== "string" || ![...THINKING_LEVELS, "inherit"].includes(configuredThinking)) return undefined;
510
+ if (raw.model !== undefined &&
511
+ (typeof raw.model !== "string" || !raw.model.trim() || raw.model.length > MANAGED_PATH_LIMIT || !raw.model.includes("/"))) return undefined;
495
512
  if (raw.thinking !== undefined && thinking === undefined) return undefined;
496
- if (raw.configuredThinking !== undefined && configuredThinking === undefined) return undefined;
497
513
  if (raw.requestedThinking !== undefined && requestedThinking === undefined) return undefined;
498
514
  if (raw.clamped !== undefined && typeof raw.clamped !== "boolean") return undefined;
499
515
  if (raw.diagnostic !== undefined && (typeof raw.diagnostic !== "string" || raw.diagnostic.length > MANAGED_ERROR_LIMIT)) return undefined;
500
516
  return {
501
517
  tier,
518
+ source,
502
519
  ...(typeof raw.model === "string" ? { model: raw.model } : {}),
503
520
  ...(thinking ? { thinking } : {}),
504
- ...(typeof raw.configuredModel === "string" ? { configuredModel: raw.configuredModel } : {}),
505
- ...(configuredThinking ? { configuredThinking } : {}),
521
+ configuredModel: raw.configuredModel,
522
+ configuredThinking: configuredThinking as TierThinking,
506
523
  ...(requestedThinking ? { requestedThinking } : {}),
507
- modelSource: source,
508
- thinkingSource,
509
524
  ...(raw.clamped === true ? { clamped: true } : {}),
510
525
  ...(typeof raw.diagnostic === "string" ? { diagnostic: raw.diagnostic } : {}),
511
526
  };
@@ -521,6 +536,13 @@ function terminalStatusForRecord(status: ManagedRecordStatus): ManagedTerminalSn
521
536
  return undefined;
522
537
  }
523
538
 
539
+ /** Recover only the key needed to prevent an unsupported tombstone from being replayed. */
540
+ function managedSpawnKeyForQuarantine(raw: unknown): string | undefined {
541
+ if (!isRecord(raw) || typeof raw.spawnKey !== "string") return undefined;
542
+ const key = raw.spawnKey.trim();
543
+ return key.length > 0 && key.length <= 256 ? key : undefined;
544
+ }
545
+
524
546
  function parseManagedTombstone(raw: unknown): ManagedSpawnTombstone | undefined {
525
547
  if (!isRecord(raw) || raw.schemaVersion !== MANAGED_SPAWN_SCHEMA_VERSION) return undefined;
526
548
  try {
@@ -533,10 +555,11 @@ function parseManagedTombstone(raw: unknown): ManagedSpawnTombstone | undefined
533
555
  ? undefined
534
556
  : boundedManagedString(raw.threadPolicyFingerprint, "threadPolicyFingerprint", 128);
535
557
  if ((thread === undefined) !== (threadPolicyFingerprint === undefined)) return undefined;
536
- const tier = raw.tier;
537
- if (tier !== undefined && !isWorkflowTier(tier)) return undefined;
558
+ const rawTier = raw.tier;
559
+ if (rawTier !== undefined && !isManagedAgentTier(rawTier)) return undefined;
560
+ const tier = rawTier as string | undefined;
538
561
  const tierSnapshotValue = raw.tierSnapshot;
539
- let tierSnapshot: WorkflowTierResolutionSnapshot | undefined;
562
+ let tierSnapshot: AgentTierResolutionSnapshot | undefined;
540
563
  if (tierSnapshotValue !== undefined) {
541
564
  if (tier === undefined) return undefined;
542
565
  tierSnapshot = parseTierSnapshot(tierSnapshotValue, tier);
@@ -608,10 +631,10 @@ export interface SpawnOptions {
608
631
  isolated?: boolean;
609
632
  inheritContext?: boolean;
610
633
  thinkingLevel?: ThinkingLevel;
611
- /** Semantic workflow tier resolved by pi-subagents at session start. */
612
- tier?: WorkflowTier;
613
634
  /** User-named model tier; resolved by pi-subagents at session start. */
614
635
  agentTier?: string;
636
+ /** Managed workflow runs must select a tier instead of inheriting the parent. */
637
+ requireAgentTier?: boolean;
615
638
  /** Optional toolset hint; concrete tool availability remains agent-configured. */
616
639
  toolset?: string;
617
640
  /** Additional tool names denied for this invocation. */
@@ -638,6 +661,8 @@ export interface SpawnOptions {
638
661
  cwd?: string;
639
662
  /** Resolved invocation snapshot captured for UI display. */
640
663
  invocation?: AgentInvocation;
664
+ /** Parent model/thinking captured for a managed spawn before it enters the queue. */
665
+ readonly [INTERNAL_PARENT_POLICY_SNAPSHOT]?: ParentPolicySnapshot;
641
666
  /** Parent abort signal — when aborted, the subagent is also stopped. */
642
667
  signal?: AbortSignal;
643
668
  /** Called on tool start/end with activity info (for streaming progress to UI). */
@@ -646,10 +671,8 @@ export interface SpawnOptions {
646
671
  onTextDelta?: (delta: string, fullText: string) => void;
647
672
  /** Called when the agent session is created (for accessing session stats). */
648
673
  onSessionCreated?: (session: AgentSession) => void;
649
- /** Called after pi-subagents resolves a semantic workflow tier. */
650
- onTierResolved?: (snapshot: WorkflowTierResolutionSnapshot) => void;
651
- /** Called after pi-subagents resolves a user-named agent tier. */
652
- onAgentTierResolved?: (snapshot: AgentTierResolutionSnapshot) => void;
674
+ /** Called after pi-subagents resolves the tier for this spawn; see RunOptions. */
675
+ onAgentTierResolved?: (snapshot: AgentTierResolutionSnapshot, modelLabel?: string) => void;
653
676
  /** Called synchronously after a new record is allocated, before session creation. */
654
677
  onSpawned?: (id: string) => void;
655
678
  /** Called at the end of each agentic turn with the cumulative count. */
@@ -684,10 +707,10 @@ export interface SpawnOptions {
684
707
  resumeSessionFile?: string;
685
708
  }
686
709
 
687
- /** Internal managed-spawn policy; model and thinking are finalized by runAgent when a tier is present. */
710
+ /** Internal managed-spawn policy; model/thinking resolution is finalized by runAgent. */
688
711
  export type ManagedSpawnPolicy = Pick<
689
712
  SpawnOptions,
690
- "model" | "maxTurns" | "isolated" | "inheritContext" | "thinkingLevel" | "isolation" | "invocation" | "rootSessionId" | "toolset" | "excludeTools"
713
+ "maxTurns" | "isolated" | "inheritContext" | "isolation" | "invocation" | "rootSessionId" | "toolset" | "excludeTools"
691
714
  > & {
692
715
  /** In-process identity of the resolved agent definition/tool allowlist for thread reuse. */
693
716
  policyFingerprint?: string;
@@ -699,6 +722,8 @@ interface ResumeTerminalSnapshot {
699
722
  completedAt?: number;
700
723
  result?: string;
701
724
  error?: string;
725
+ /** Policy metadata must survive a detached/resumed attempt unchanged. */
726
+ invocation?: AgentInvocation;
702
727
  }
703
728
 
704
729
  interface ResumeControl {
@@ -720,6 +745,16 @@ export class AgentManager {
720
745
  private onCompact?: OnAgentCompact;
721
746
  private managedSpawns = new Map<string, ManagedSpawnTombstone>();
722
747
  private managedKeysById = new Map<string, string>();
748
+ /** Spawn keys found in unsupported/corrupt persisted tombstones. */
749
+ private managedQuarantinedKeys = new Set<string>();
750
+ /**
751
+ * Spawn keys retired during a branch/session replacement before their
752
+ * provider work settled. A retired key must never be reallocated, but the set
753
+ * is bounded: a long session that replaces its branch repeatedly would
754
+ * otherwise grow it without limit. Oldest entries are evicted first — those
755
+ * are the ones whose sessions are least likely to still be alive.
756
+ */
757
+ private managedRetiredKeys = new Set<string>();
723
758
  /** Active workflow-thread name to the managed AgentSession it re-enters. */
724
759
  private managedThreads = new Map<string, string>();
725
760
  /** Policy identity captured with each thread; policy changes cannot bypass a denylist. */
@@ -1049,7 +1084,8 @@ export class AgentManager {
1049
1084
  : undefined;
1050
1085
  this.replaceManagedTombstone(key, {
1051
1086
  ...tombstone,
1052
- ...(record.invocation?.tierSnapshot ? { tierSnapshot: { ...record.invocation.tierSnapshot } } : {}),
1087
+ ...(record.invocation?.agentTierSnapshot ? { tierSnapshot: { ...record.invocation.agentTierSnapshot } } : {}),
1088
+ ...(record.invocation?.agentTier !== undefined ? { tier: record.invocation.agentTier } : {}),
1053
1089
  state,
1054
1090
  updatedAt: Date.now(),
1055
1091
  compactionCount,
@@ -1091,12 +1127,30 @@ export class AgentManager {
1091
1127
  }
1092
1128
  return {
1093
1129
  id: tombstone.id,
1130
+ ...(tombstone.tier === undefined ? {} : { tier: tombstone.tier }),
1094
1131
  state: tombstone.state,
1095
1132
  created,
1096
1133
  ...(tombstone.terminal ? { terminal: cloneManagedTerminal(tombstone.terminal) } : {}),
1097
1134
  };
1098
1135
  }
1099
1136
 
1137
+ private retireLiveManagedKeys(): void {
1138
+ for (const [key, tombstone] of this.managedSpawns) {
1139
+ const record = this.agents.get(tombstone.id);
1140
+ if (!isManagedTerminalState(tombstone.state) || (record !== undefined && !this.isFullyCleaned(record))) {
1141
+ // Re-inserting moves an existing key to the end of iteration order, so
1142
+ // the eviction below always drops the least recently retired key.
1143
+ this.managedRetiredKeys.delete(key);
1144
+ this.managedRetiredKeys.add(key);
1145
+ }
1146
+ }
1147
+ while (this.managedRetiredKeys.size > MANAGED_RETIRED_KEY_LIMIT) {
1148
+ const oldest = this.managedRetiredKeys.values().next();
1149
+ if (oldest.done) break;
1150
+ this.managedRetiredKeys.delete(oldest.value);
1151
+ }
1152
+ }
1153
+
1100
1154
  /** Restore session-scoped managed idempotency entries and settle orphaned work. */
1101
1155
  restoreManagedSpawns(
1102
1156
  entries: readonly ManagedSpawnEntryLike[],
@@ -1104,15 +1158,38 @@ export class AgentManager {
1104
1158
  ): ManagedSpawnTombstone[] {
1105
1159
  this.clearManagedPersistenceRetries();
1106
1160
  if (this.disposed) return [];
1161
+ this.retireLiveManagedKeys();
1107
1162
  this.managedSpawns.clear();
1108
1163
  this.managedKeysById.clear();
1164
+ this.managedQuarantinedKeys.clear();
1109
1165
  this.managedThreads.clear();
1110
1166
  this.managedThreadPolicies.clear();
1111
1167
  this.managedThreadReservations.clear();
1112
1168
  for (const entry of entries) {
1113
1169
  if (entry.type !== "custom" || entry.customType !== MANAGED_SPAWN_ENTRY_TYPE) continue;
1114
1170
  const tombstone = parseManagedTombstone(entry.data);
1115
- if (!tombstone) continue;
1171
+ if (!tombstone) {
1172
+ const key = managedSpawnKeyForQuarantine(entry.data);
1173
+ if (key) {
1174
+ this.managedQuarantinedKeys.add(key);
1175
+ const previous = this.managedSpawns.get(key);
1176
+ if (previous && this.managedKeysById.get(previous.id) === key) {
1177
+ this.managedKeysById.delete(previous.id);
1178
+ for (const [threadKey, threadId] of this.managedThreads) {
1179
+ if (threadId !== previous.id) continue;
1180
+ this.managedThreads.delete(threadKey);
1181
+ this.managedThreadPolicies.delete(threadKey);
1182
+ }
1183
+ }
1184
+ // A later incompatible fact must invalidate an earlier current-schema
1185
+ // fact for the same key. Otherwise restore order could resurrect a
1186
+ // spawn that the clean-break quarantine is meant to reject.
1187
+ this.managedSpawns.delete(key);
1188
+ console.warn(`[pi-subagents] quarantined unsupported managed spawn tombstone for ${key}`);
1189
+ }
1190
+ continue;
1191
+ }
1192
+ if (this.managedQuarantinedKeys.has(tombstone.spawnKey) || this.managedRetiredKeys.has(tombstone.spawnKey)) continue;
1116
1193
  if (options.dropActive && !isManagedTerminalState(tombstone.state)) continue;
1117
1194
  this.managedSpawns.set(tombstone.spawnKey, tombstone);
1118
1195
  this.managedKeysById.set(tombstone.id, tombstone.spawnKey);
@@ -1387,42 +1464,68 @@ export class AgentManager {
1387
1464
  callbacks?: Pick<SpawnOptions, "onToolActivity" | "onTextDelta" | "onSessionCreated" | "onTurnEnd" | "onAssistantUsage" | "onCompaction" | "onSpawned">,
1388
1465
  ): ManagedSpawnResult {
1389
1466
  if (this.disposed) throw new Error("AgentManager is disposed");
1390
- const normalized = normalizeManagedSpawnRequest(request);
1391
- if (normalized.thread && (normalized.isolation === "worktree" || policy.isolation === "worktree")) {
1467
+ const normalized = parseManagedSpawnRequest(request);
1468
+ // The policy may already know the agent's own tier or the configured
1469
+ // default before runAgent emits its authoritative resolution callback.
1470
+ // Carry that key in managed identity, but keep it out of the runner options
1471
+ // so source and snapshot semantics still resolve centrally in runAgent.
1472
+ const identityTier = normalized.tier ?? policy.invocation?.agentTier;
1473
+ // Narrowed once so every later use is a plain string rather than a
1474
+ // non-null assertion on a field TypeScript cannot re-narrow across the
1475
+ // intervening lookups.
1476
+ const thread = normalized.thread;
1477
+ if (thread && (normalized.isolation === "worktree" || policy.isolation === "worktree")) {
1392
1478
  throw new Error("Managed workflow threads cannot use worktree isolation; use separate calls instead.");
1393
1479
  }
1394
1480
  const scope = normalized.spawnKey;
1395
- const policyFingerprint = managedThreadPolicyFingerprint(normalized, policy);
1481
+ if (this.managedQuarantinedKeys.has(scope) || this.managedRetiredKeys.has(scope)) {
1482
+ throw new Error(`Managed spawn key is quarantined and cannot be reused: "${scope}"`);
1483
+ }
1484
+ const parentThinking = currentParentThinkingLevel(pi);
1485
+ const parentPolicySnapshot: ParentPolicySnapshot = Object.freeze({
1486
+ model: ctx.model,
1487
+ thinking: parentThinking,
1488
+ });
1489
+ const policyFingerprint = managedThreadPolicyFingerprint(
1490
+ normalized,
1491
+ policy,
1492
+ parentPolicySnapshot.model,
1493
+ parentPolicySnapshot.thinking,
1494
+ );
1396
1495
  const fingerprint = managedFingerprint(normalized, policyFingerprint);
1397
- const legacyFingerprint = managedLegacyFingerprintV1(normalized);
1398
- const threadPolicyFingerprint = normalized.thread ? policyFingerprint : undefined;
1496
+ const threadPolicyFingerprint = thread ? policyFingerprint : undefined;
1399
1497
  const previous = this.managedSpawns.get(scope);
1400
1498
  if (previous) {
1401
- if (previous.fingerprint !== fingerprint && previous.fingerprint !== legacyFingerprint) {
1499
+ if (previous.fingerprint !== fingerprint) {
1402
1500
  throw new Error(`Managed spawn key conflict: "${normalized.spawnKey}"`);
1403
1501
  }
1404
- if (normalized.thread && previous.threadPolicyFingerprint !== threadPolicyFingerprint) {
1405
- throw new Error(`Managed workflow thread policy conflict: "${normalized.thread}"; use a new thread name for changed model/tool policy.`);
1502
+ if (thread && previous.threadPolicyFingerprint !== threadPolicyFingerprint) {
1503
+ throw new Error(threadPolicyConflictMessage(thread, identityTier));
1406
1504
  }
1407
1505
  return this.managedResult(scope);
1408
1506
  }
1409
1507
 
1410
- const threadKey = normalized.thread ? `${normalized.owner.runId}\u0000${normalized.thread}` : undefined;
1508
+ const threadKey = thread ? `${normalized.owner.runId}\u0000${thread}` : undefined;
1411
1509
  if (threadKey && this.managedThreadReservations.has(threadKey)) {
1412
- throw new Error(`Managed workflow thread "${normalized.thread}" is already running; calls must be sequential.`);
1510
+ throw new Error(`Managed workflow thread "${thread}" is already running; calls must be sequential.`);
1413
1511
  }
1414
1512
  const threadedId = threadKey ? this.managedThreads.get(threadKey) : undefined;
1415
- if (threadedId) {
1416
- if (threadPolicyFingerprint !== undefined && this.managedThreadPolicies.get(threadKey!) !== threadPolicyFingerprint) {
1417
- throw new Error(`Managed workflow thread policy conflict: "${normalized.thread}"; use a new thread name for changed model/tool policy.`);
1513
+ if (threadedId && threadKey && thread) {
1514
+ if (threadPolicyFingerprint !== undefined && this.managedThreadPolicies.get(threadKey) !== threadPolicyFingerprint) {
1515
+ throw new Error(threadPolicyConflictMessage(thread, identityTier));
1418
1516
  }
1419
1517
  const record = this.agents.get(threadedId);
1420
1518
  if (record?.session && record.status !== "running" && record.status !== "queued" && !record.detached) {
1421
1519
  const previousOwner = record.owner;
1422
1520
  const previousInvocation = record.invocation;
1423
1521
  const previousManagedKey = this.managedKeysById.get(threadedId);
1522
+ const resumedTier = identityTier ?? previousInvocation?.agentTier;
1424
1523
  record.owner = Object.freeze({ ...normalized.owner });
1425
- record.invocation = policy.invocation;
1524
+ record.invocation = {
1525
+ ...(policy.invocation ?? {}),
1526
+ ...(resumedTier === undefined ? {} : { agentTier: resumedTier }),
1527
+ ...(previousInvocation?.agentTierSnapshot ? { agentTierSnapshot: previousInvocation.agentTierSnapshot } : {}),
1528
+ };
1426
1529
  const now = Date.now();
1427
1530
  const tombstone: ManagedSpawnTombstone = {
1428
1531
  schemaVersion: MANAGED_SPAWN_SCHEMA_VERSION,
@@ -1433,12 +1536,13 @@ export class AgentManager {
1433
1536
  type: normalized.type,
1434
1537
  description: normalized.description,
1435
1538
  owner: { ...normalized.owner },
1436
- ...(normalized.tier === undefined ? {} : { tier: normalized.tier }),
1437
- ...(normalized.thread === undefined ? {} : { thread: normalized.thread, threadPolicyFingerprint }),
1539
+ ...(resumedTier === undefined ? {} : { tier: resumedTier }),
1540
+ ...(thread === undefined ? {} : { thread, threadPolicyFingerprint }),
1438
1541
  state: "queued",
1439
1542
  createdAt: now,
1440
1543
  updatedAt: now,
1441
1544
  compactionCount: record.compactionCount,
1545
+ ...(record.invocation?.agentTierSnapshot ? { tierSnapshot: { ...record.invocation.agentTierSnapshot } } : {}),
1442
1546
  };
1443
1547
  this.clearManagedPersistenceRetry(scope);
1444
1548
  this.managedSpawns.set(scope, tombstone);
@@ -1461,10 +1565,15 @@ export class AgentManager {
1461
1565
  }).catch(() => {});
1462
1566
  this.syncManagedRecord(record, true);
1463
1567
  const resumedState = String(record.status);
1464
- return { id: threadedId, state: resumedState === "queued" ? "queued" : "running", created: true };
1568
+ return {
1569
+ id: threadedId,
1570
+ ...(resumedTier === undefined ? {} : { tier: resumedTier }),
1571
+ state: resumedState === "queued" ? "queued" : "running",
1572
+ created: true,
1573
+ };
1465
1574
  }
1466
1575
  if (record && !record.detached && (record.status === "running" || record.status === "queued")) {
1467
- throw new Error(`Managed workflow thread "${normalized.thread}" is already running; calls must be sequential.`);
1576
+ throw new Error(`Managed workflow thread "${thread}" is already running; calls must be sequential.`);
1468
1577
  }
1469
1578
  if (!record || record.detached || !record.session) {
1470
1579
  this.managedThreads.delete(threadKey!);
@@ -1485,8 +1594,8 @@ export class AgentManager {
1485
1594
  type: normalized.type,
1486
1595
  description: normalized.description,
1487
1596
  owner: { ...normalized.owner },
1488
- ...(normalized.tier === undefined ? {} : { tier: normalized.tier }),
1489
- ...(normalized.thread === undefined ? {} : { thread: normalized.thread, threadPolicyFingerprint }),
1597
+ ...(identityTier === undefined ? {} : { tier: identityTier }),
1598
+ ...(thread === undefined ? {} : { thread, threadPolicyFingerprint }),
1490
1599
  state: "queued",
1491
1600
  createdAt: now,
1492
1601
  updatedAt: now,
@@ -1518,8 +1627,12 @@ export class AgentManager {
1518
1627
  try {
1519
1628
  this.spawnInternal(pi, ctx, normalized.type, normalized.prompt, {
1520
1629
  ...policy,
1521
- ...(normalized.tier === undefined ? {} : { tier: normalized.tier }),
1522
- ...(normalized.thread === undefined ? {} : { thread: normalized.thread }),
1630
+ [INTERNAL_PARENT_POLICY_SNAPSHOT]: parentPolicySnapshot,
1631
+ // A managed workflow must resolve a tier or fail closed; it may not
1632
+ // silently inherit the parent session's model when nobody named one.
1633
+ requireAgentTier: true,
1634
+ ...(normalized.tier === undefined ? {} : { agentTier: normalized.tier }),
1635
+ ...(thread === undefined ? {} : { thread }),
1523
1636
  description: normalized.description,
1524
1637
  isBackground: true,
1525
1638
  ...callbacks,
@@ -1553,11 +1666,16 @@ export class AgentManager {
1553
1666
  return this.managedResult(scope, true);
1554
1667
  }
1555
1668
 
1556
- /** Forget managed idempotency keys when a logical session is replaced. */
1669
+ /** Forget settled managed idempotency state when a logical session is replaced; retire live keys. */
1557
1670
  resetManagedSpawns(): void {
1558
1671
  this.clearManagedPersistenceRetries();
1672
+ // Keep identities whose provider/session may still be alive after a branch
1673
+ // replacement. Clearing the in-memory tombstone without retaining its key
1674
+ // would let a resumed run allocate the same spawn identity again.
1675
+ this.retireLiveManagedKeys();
1559
1676
  this.managedSpawns.clear();
1560
1677
  this.managedKeysById.clear();
1678
+ this.managedQuarantinedKeys.clear();
1561
1679
  this.managedThreads.clear();
1562
1680
  this.managedThreadPolicies.clear();
1563
1681
  this.managedThreadReservations.clear();
@@ -1639,9 +1757,10 @@ export class AgentManager {
1639
1757
  isolated: internalOverride?.isolated ?? options.isolated,
1640
1758
  inheritContext: internalOverride?.inheritContext ?? options.inheritContext,
1641
1759
  ...(internalOverride ? { [INTERNAL_AGENT_CONFIG_OVERRIDE]: internalOverride } : {}),
1760
+ [INTERNAL_PARENT_POLICY_SNAPSHOT]: options[INTERNAL_PARENT_POLICY_SNAPSHOT],
1642
1761
  thinkingLevel: options.thinkingLevel,
1643
- tier: options.tier,
1644
1762
  agentTier: options.agentTier,
1763
+ requireAgentTier: options.requireAgentTier,
1645
1764
  // Worktree wins for the working dir (the agent must run in the copy —
1646
1765
  // which, with a custom cwd, was created from that target). Config stays
1647
1766
  // with the parent project when a caller-supplied cwd is in play; it must
@@ -1691,31 +1810,21 @@ export class AgentManager {
1691
1810
  depth: record.depth ?? 1,
1692
1811
  maxSubagentDepth: record.maxSubagentDepth,
1693
1812
  },
1694
- onTierResolved: (snapshot) => {
1695
- if (!record.detached) {
1696
- record.invocation = {
1697
- ...(record.invocation ?? {}),
1698
- ...(options.tier === undefined ? {} : { tier: options.tier }),
1699
- thinking: snapshot.thinking,
1700
- tierSnapshot: { ...snapshot },
1701
- };
1702
- this.syncManagedRecord(record, true);
1703
- options.onTierResolved?.(snapshot);
1704
- }
1705
- },
1706
- // Recorded on the same record as the workflow snapshot but under its own
1707
- // field, so a run can carry both without either overwriting the other's
1708
- // account of how its model was chosen.
1709
- onAgentTierResolved: (snapshot) => {
1813
+ onAgentTierResolved: (snapshot, modelLabel) => {
1710
1814
  if (!record.detached) {
1711
1815
  record.invocation = {
1712
1816
  ...(record.invocation ?? {}),
1817
+ // The tier owns model and thinking, so it owns their display too —
1818
+ // including clearing a label a caller pre-computed before the tier
1819
+ // resolved. Without this a tiered spawn shows its tier name and no
1820
+ // model at all, even when the profile pins one.
1821
+ modelName: modelLabel,
1713
1822
  agentTier: snapshot.tier,
1714
1823
  thinking: snapshot.thinking,
1715
1824
  agentTierSnapshot: { ...snapshot },
1716
1825
  };
1717
1826
  this.syncManagedRecord(record, true);
1718
- options.onAgentTierResolved?.(snapshot);
1827
+ options.onAgentTierResolved?.(snapshot, modelLabel);
1719
1828
  }
1720
1829
  },
1721
1830
  onSessionCreated: (session) => {
@@ -2134,6 +2243,7 @@ export class AgentManager {
2134
2243
  ...(record.completedAt === undefined ? {} : { completedAt: record.completedAt }),
2135
2244
  ...(record.result === undefined ? {} : { result: record.result }),
2136
2245
  ...(record.error === undefined ? {} : { error: record.error }),
2246
+ ...(record.invocation ? { invocation: cloneFrozenData(record.invocation) as AgentInvocation } : {}),
2137
2247
  };
2138
2248
  }
2139
2249
 
@@ -2143,6 +2253,9 @@ export class AgentManager {
2143
2253
  record.completedAt = snapshot.completedAt;
2144
2254
  record.result = snapshot.result;
2145
2255
  record.error = snapshot.error;
2256
+ record.invocation = snapshot.invocation
2257
+ ? cloneFrozenData(snapshot.invocation) as AgentInvocation
2258
+ : undefined;
2146
2259
  }
2147
2260
 
2148
2261
  private beginResumeControl(
@@ -3358,6 +3471,8 @@ export class AgentManager {
3358
3471
  this.agents.clear();
3359
3472
  this.managedSpawns.clear();
3360
3473
  this.managedKeysById.clear();
3474
+ this.managedQuarantinedKeys.clear();
3475
+ this.managedRetiredKeys.clear();
3361
3476
  this.managedThreads.clear();
3362
3477
  this.managedThreadPolicies.clear();
3363
3478
  this.managedThreadReservations.clear();