@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.
- package/build-info.json +9 -3
- package/dist/completion/exit-classification.d.ts +10 -0
- package/dist/completion/exit-classification.d.ts.map +1 -1
- package/dist/completion/exit-classification.js +13 -1
- package/dist/completion/exit-classification.js.map +1 -1
- package/dist/completion/review-exit-phase.js +1 -1
- package/dist/completion/review-exit-phase.js.map +1 -1
- package/dist/focus-executor.d.ts +413 -4
- package/dist/focus-executor.d.ts.map +1 -1
- package/dist/focus-executor.js +571 -294
- package/dist/focus-executor.js.map +1 -1
- package/dist/focus-phase.d.ts +8 -0
- package/dist/focus-phase.d.ts.map +1 -1
- package/dist/focus-phase.js +8 -0
- package/dist/focus-phase.js.map +1 -1
- package/dist/prompt-sections/execution-sections.d.ts.map +1 -1
- package/dist/prompt-sections/execution-sections.js +19 -6
- package/dist/prompt-sections/execution-sections.js.map +1 -1
- package/dist/queries/deliveries.d.ts +13 -1
- package/dist/queries/deliveries.d.ts.map +1 -1
- package/dist/queries/deliveries.js +30 -1
- package/dist/queries/deliveries.js.map +1 -1
- package/dist/spawner/helpers.d.ts.map +1 -1
- package/dist/spawner/helpers.js +24 -1
- package/dist/spawner/helpers.js.map +1 -1
- package/package.json +2 -2
package/dist/focus-executor.js
CHANGED
|
@@ -271,69 +271,18 @@ export function buildAgentArgs(backend, config, worktreePath, pipelineConfig, re
|
|
|
271
271
|
: backend.buildSpawnArgs(base);
|
|
272
272
|
}
|
|
273
273
|
/**
|
|
274
|
-
*
|
|
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
|
-
*
|
|
277
|
-
*
|
|
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
|
|
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
|
-
|
|
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
|
-
|
|
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
|
-
|
|
460
|
-
|
|
461
|
-
|
|
462
|
-
|
|
463
|
-
|
|
464
|
-
|
|
465
|
-
|
|
466
|
-
|
|
467
|
-
|
|
468
|
-
|
|
469
|
-
|
|
470
|
-
|
|
471
|
-
|
|
472
|
-
|
|
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
|
|
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
|
|
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
|
-
|
|
651
|
-
|
|
652
|
-
|
|
653
|
-
|
|
654
|
-
|
|
655
|
-
|
|
656
|
-
|
|
657
|
-
|
|
658
|
-
|
|
659
|
-
|
|
660
|
-
|
|
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
|
|
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
|
-
|
|
704
|
-
|
|
705
|
-
|
|
706
|
-
|
|
707
|
-
|
|
708
|
-
|
|
709
|
-
|
|
710
|
-
|
|
711
|
-
|
|
712
|
-
|
|
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
|
|
720
|
-
//
|
|
721
|
-
//
|
|
722
|
-
//
|
|
723
|
-
|
|
724
|
-
|
|
725
|
-
|
|
726
|
-
|
|
727
|
-
|
|
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 (
|
|
881
|
+
if (localModelConfig.kind === 'fallback') {
|
|
737
882
|
console.warn(`[focus-executor] Focus "${focusName}" ${enginePass} pass selected local model ` +
|
|
738
|
-
`${(
|
|
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 (
|
|
742
|
-
|
|
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 (
|
|
764
|
-
const {
|
|
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 =
|
|
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
|
|
786
|
-
|
|
787
|
-
|
|
788
|
-
|
|
789
|
-
|
|
790
|
-
|
|
791
|
-
|
|
792
|
-
|
|
793
|
-
|
|
794
|
-
|
|
795
|
-
|
|
796
|
-
|
|
797
|
-
|
|
798
|
-
|
|
799
|
-
|
|
800
|
-
|
|
801
|
-
|
|
802
|
-
|
|
803
|
-
|
|
804
|
-
|
|
805
|
-
|
|
806
|
-
|
|
807
|
-
}
|
|
808
|
-
|
|
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
|
-
|
|
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
|
-
|
|
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
|