@telora/daemon 0.18.25 → 0.18.33

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.
@@ -271,69 +271,18 @@ export function buildAgentArgs(backend, config, worktreePath, pipelineConfig, re
271
271
  : backend.buildSpawnArgs(base);
272
272
  }
273
273
  /**
274
- * Spawn a focus team to execute all deliveries in a focus.
274
+ * Phase 1: fetch all spawn context (deliveries / issues / product-context /
275
+ * deployment-profile / loop-context) in one Promise.all, then resolve the PRD
276
+ * milestone push-down. Returns null when the context fetch rejects -- the
277
+ * caller treats null as "tear down the freshly-inserted team and return"
278
+ * (activeTeams.delete). The PRD push-down deliberately propagates on throw,
279
+ * matching the pre-extraction behavior (it sits after the try/catch).
275
280
  *
276
- * Creates a single Agent Team (lead process) that reads all deliveries
277
- * and issues, builds a task DAG, and coordinates worker execution.
281
+ * Behavior-preserving extraction of the original inline block; the log lines,
282
+ * the deployment-profile .catch fallback, the loop-context try/catch fallback,
283
+ * and the manifest accumulation order are byte-identical.
278
284
  */
279
- export async function spawnFocusTeam(params) {
280
- const { config, focusId, focusName, role, pipelineConfig, readOnly = false, claudeSessionIds, engineOverride, localModelOverride, orgDefaultEngine, forceSpawn = false } = params;
281
- const activeTeams = getActiveTeams();
282
- // The agent backend is resolved per-pass below, once the spawn's lineage
283
- // (coding vs review) is known -- see resolveBackend() after lineage resolution.
284
- // Prevent double-spawn
285
- if (activeTeams.has(focusId)) {
286
- console.warn(`[focus-executor] Team already active for focus "${focusName}", skipping spawn`);
287
- return;
288
- }
289
- if (await applyRespawnGuard(focusId, focusName))
290
- return;
291
- const executionConfig = deriveExecutionConfig(pipelineConfig);
292
- const branchName = generateFocusBranchName(role, focusName, focusId);
293
- // Initialize team state
294
- const teamState = {
295
- focusId,
296
- focusName,
297
- roleId: role.id,
298
- roleName: role.name,
299
- organizationId: config.organizationId,
300
- productId: config.productId,
301
- executionConfig,
302
- pipelineConfig,
303
- startedAt: new Date(),
304
- phase: 'initializing',
305
- knownDeliveryIds: new Set(),
306
- mergedDeliveryIds: new Set(),
307
- planningPhase: false,
308
- shutdownReason: null,
309
- deliveryStageIds: new Map(),
310
- leadSessionId: null,
311
- leadPid: null,
312
- leadStdin: null,
313
- branchName,
314
- worktreePath: null,
315
- resolvingMergeConflict: false,
316
- readOnly,
317
- completionDetector: null,
318
- claudeSessionId: null,
319
- lastProgressSnapshot: null,
320
- noProgressCycles: 0,
321
- lastConsumedDirectiveHash: null,
322
- sessionType: 'coding',
323
- lineage: 'coding',
324
- };
325
- activeTeams.set(focusId, teamState);
326
- const currentProduct = config.products.find(p => p.id === config.productId);
327
- const productTag = config.products.length > 1 && currentProduct ? ` [${productLabel(currentProduct)}]` : '';
328
- console.log(`[focus-executor] Spawning team for focus "${focusName}"${readOnly ? ' [READ-ONLY]' : ''}${productTag}`);
329
- console.log(` Role: ${role.name}`);
330
- console.log(` Model: ${pipelineConfig?.model ?? '(CLI default)'}`);
331
- console.log(` Max workers: ${executionConfig.maxWorkers}`);
332
- console.log(` Branch: ${branchName}`);
333
- if (config.products.length > 1) {
334
- console.log(` Product: ${currentProduct ? productLabel(currentProduct) : config.productId.slice(0, 8)}`);
335
- console.log(` Repo: ${config.repoPath}`);
336
- }
285
+ export async function fetchSpawnContext(config, focusId, focusName) {
337
286
  // Accumulates the assembly manifest (one entry per resolved source) across
338
287
  // every recipe composed for this spawn, persisted after the session exists.
339
288
  const assemblyManifest = [];
@@ -376,8 +325,7 @@ export async function spawnFocusTeam(params) {
376
325
  }
377
326
  catch (err) {
378
327
  console.error(`[focus-executor] Failed to fetch context for focus "${focusName}":`, err.message);
379
- activeTeams.delete(focusId);
380
- return;
328
+ return null;
381
329
  }
382
330
  // PRD push-down: if this focus was armed by a PRD, resolve the parent PRD's
383
331
  // prd.context (milestone goal/expansion/vision + frontier position) and carry
@@ -398,6 +346,21 @@ export async function spawnFocusTeam(params) {
398
346
  prdMilestoneContext = milestoneResult.content;
399
347
  assemblyManifest.push(...milestoneResult.manifest);
400
348
  }
349
+ return { deliveries, issues, productContextDocs, deploymentProfileSnapshot, loopContext, prdMilestoneContext, assemblyManifest };
350
+ }
351
+ /**
352
+ * Phase 2: consume any pending spawn directive, enforce the rank-ordered
353
+ * pre-spawn guard, and record known-delivery bookkeeping on the team state.
354
+ *
355
+ * Mutates `teamState` exactly as the original inline block did
356
+ * (lastConsumedDirectiveHash, sessionType, planningPhase, knownDeliveryIds,
357
+ * deliveryStageIds, mergedDeliveryIds) and emits the same log lines. Returns
358
+ * 'skip' for the findActionableDeliveries skip case (the caller owns the
359
+ * activeTeams.delete + return so the team-state map stays the orchestrator's
360
+ * responsibility). Behavior byte-identical.
361
+ */
362
+ export function resolveActionableDeliveries(args) {
363
+ const { focusId, focusName, deliveries, issues, forceSpawn, teamState } = args;
401
364
  // Check for pending spawn directive (e.g., review stage spawn).
402
365
  // Record its content hash on team state so the spawn-guard in
403
366
  // executeSpawnDirective can detect divergent directives within the 60s
@@ -426,8 +389,7 @@ export async function spawnFocusTeam(params) {
426
389
  else {
427
390
  const decision = findActionableDeliveries(deliveries, focusName);
428
391
  if (decision.kind === 'skip') {
429
- activeTeams.delete(focusId);
430
- return;
392
+ return { kind: 'skip' };
431
393
  }
432
394
  if (decision.kind === 'planning') {
433
395
  isPlanningSpawn = true;
@@ -456,126 +418,28 @@ export async function spawnFocusTeam(params) {
456
418
  console.log(` Deliveries: ${deliveries.length} (${actionableDeliveries.length} queued)`);
457
419
  }
458
420
  console.log(` Issues: ${issues.length}`);
459
- // Ensure log directory exists
460
- if (!existsSync(config.logDir)) {
461
- mkdirSync(config.logDir, { recursive: true, mode: 0o700 });
462
- }
463
- // Reuse persistent focus worktree (created by ensureFocusWorktrees in poll loop)
464
- let worktreePath;
465
- const existingWorktree = getFocusWorktree(focusId);
466
- if (existingWorktree) {
467
- worktreePath = existingWorktree.worktreePath;
468
- // Rebase onto integration to pick up latest changes from other focuses
469
- const rebaseResult = runGitSync(['rebase', config.integrationBranch], worktreePath);
470
- if (!rebaseResult.success) {
471
- runGitSync(['rebase', '--abort'], worktreePath);
472
- console.warn(`[focus-executor] Rebase failed for "${focusName}", continuing with existing state`);
473
- }
474
- console.log(` Worktree (reused): ${worktreePath}`);
475
- // Re-assert read-only audit guards on the reused worktree. ensureFocusWorktrees
476
- // installs them at provisioning time, but a read-only focus may reuse a
477
- // persistent worktree across daemon restarts -- re-applying here (idempotent)
478
- // guarantees the deny overlay + pre-commit hook are present before spawn.
479
- if (readOnly) {
480
- installReadOnlyAuditGuards(worktreePath);
481
- console.log(` Re-asserted read-only audit guards (reused worktree)`);
482
- }
483
- }
484
- else {
485
- // Guard: can't create worktrees in a repo with no commits
486
- if (!repoHasCommits(config.repoPath)) {
487
- console.warn(`[focus-executor] Repository has no commits -- cannot create worktree for "${focusName}"`);
488
- activeTeams.delete(focusId);
489
- recordFocusTeardown(focusId);
490
- return;
491
- }
492
- // Fallback: worktree doesn't exist yet (race condition or first poll)
493
- console.warn(`[focus-executor] No persistent worktree for "${focusName}", creating inline`);
494
- try {
495
- worktreePath = await createWorktree(config, branchName);
496
- setFocusWorktree(focusId, {
497
- focusId,
498
- focusName,
499
- worktreePath,
500
- branchName,
501
- createdAt: new Date(),
502
- });
503
- console.log(` Worktree (created inline): ${worktreePath}`);
504
- // Install read-only audit guards (pre-commit hook + write-deny overlay)
505
- if (readOnly) {
506
- installReadOnlyAuditGuards(worktreePath);
507
- console.log(` Installed read-only audit guards (read-only mode)`);
508
- }
509
- }
510
- catch (err) {
511
- console.error(`[focus-executor] Failed to create worktree for focus "${focusName}":`, err instanceof Error ? err.message : String(err));
512
- activeTeams.delete(focusId);
513
- recordFocusTeardown(focusId);
514
- return;
515
- }
516
- }
517
- teamState.worktreePath = worktreePath;
518
- // Create session record for the team lead
519
- let session;
520
- try {
521
- session = await createSession({
522
- organizationId: config.organizationId,
523
- roleId: role.id,
524
- issueId: null,
525
- focusId,
526
- branchName,
527
- sessionType: pendingDirective?.sessionType ?? 'coding',
528
- });
529
- teamState.leadSessionId = session.id;
530
- // Confirm the session is durably persisted so its telemetry (tagged with
531
- // telora.session_id = session.id at spawn) passes the writer's emission
532
- // gate. Emission paths carrying an unpersisted id (e.g. the audit assessor's
533
- // raw Claude session id) have that id nulled instead of FK-violating.
534
- markSessionPersisted(session.id);
535
- }
536
- catch (err) {
537
- console.error(`[focus-executor] Failed to create session for focus "${focusName}":`, err.message);
538
- // Worktree is focus-owned and persists even if session creation fails
539
- activeTeams.delete(focusId);
540
- return;
541
- }
542
- recordActivity();
543
- // Build the team lead prompt.
544
- // Priority: pending spawn directive > current stage directive > legacy full prompt.
545
- // When a directive exists, compose: role framework + assembled directive content.
546
- // This ensures the workflow stage controls what the agent does.
547
- // If this focus has a recorded review_requested_at and there are open
548
- // review-filed issues, compute the count so the team prompt includes the
549
- // remediation directive. Best-effort -- a fetch failure leaves the count
550
- // at zero and the directive is omitted (the team behaves as a normal
551
- // execution team rather than a remediation team).
552
- let reviewFiledOpenCount = 0;
553
- let priorFindings = [];
554
- let focusDescription = null;
555
- try {
556
- const activeFocuses = await fetchActiveFocusesForReviewState(config.organizationId, config.productId);
557
- const current = activeFocuses.find(f => f.focus_id === focusId);
558
- const reviewRequestedAt = current?.review_requested_at ?? null;
559
- if (reviewRequestedAt) {
560
- priorFindings = filterReviewFiledIssues(issues, reviewRequestedAt);
561
- reviewFiledOpenCount = priorFindings.length;
562
- }
563
- focusDescription = current?.focus_description ?? null;
564
- }
565
- catch (err) {
566
- console.debug(`[focus-executor] Could not compute review-filed open count for "${focusName}":`, err.message);
567
- }
568
- // --- Phase A: resolve the directive body + spawn metadata (no prompt yet) ---
569
- // The engine backend is resolved per-pass (Phase B) before the prompt is
570
- // built (Phase C), so the lead prompt's orchestration vocabulary matches the
571
- // engine that will actually run this pass.
421
+ return { kind: 'proceed', pendingDirective, actionableDeliveries, isPlanningSpawn };
422
+ }
423
+ /**
424
+ * Phase A: resolve the team-lead directive body + spawn metadata (no prompt
425
+ * yet). Priority: pending spawn directive > current-stage directive > legacy
426
+ * full prompt. Pushes any assembled directive manifest entries onto the shared
427
+ * `assemblyManifest` accumulator.
428
+ *
429
+ * Behavior-preserving extraction of the original inline Phase A block -- the
430
+ * "Using pending spawn directive" / "Using stage directive" log lines, the
431
+ * "LEGACY PROMPT FALLBACK" warning and every fallbackReason string, and the
432
+ * directive-manifest accumulation are byte-identical.
433
+ */
434
+ export async function resolveDirectiveOrFallback(args) {
435
+ const { config, focusId, focusName, worktreePath, pendingDirective, assemblyManifest } = args;
572
436
  let directiveBody = null; // appended after the role framework
573
437
  let useLegacyFullPrompt = false; // legacy full prompt (no directive)
574
438
  let directiveModel = null;
575
439
  // Workflow stage active at spawn -- stamped onto telemetry as the .stage lever.
576
440
  let spawnStageName = null;
577
441
  // Lineage + continuity declared by whatever is firing this spawn (pending
578
- // directive, current stage directive, or none). Resolved uniformly below
442
+ // directive, current stage directive, or none). Resolved uniformly later
579
443
  // (INJ-B). For a pending directive these are already resolved upstream, so
580
444
  // resolveLineageSpec is idempotent.
581
445
  let spawnDirectiveDeclared = null;
@@ -621,7 +485,7 @@ export async function spawnFocusTeam(params) {
621
485
  else {
622
486
  spawnStageName = currentStage.name ?? null;
623
487
  // The current stage's directive declares its own lineage + continuity
624
- // (INJ-B); resolved uniformly below.
488
+ // (INJ-B); resolved uniformly later.
625
489
  spawnDirectiveDeclared = currentStage.agentDirective;
626
490
  const { content: directiveContent, manifest: directiveManifest } = await assembleDirectiveContentWithManifest(config, focusId, currentStage.agentDirective, worktreePath);
627
491
  if (directiveContent.trim()) {
@@ -647,25 +511,17 @@ export async function spawnFocusTeam(params) {
647
511
  useLegacyFullPrompt = true;
648
512
  }
649
513
  }
650
- // --- Phase B: resolve the session lineage (pass) and the engine backend ---
651
- // The lineage is the session-id map slot; sessionType stays the review-exit
652
- // gate signal, derived from the lineage (INJ-B). The engine is then resolved
653
- // for this pass via the single resolveBackend() selection point (D3).
654
- const spawnSpec = resolveLineageSpec({ declared: spawnDirectiveDeclared, stageName: spawnStageName });
655
- teamState.lineage = spawnSpec.lineage;
656
- teamState.sessionType = spawnSpec.lineage === 'review' ? 'review' : 'coding';
657
- const enginePass = spawnSpec.lineage === 'review' ? 'review' : 'coding';
658
- const resolvedEngine = resolveBackend({
659
- focusOverride: engineOverride,
660
- pass: enginePass,
661
- productDefault: config.defaultCodingEngine,
662
- orgDefault: orgDefaultEngine,
663
- });
664
- const backend = getBackend(resolvedEngine.engineId);
665
- console.log(`[focus-executor] Engine for "${focusName}" pass=${enginePass}: ` +
666
- `${resolvedEngine.engineId} (source: ${resolvedEngine.source})`);
667
- // --- Phase C: build the prompt with the resolved engine's vocabulary ---
668
- const promptContext = {
514
+ return { directiveBody, directiveModel, spawnStageName, spawnDirectiveDeclared, useLegacyFullPrompt };
515
+ }
516
+ /**
517
+ * Phase C input: assemble the FocusTeamPromptContext threaded into both the
518
+ * directive and legacy prompt builders. Pure factory -- byte-identical to the
519
+ * original inline object literal; extracted so the orchestrator reads as ordered
520
+ * phase calls rather than inlining the context shape.
521
+ */
522
+ export function buildFocusPromptContext(args) {
523
+ const { config, focusId, focusName, focusDescription, deliveries, issues, executionConfig, pipelineConfig, productContextDocs, deploymentProfileSnapshot, readOnly, loopContext, reviewFiledOpenCount, engineId, } = args;
524
+ return {
669
525
  focusId,
670
526
  focusName,
671
527
  focusDescription,
@@ -680,8 +536,17 @@ export async function spawnFocusTeam(params) {
680
536
  readOnly,
681
537
  loopContext,
682
538
  reviewFiledOpenCount,
683
- engineId: backend.id,
539
+ engineId,
684
540
  };
541
+ }
542
+ /**
543
+ * Phase C: compose the final team-lead message -- role framework + remediation
544
+ * framing + directive body (or the legacy full focus prompt), then the PRD
545
+ * milestone push-down appended last so it reaches BOTH prompt paths. Pure +
546
+ * testable. Byte-identical to the original inline composition.
547
+ */
548
+ export function buildSpawnPrompt(args) {
549
+ const { role, promptContext, useLegacyFullPrompt, directiveBody, prdMilestoneContext } = args;
685
550
  let prompt;
686
551
  if (!useLegacyFullPrompt && directiveBody !== null) {
687
552
  const framing = buildRemediationFramingSection(directiveBody).join('\n');
@@ -700,121 +565,398 @@ export async function spawnFocusTeam(params) {
700
565
  if (prdMilestoneContext.trim()) {
701
566
  prompt += '\n\n' + prdMilestoneContext;
702
567
  }
703
- // Set up log file streams
704
- const logs = setupTeamLogStreams(config.logDir, branchName, focusName);
705
- // Build args and env.
706
- // Resolve the resume id from the focus's per-lineage session map (INJ-A),
707
- // keyed by the spawn's declared lineage, honoring its declared continuity
708
- // policy (INJ-B). continuity=fresh -> null; resume -> the lineage's stored id.
709
- const resumeId = resolveResumeId({
710
- sessionMap: claudeSessionIds,
711
- lineage: spawnSpec.lineage,
712
- continuity: spawnSpec.continuity,
568
+ return prompt;
569
+ }
570
+ /**
571
+ * Phase: resolve the per-pass local-model routing for a codex spawn.
572
+ *
573
+ * Wraps the fetch (getLocalModel / getLocalModelSecret) + the pure decision
574
+ * (selectLocalModelRef -> decideLocalModelRouting) and, on 'route', provisions
575
+ * the per-model CODEX_HOME (writeLocalModelCodexHome) -- returning the codexHome
576
+ * the caller points the spawn at. The key VALUE comes from Telora (D8), never a
577
+ * host env var. Behavior byte-identical to the original inline block; only the
578
+ * apply-side (session-terminal write on skip, env injection on route) stays in
579
+ * the caller.
580
+ */
581
+ export async function resolveLocalModelConfig(args, deps = {}) {
582
+ const getModel = deps.getLocalModel ?? getLocalModel;
583
+ const getSecret = deps.getLocalModelSecret ?? getLocalModelSecret;
584
+ const writeCodexHome = deps.writeLocalModelCodexHome ?? writeLocalModelCodexHome;
585
+ const localModelRef = selectLocalModelRef(args.engineId, args.enginePass, args.localModelOverride);
586
+ // getLocalModel + getLocalModelSecret (the DB calls) run only when a ref
587
+ // exists; decideLocalModelRouting is the pure decision over their results.
588
+ const coords = localModelRef ? await getModel(localModelRef) : null;
589
+ const apiKey = localModelRef ? await getSecret(localModelRef) : null;
590
+ const routing = decideLocalModelRouting(localModelRef, coords, apiKey);
591
+ switch (routing.kind) {
592
+ case 'none':
593
+ return { kind: 'none' };
594
+ case 'fallback':
595
+ return { kind: 'fallback', ref: localModelRef };
596
+ case 'skip':
597
+ return { kind: 'skip', coords: routing.coords };
598
+ case 'route': {
599
+ const { codexHome } = writeCodexHome(routing.coords.id, {
600
+ baseUrl: routing.coords.baseUrl,
601
+ model: routing.coords.model,
602
+ wireApi: routing.coords.wireApi ?? undefined,
603
+ });
604
+ return {
605
+ kind: 'route',
606
+ codexHome,
607
+ codexModel: routing.coords.model,
608
+ apiKey: routing.apiKey,
609
+ coords: routing.coords,
610
+ };
611
+ }
612
+ }
613
+ }
614
+ /**
615
+ * The terminal session-update payload written when a local-model pass is skipped
616
+ * for a missing bearer key (fail-closed). Pure -- the caller adds `ended_at` and
617
+ * calls updateSession. Exported so the skip exit_category contract is unit-pinned.
618
+ */
619
+ export function localModelKeyUnsetSessionUpdate(coords) {
620
+ return {
621
+ status: 'failed',
622
+ exit_reason: `No API key configured in Telora for local model "${coords.name}"`,
623
+ exit_category: 'local_model_key_unset',
624
+ };
625
+ }
626
+ /**
627
+ * The terminal session-update payload written when the resource governor denies a
628
+ * slot for this spawn. Pure -- the caller adds `ended_at` and calls updateSession.
629
+ * Exported so the governor-denied exit_category contract is unit-pinned (the
630
+ * apply-side -- updateSession + governor.releaseSlot + activeTeams.delete -- stays
631
+ * in spawnTeamProcess, exercised by spawn-path-unified.test.ts).
632
+ */
633
+ export function governorDeniedSessionUpdate() {
634
+ return {
635
+ status: 'failed',
636
+ exit_reason: 'Governor denied slot',
637
+ exit_category: 'governor_denied',
638
+ };
639
+ }
640
+ /**
641
+ * The terminal session-update payload written when a required OS sandbox cannot
642
+ * initialize and the spawn is refused (fail-closed). Pure -- the caller adds
643
+ * `ended_at` and calls updateSession. Exported so the sandbox-unavailable
644
+ * exit_category contract is unit-pinned.
645
+ */
646
+ export function sandboxUnavailableSessionUpdate(message) {
647
+ return {
648
+ status: 'failed',
649
+ exit_reason: message,
650
+ exit_category: 'sandbox_unavailable',
651
+ };
652
+ }
653
+ /**
654
+ * Log the spawn header (focus / role / model / workers / branch, plus the
655
+ * product line in multi-product mode). Side-effect-only; byte-identical to the
656
+ * original inline logging.
657
+ */
658
+ export function logSpawnHeader(args) {
659
+ const { config, focusName, role, pipelineConfig, executionConfig, branchName, readOnly } = args;
660
+ const currentProduct = config.products.find(p => p.id === config.productId);
661
+ const productTag = config.products.length > 1 && currentProduct ? ` [${productLabel(currentProduct)}]` : '';
662
+ console.log(`[focus-executor] Spawning team for focus "${focusName}"${readOnly ? ' [READ-ONLY]' : ''}${productTag}`);
663
+ console.log(` Role: ${role.name}`);
664
+ console.log(` Model: ${pipelineConfig?.model ?? '(CLI default)'}`);
665
+ console.log(` Max workers: ${executionConfig.maxWorkers}`);
666
+ console.log(` Branch: ${branchName}`);
667
+ if (config.products.length > 1) {
668
+ console.log(` Product: ${currentProduct ? productLabel(currentProduct) : config.productId.slice(0, 8)}`);
669
+ console.log(` Repo: ${config.repoPath}`);
670
+ }
671
+ }
672
+ /**
673
+ * Build the initial FocusTeamState for a spawn. Pure factory -- no side effects;
674
+ * the caller inserts it into the activeTeams map.
675
+ */
676
+ export function createInitialTeamState(args) {
677
+ const { focusId, focusName, role, config, executionConfig, pipelineConfig, branchName, readOnly } = args;
678
+ return {
679
+ focusId,
680
+ focusName,
681
+ roleId: role.id,
682
+ roleName: role.name,
683
+ organizationId: config.organizationId,
684
+ productId: config.productId,
685
+ executionConfig,
686
+ pipelineConfig,
687
+ startedAt: new Date(),
688
+ phase: 'initializing',
689
+ knownDeliveryIds: new Set(),
690
+ mergedDeliveryIds: new Set(),
691
+ planningPhase: false,
692
+ shutdownReason: null,
693
+ deliveryStageIds: new Map(),
694
+ leadSessionId: null,
695
+ leadPid: null,
696
+ leadStdin: null,
697
+ branchName,
698
+ worktreePath: null,
699
+ resolvingMergeConflict: false,
700
+ readOnly,
701
+ completionDetector: null,
702
+ claudeSessionId: null,
703
+ lastProgressSnapshot: null,
704
+ noProgressCycles: 0,
705
+ lastConsumedDirectiveHash: null,
706
+ sessionType: 'coding',
707
+ lineage: 'coding',
708
+ };
709
+ }
710
+ /**
711
+ * Phase: resolve the focus worktree to spawn into. Reuses the persistent
712
+ * worktree (rebasing onto integration + re-asserting read-only guards) when one
713
+ * exists, else creates one inline. Returns null when the spawn cannot proceed
714
+ * (repo has no commits, or inline creation failed) -- owning its own teardown
715
+ * (activeTeams.delete + recordFocusTeardown) so the caller just returns.
716
+ * Behavior byte-identical to the original inline block.
717
+ */
718
+ export async function resolveSpawnWorktree(args) {
719
+ const { config, focusId, focusName, branchName, readOnly } = args;
720
+ const activeTeams = getActiveTeams();
721
+ // Reuse persistent focus worktree (created by ensureFocusWorktrees in poll loop)
722
+ const existingWorktree = getFocusWorktree(focusId);
723
+ if (existingWorktree) {
724
+ const worktreePath = existingWorktree.worktreePath;
725
+ // Rebase onto integration to pick up latest changes from other focuses
726
+ const rebaseResult = runGitSync(['rebase', config.integrationBranch], worktreePath);
727
+ if (!rebaseResult.success) {
728
+ runGitSync(['rebase', '--abort'], worktreePath);
729
+ console.warn(`[focus-executor] Rebase failed for "${focusName}", continuing with existing state`);
730
+ }
731
+ console.log(` Worktree (reused): ${worktreePath}`);
732
+ // Re-assert read-only audit guards on the reused worktree. ensureFocusWorktrees
733
+ // installs them at provisioning time, but a read-only focus may reuse a
734
+ // persistent worktree across daemon restarts -- re-applying here (idempotent)
735
+ // guarantees the deny overlay + pre-commit hook are present before spawn.
736
+ if (readOnly) {
737
+ installReadOnlyAuditGuards(worktreePath);
738
+ console.log(` Re-asserted read-only audit guards (reused worktree)`);
739
+ }
740
+ return worktreePath;
741
+ }
742
+ // Guard: can't create worktrees in a repo with no commits
743
+ if (!repoHasCommits(config.repoPath)) {
744
+ console.warn(`[focus-executor] Repository has no commits -- cannot create worktree for "${focusName}"`);
745
+ activeTeams.delete(focusId);
746
+ recordFocusTeardown(focusId);
747
+ return null;
748
+ }
749
+ // Fallback: worktree doesn't exist yet (race condition or first poll)
750
+ console.warn(`[focus-executor] No persistent worktree for "${focusName}", creating inline`);
751
+ try {
752
+ const worktreePath = await createWorktree(config, branchName);
753
+ setFocusWorktree(focusId, {
754
+ focusId,
755
+ focusName,
756
+ worktreePath,
757
+ branchName,
758
+ createdAt: new Date(),
759
+ });
760
+ console.log(` Worktree (created inline): ${worktreePath}`);
761
+ // Install read-only audit guards (pre-commit hook + write-deny overlay)
762
+ if (readOnly) {
763
+ installReadOnlyAuditGuards(worktreePath);
764
+ console.log(` Installed read-only audit guards (read-only mode)`);
765
+ }
766
+ return worktreePath;
767
+ }
768
+ catch (err) {
769
+ console.error(`[focus-executor] Failed to create worktree for focus "${focusName}":`, err instanceof Error ? err.message : String(err));
770
+ activeTeams.delete(focusId);
771
+ recordFocusTeardown(focusId);
772
+ return null;
773
+ }
774
+ }
775
+ /**
776
+ * Phase: create the team-lead session row + register it with the telemetry
777
+ * persistence gate, stamping its id onto the team state. Returns null when
778
+ * createSession fails (owning its own activeTeams.delete teardown). The worktree
779
+ * is focus-owned and persists even when session creation fails.
780
+ */
781
+ export async function createTeamSession(args) {
782
+ const { config, role, focusId, focusName, branchName, pendingDirective, teamState } = args;
783
+ try {
784
+ const session = await createSession({
785
+ organizationId: config.organizationId,
786
+ roleId: role.id,
787
+ issueId: null,
788
+ focusId,
789
+ branchName,
790
+ sessionType: pendingDirective?.sessionType ?? 'coding',
791
+ });
792
+ teamState.leadSessionId = session.id;
793
+ // Confirm the session is durably persisted so its telemetry (tagged with
794
+ // telora.session_id = session.id at spawn) passes the writer's emission
795
+ // gate. Emission paths carrying an unpersisted id (e.g. the audit assessor's
796
+ // raw Claude session id) have that id nulled instead of FK-violating.
797
+ markSessionPersisted(session.id);
798
+ return session;
799
+ }
800
+ catch (err) {
801
+ console.error(`[focus-executor] Failed to create session for focus "${focusName}":`, err.message);
802
+ // Worktree is focus-owned and persists even if session creation fails
803
+ getActiveTeams().delete(focusId);
804
+ return null;
805
+ }
806
+ }
807
+ /**
808
+ * Phase: best-effort review context. When the focus has a recorded
809
+ * review_requested_at, count the open review-filed issues (the remediation
810
+ * directive's working set) and carry the prior findings; also resolve the focus
811
+ * description. A fetch failure leaves the count at zero (normal execution team).
812
+ */
813
+ export async function resolveReviewContext(args) {
814
+ const { config, focusId, focusName, issues } = args;
815
+ let reviewFiledOpenCount = 0;
816
+ let priorFindings = [];
817
+ let focusDescription = null;
818
+ try {
819
+ const activeFocuses = await fetchActiveFocusesForReviewState(config.organizationId, config.productId);
820
+ const current = activeFocuses.find(f => f.focus_id === focusId);
821
+ const reviewRequestedAt = current?.review_requested_at ?? null;
822
+ if (reviewRequestedAt) {
823
+ priorFindings = filterReviewFiledIssues(issues, reviewRequestedAt);
824
+ reviewFiledOpenCount = priorFindings.length;
825
+ }
826
+ focusDescription = current?.focus_description ?? null;
827
+ }
828
+ catch (err) {
829
+ console.debug(`[focus-executor] Could not compute review-filed open count for "${focusName}":`, err.message);
830
+ }
831
+ return { reviewFiledOpenCount, priorFindings, focusDescription };
832
+ }
833
+ /**
834
+ * Phase B: resolve the session lineage (pass) and the engine backend for it,
835
+ * stamping lineage + sessionType onto the team state. Returns the lineage spec,
836
+ * the derived engine pass, the resolved engine, and the backend object.
837
+ */
838
+ export function resolveEngineForPass(args) {
839
+ const { spawnDirectiveDeclared, spawnStageName, engineOverride, config, orgDefaultEngine, focusName, teamState } = args;
840
+ const spawnSpec = resolveLineageSpec({ declared: spawnDirectiveDeclared, stageName: spawnStageName });
841
+ teamState.lineage = spawnSpec.lineage;
842
+ teamState.sessionType = spawnSpec.lineage === 'review' ? 'review' : 'coding';
843
+ const enginePass = spawnSpec.lineage === 'review' ? 'review' : 'coding';
844
+ const resolvedEngine = resolveBackend({
845
+ focusOverride: engineOverride,
846
+ pass: enginePass,
847
+ productDefault: config.defaultCodingEngine,
848
+ orgDefault: orgDefaultEngine,
713
849
  });
850
+ const backend = getBackend(resolvedEngine.engineId);
851
+ console.log(`[focus-executor] Engine for "${focusName}" pass=${enginePass}: ` +
852
+ `${resolvedEngine.engineId} (source: ${resolvedEngine.source})`);
853
+ return { spawnSpec, enginePass, resolvedEngine, backend };
854
+ }
855
+ /**
856
+ * Phase: resolve the per-product CODEX_HOME default, apply per-pass local-model
857
+ * routing (fallback / skip / route), select the effective model, and build the
858
+ * CLI args. Returns 'skip' (fail-closed, no key) for the caller to finalize, or
859
+ * 'ready' with the args + the model/env bits the spawn needs. Byte-identical to
860
+ * the original inline local-model + model-selection + args blocks.
861
+ */
862
+ export async function resolveSpawnModel(args) {
863
+ const { resolvedEngine, enginePass, localModelOverride, config, directiveModel, pipelineConfig, backend, worktreePath, resumeId, role, readOnly, focusName, } = args;
714
864
  // Resolve the per-product isolated CODEX_HOME for this spawn (undefined for
715
865
  // claude engines and cloud products with no provisioned codexHome -- those
716
866
  // spawns are byte-for-byte unaffected).
717
867
  let spawnCodexHome = codexHomeForSpawn(resolvedEngine.engineId, config);
718
868
  let spawnCodexModel = config.codexModel;
719
- // Per-pass local model routing (D3): if this codex pass selected a registered
720
- // local model, resolve it to coordinates, provision a per-model CODEX_HOME,
721
- // and route the spawn there -- overriding the per-product codexHome/codexModel
722
- // for this pass only. Absent ref => the per-product default above is kept, so
723
- // existing spawns are unaffected. Coding and review can resolve different
724
- // models within one focus because resolution is keyed on enginePass.
725
- const localModelRef = selectLocalModelRef(resolvedEngine.engineId, enginePass, localModelOverride);
726
- // getLocalModel + getLocalModelSecret (the DB calls) stay here;
727
- // decideLocalModelRouting is the pure decision over their results. Both are
728
- // fetched only when a ref exists. The key VALUE is fetched from Telora (D8) --
729
- // no host env var -- and injected into the spawn env below.
730
- const localModelCoords = localModelRef ? await getLocalModel(localModelRef) : null;
731
- const localModelApiKey = localModelRef ? await getLocalModelSecret(localModelRef) : null;
732
- const localModelRouting = decideLocalModelRouting(localModelRef, localModelCoords, localModelApiKey);
869
+ // Per-pass local model routing: if this codex pass selected a registered local
870
+ // model, resolve it to coordinates, provision a per-model CODEX_HOME, and route
871
+ // the spawn there -- overriding the per-product codexHome/codexModel for this
872
+ // pass only. Absent ref => the per-product default above is kept.
873
+ const localModelConfig = await resolveLocalModelConfig({
874
+ engineId: resolvedEngine.engineId,
875
+ enginePass,
876
+ localModelOverride,
877
+ });
733
878
  // The Telora-resolved key VALUE injected into the spawn env (under the fixed
734
879
  // TELORA_LOCAL_API_KEY name) for a routed local-model pass; null otherwise.
735
880
  let spawnLocalModelKey = null;
736
- if (localModelRouting.kind === 'fallback') {
881
+ if (localModelConfig.kind === 'fallback') {
737
882
  console.warn(`[focus-executor] Focus "${focusName}" ${enginePass} pass selected local model ` +
738
- `${(localModelRef ?? '').slice(0, 8)} but it is not in the registry; falling back to the ` +
883
+ `${(localModelConfig.ref ?? '').slice(0, 8)} but it is not in the registry; falling back to the ` +
739
884
  `per-product default model.`);
740
885
  }
741
- else if (localModelRouting.kind === 'skip') {
742
- const { coords } = localModelRouting;
743
- // Fail closed: never spawn a local-model pass against a server with no bearer
744
- // key configured in Telora. Mark the already-created session terminal
745
- // (mirroring the governor-denied / sandbox-unavailable paths so the row does
746
- // not linger non-terminal), clean up the registered team state, and skip this
747
- // spawn (a later poll retries once an admin sets the key under Settings ->
748
- // Local Models). Surfaced loudly so the misconfiguration is actionable.
749
- console.error(`[focus-executor] Cannot route "${focusName}" ${enginePass} pass to local model ` +
750
- `"${coords.name}" (${coords.model} @ ${coords.baseUrl}): no API key is configured for it ` +
751
- `in Telora. Set the key under Settings -> Local Models and re-run; skipping spawn.`);
752
- await updateSession(session.id, {
753
- status: 'failed',
754
- exit_reason: `No API key configured in Telora for local model "${coords.name}"`,
755
- exit_category: 'local_model_key_unset',
756
- ended_at: new Date().toISOString(),
757
- }).catch(updateErr => {
758
- console.warn(`[focus-executor] Failed to update session after local-model key-unset:`, updateErr.message);
759
- });
760
- activeTeams.delete(focusId);
761
- return;
886
+ else if (localModelConfig.kind === 'skip') {
887
+ return { kind: 'skip', coords: localModelConfig.coords };
762
888
  }
763
- else if (localModelRouting.kind === 'route') {
764
- const { coords, apiKey } = localModelRouting;
765
- const { codexHome } = writeLocalModelCodexHome(coords.id, {
766
- baseUrl: coords.baseUrl,
767
- model: coords.model,
768
- wireApi: coords.wireApi ?? undefined,
769
- });
889
+ else if (localModelConfig.kind === 'route') {
890
+ const { codexHome, codexModel, apiKey, coords } = localModelConfig;
770
891
  spawnCodexHome = codexHome;
771
- spawnCodexModel = coords.model;
892
+ spawnCodexModel = codexModel;
772
893
  spawnLocalModelKey = apiKey;
773
894
  console.log(`[focus-executor] Routing "${focusName}" ${enginePass} pass to local model ` +
774
895
  `"${coords.name}" (${coords.model} @ ${coords.baseUrl}); CODEX_HOME=${codexHome}, ` +
775
896
  `key from Telora (env ${TELORA_LOCAL_API_KEY_ENV}).`);
776
897
  }
777
898
  // Model selection: focus-selected pass model > directive fallback.
778
- // resolveEffectiveModel is exported for unit testing.
779
899
  const focusSelectedModel = selectPipelineModelForSpawn(resolvedEngine.engineId, spawnCodexHome, pipelineConfig?.model);
780
900
  const effectiveDirectiveModel = selectDirectiveModelForSpawn(resolvedEngine.engineId, directiveModel, spawnCodexModel ?? focusSelectedModel);
781
901
  const effectiveModel = resolveEffectiveModel(effectiveDirectiveModel, spawnCodexHome, spawnCodexModel, focusSelectedModel);
782
902
  const effectivePipelineConfig = effectiveModel !== (pipelineConfig?.model ?? null)
783
903
  ? { ...pipelineConfig, model: effectiveModel }
784
904
  : pipelineConfig;
785
- const args = buildAgentArgs(backend, config, worktreePath, effectivePipelineConfig, resumeId, role.allowed_tools, readOnly);
786
- // On resume, send an incremental delta (what changed + prior findings to
787
- // verify) instead of the full role-framework + assembly: the resumed session
788
- // already holds its role and accumulated context (INJ-D). On a fresh spawn
789
- // the full prompt is sent unchanged.
790
- let messageToSend = prompt;
791
- if (resumeId) {
792
- let gitDiff = '';
793
- try {
794
- const diffCtx = {
795
- focusId,
796
- deliveryIds: deliveries.map(d => d.id),
797
- worktreePath,
798
- config,
799
- organizationId: config.organizationId,
800
- productId: config.productId,
801
- };
802
- const { content } = await resolveAssemblyRecipeWithManifest(['git.diff_against_base'], diffCtx);
803
- gitDiff = content;
804
- }
805
- catch (err) {
806
- console.debug(`[focus-executor] Resume-delta diff assembly failed (non-fatal):`, err.message);
807
- }
808
- messageToSend = buildResumeDeltaMessage({
809
- lineage: spawnSpec.lineage,
810
- focusName,
811
- gitDiff,
812
- priorFindings,
813
- });
814
- console.log(`[focus-executor] Resuming Claude session ${resumeId} for "${focusName}" ` +
815
- `-- sending incremental delta (lineage ${spawnSpec.lineage}, ${messageToSend.length} chars, ` +
816
- `${priorFindings.length} prior finding(s))`);
905
+ const args2 = buildAgentArgs(backend, config, worktreePath, effectivePipelineConfig, resumeId, role.allowed_tools, readOnly);
906
+ return { kind: 'ready', args: args2, effectiveModel, spawnCodexHome, spawnLocalModelKey };
907
+ }
908
+ /**
909
+ * Phase: choose the stdin message. A fresh spawn sends the full composed prompt;
910
+ * a resume sends an incremental delta (git diff + prior findings to verify) since
911
+ * the resumed session already holds its role + accumulated context (INJ-D).
912
+ */
913
+ export async function buildSpawnMessage(args) {
914
+ const { prompt, resumeId, spawnSpec, config, focusId, focusName, deliveries, worktreePath, priorFindings } = args;
915
+ if (!resumeId)
916
+ return prompt;
917
+ let gitDiff = '';
918
+ try {
919
+ const diffCtx = {
920
+ focusId,
921
+ deliveryIds: deliveries.map(d => d.id),
922
+ worktreePath,
923
+ config,
924
+ organizationId: config.organizationId,
925
+ productId: config.productId,
926
+ };
927
+ const { content } = await resolveAssemblyRecipeWithManifest(['git.diff_against_base'], diffCtx);
928
+ gitDiff = content;
817
929
  }
930
+ catch (err) {
931
+ console.debug(`[focus-executor] Resume-delta diff assembly failed (non-fatal):`, err.message);
932
+ }
933
+ const messageToSend = buildResumeDeltaMessage({
934
+ lineage: spawnSpec.lineage,
935
+ focusName,
936
+ gitDiff,
937
+ priorFindings,
938
+ });
939
+ console.log(`[focus-executor] Resuming Claude session ${resumeId} for "${focusName}" ` +
940
+ `-- sending incremental delta (lineage ${spawnSpec.lineage}, ${messageToSend.length} chars, ` +
941
+ `${priorFindings.length} prior finding(s))`);
942
+ return messageToSend;
943
+ }
944
+ /**
945
+ * Final phase: build the levered spawn environment, persist the assembly
946
+ * manifest, write the session lifecycle (starting -> running), acquire the
947
+ * governor slot, wrap the command in the sandbox (fail-closed), spawn the
948
+ * child, write its stdin, and attach the completion/close/error handlers.
949
+ *
950
+ * Owns ALL of its early-return cleanups so the team-state map and governor slot
951
+ * never leak: governor-denied (exit_category 'governor_denied') and
952
+ * sandbox-unavailable (exit_category 'sandbox_unavailable') both mark the
953
+ * session terminal, release the governor slot where one was held, remove the
954
+ * team from activeTeams, and return without spawning. Behavior byte-identical
955
+ * to the original inline tail of spawnFocusTeam.
956
+ */
957
+ export async function spawnTeamProcess(spec) {
958
+ const { config, focusId, focusName, session, teamState, backend, args, messageToSend, logs, worktreePath, resumeId, assemblyManifest, spawnCodexHome, spawnLocalModelKey, effectiveModel, pendingDirective, spawnStageName, params, } = spec;
959
+ const activeTeams = getActiveTeams();
818
960
  // Tag spend with the lever: the team-lead spawn runs the focus's execute
819
961
  // phase (review directives run the gate); the workflow stage is carried
820
962
  // separately. The audit phase tags its own spawn elsewhere.
@@ -878,9 +1020,7 @@ export async function spawnFocusTeam(params) {
878
1020
  console.warn(`[focus-executor] Governor denied slot for "${focusName}":`, err.message);
879
1021
  // Mark session as failed so it doesn't linger in `starting` status
880
1022
  await updateSession(session.id, {
881
- status: 'failed',
882
- exit_reason: 'Governor denied slot',
883
- exit_category: 'governor_denied',
1023
+ ...governorDeniedSessionUpdate(),
884
1024
  ended_at: new Date().toISOString(),
885
1025
  }).catch(updateErr => {
886
1026
  console.warn(`[focus-executor] Failed to update session after governor denial:`, updateErr.message);
@@ -908,9 +1048,7 @@ export async function spawnFocusTeam(params) {
908
1048
  if (err instanceof SandboxUnavailableError) {
909
1049
  console.error(`[focus-executor] Refusing spawn for "${focusName}": ${err.message}`);
910
1050
  await updateSession(session.id, {
911
- status: 'failed',
912
- exit_reason: err.message,
913
- exit_category: 'sandbox_unavailable',
1051
+ ...sandboxUnavailableSessionUpdate(err.message),
914
1052
  ended_at: new Date().toISOString(),
915
1053
  }).catch(updateErr => {
916
1054
  console.warn(`[focus-executor] Failed to update session after sandbox refusal:`, updateErr.message);
@@ -997,4 +1135,143 @@ export async function spawnFocusTeam(params) {
997
1135
  activeTeams.delete(focusId);
998
1136
  });
999
1137
  }
1138
+ /**
1139
+ * Spawn a focus team to execute all deliveries in a focus.
1140
+ *
1141
+ * Creates a single Agent Team (lead process) that reads all deliveries
1142
+ * and issues, builds a task DAG, and coordinates worker execution.
1143
+ */
1144
+ export async function spawnFocusTeam(params) {
1145
+ const { config, focusId, focusName, role, pipelineConfig, readOnly = false, claudeSessionIds, engineOverride, localModelOverride, orgDefaultEngine, forceSpawn = false } = params;
1146
+ const activeTeams = getActiveTeams();
1147
+ // The agent backend is resolved per-pass below, once the spawn's lineage
1148
+ // (coding vs review) is known -- see resolveBackend() after lineage resolution.
1149
+ // Prevent double-spawn
1150
+ if (activeTeams.has(focusId)) {
1151
+ console.warn(`[focus-executor] Team already active for focus "${focusName}", skipping spawn`);
1152
+ return;
1153
+ }
1154
+ if (await applyRespawnGuard(focusId, focusName))
1155
+ return;
1156
+ const executionConfig = deriveExecutionConfig(pipelineConfig);
1157
+ const branchName = generateFocusBranchName(role, focusName, focusId);
1158
+ // Initialize team state
1159
+ const teamState = createInitialTeamState({
1160
+ focusId, focusName, role, config, executionConfig, pipelineConfig, branchName, readOnly,
1161
+ });
1162
+ activeTeams.set(focusId, teamState);
1163
+ logSpawnHeader({ config, focusName, role, pipelineConfig, executionConfig, branchName, readOnly });
1164
+ // --- Phase 1: fetch all spawn context (deliveries/issues/product-context/
1165
+ // deployment-profile/loop-context) + PRD milestone push-down. null => the
1166
+ // context fetch rejected; tear down the freshly-inserted team and return.
1167
+ const spawnContext = await fetchSpawnContext(config, focusId, focusName);
1168
+ if (!spawnContext) {
1169
+ activeTeams.delete(focusId);
1170
+ return;
1171
+ }
1172
+ const { deliveries, issues, productContextDocs, deploymentProfileSnapshot, loopContext, prdMilestoneContext, assemblyManifest, } = spawnContext;
1173
+ // --- Phase 2: consume any pending directive, run the rank-ordered pre-spawn
1174
+ // guard, and record known-delivery bookkeeping on the team state. 'skip' =>
1175
+ // no actionable work; tear down and return (the map stays orchestrator-owned).
1176
+ const resolution = resolveActionableDeliveries({
1177
+ focusId, focusName, deliveries, issues, forceSpawn, teamState,
1178
+ });
1179
+ if (resolution.kind === 'skip') {
1180
+ activeTeams.delete(focusId);
1181
+ return;
1182
+ }
1183
+ const { pendingDirective } = resolution;
1184
+ // Ensure log directory exists
1185
+ if (!existsSync(config.logDir)) {
1186
+ mkdirSync(config.logDir, { recursive: true, mode: 0o700 });
1187
+ }
1188
+ // --- Phase: resolve the focus worktree (reuse + rebase, or inline-create).
1189
+ // null => cannot spawn (no commits / create failed); the helper already tore
1190
+ // down (activeTeams.delete + recordFocusTeardown).
1191
+ const worktreePath = await resolveSpawnWorktree({ config, focusId, focusName, branchName, readOnly });
1192
+ if (!worktreePath)
1193
+ return;
1194
+ teamState.worktreePath = worktreePath;
1195
+ // --- Phase: create the team-lead session. null => createSession failed (the
1196
+ // helper already removed the team from activeTeams; worktree is focus-owned).
1197
+ const session = await createTeamSession({
1198
+ config, role, focusId, focusName, branchName, pendingDirective, teamState,
1199
+ });
1200
+ if (!session)
1201
+ return;
1202
+ recordActivity();
1203
+ // Best-effort review context: review-filed open count + prior findings (the
1204
+ // remediation working set) + the focus description for the prompt.
1205
+ const { reviewFiledOpenCount, priorFindings, focusDescription } = await resolveReviewContext({
1206
+ config, focusId, focusName, issues,
1207
+ });
1208
+ // --- Phase A: resolve the directive body + spawn metadata (no prompt yet) ---
1209
+ // The engine backend is resolved per-pass (Phase B) before the prompt is
1210
+ // built (Phase C), so the lead prompt's orchestration vocabulary matches the
1211
+ // engine that will actually run this pass.
1212
+ const { directiveBody, directiveModel, spawnStageName, spawnDirectiveDeclared, useLegacyFullPrompt, } = await resolveDirectiveOrFallback({
1213
+ config, focusId, focusName, worktreePath, pendingDirective, assemblyManifest,
1214
+ });
1215
+ // --- Phase B: resolve the session lineage (pass) and the engine backend
1216
+ // (stamps lineage + sessionType onto the team state). ---
1217
+ const { spawnSpec, enginePass, resolvedEngine, backend } = resolveEngineForPass({
1218
+ spawnDirectiveDeclared, spawnStageName, engineOverride, config, orgDefaultEngine, focusName, teamState,
1219
+ });
1220
+ // --- Phase C: build the prompt with the resolved engine's vocabulary ---
1221
+ const promptContext = buildFocusPromptContext({
1222
+ config, focusId, focusName, focusDescription, deliveries, issues, executionConfig,
1223
+ pipelineConfig, productContextDocs, deploymentProfileSnapshot, readOnly, loopContext,
1224
+ reviewFiledOpenCount, engineId: backend.id,
1225
+ });
1226
+ const prompt = buildSpawnPrompt({
1227
+ role, promptContext, useLegacyFullPrompt, directiveBody, prdMilestoneContext,
1228
+ });
1229
+ // Set up log file streams
1230
+ const logs = setupTeamLogStreams(config.logDir, branchName, focusName);
1231
+ // Build args and env.
1232
+ // Resolve the resume id from the focus's per-lineage session map (INJ-A),
1233
+ // keyed by the spawn's declared lineage, honoring its declared continuity
1234
+ // policy (INJ-B). continuity=fresh -> null; resume -> the lineage's stored id.
1235
+ const resumeId = resolveResumeId({
1236
+ sessionMap: claudeSessionIds,
1237
+ lineage: spawnSpec.lineage,
1238
+ continuity: spawnSpec.continuity,
1239
+ });
1240
+ // --- Phase: resolve CODEX_HOME + per-pass local-model routing + model
1241
+ // selection + CLI args. 'skip' => fail-closed local-model pass (no bearer key);
1242
+ // mark the session terminal (local_model_key_unset) and tear down.
1243
+ const modelResolution = await resolveSpawnModel({
1244
+ resolvedEngine, enginePass, localModelOverride, config, directiveModel,
1245
+ pipelineConfig, backend, worktreePath, resumeId, role, readOnly, focusName,
1246
+ });
1247
+ if (modelResolution.kind === 'skip') {
1248
+ const { coords } = modelResolution;
1249
+ console.error(`[focus-executor] Cannot route "${focusName}" ${enginePass} pass to local model ` +
1250
+ `"${coords.name}" (${coords.model} @ ${coords.baseUrl}): no API key is configured for it ` +
1251
+ `in Telora. Set the key under Settings -> Local Models and re-run; skipping spawn.`);
1252
+ await updateSession(session.id, {
1253
+ ...localModelKeyUnsetSessionUpdate(coords),
1254
+ ended_at: new Date().toISOString(),
1255
+ }).catch(updateErr => {
1256
+ console.warn(`[focus-executor] Failed to update session after local-model key-unset:`, updateErr.message);
1257
+ });
1258
+ activeTeams.delete(focusId);
1259
+ return;
1260
+ }
1261
+ const { args, effectiveModel, spawnCodexHome, spawnLocalModelKey } = modelResolution;
1262
+ // On resume, send an incremental delta instead of the full prompt (INJ-D);
1263
+ // a fresh spawn sends the composed prompt unchanged.
1264
+ const messageToSend = await buildSpawnMessage({
1265
+ prompt, resumeId, spawnSpec, config, focusId, focusName, deliveries, worktreePath, priorFindings,
1266
+ });
1267
+ // --- Final phase: build the spawn env, persist the manifest, write the
1268
+ // session lifecycle, acquire the governor slot, sandbox-wrap, spawn the child,
1269
+ // write stdin, and attach handlers. Owns its governor-denied / sandbox-
1270
+ // unavailable cleanups (session failed-write + slot release + activeTeams.delete).
1271
+ await spawnTeamProcess({
1272
+ config, focusId, focusName, session, teamState, backend, args, messageToSend,
1273
+ logs, worktreePath, resumeId, assemblyManifest, spawnCodexHome, spawnLocalModelKey,
1274
+ effectiveModel, pendingDirective, spawnStageName, params,
1275
+ });
1276
+ }
1000
1277
  //# sourceMappingURL=focus-executor.js.map