fraim-hub 2.0.270 → 2.0.271

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.
@@ -14,6 +14,7 @@ exports.getAiHubCategories = getAiHubCategories;
14
14
  const fs_1 = __importDefault(require("fs"));
15
15
  const path_1 = __importDefault(require("path"));
16
16
  const project_fraim_paths_1 = require("../core/utils/project-fraim-paths");
17
+ const resolve_phase_edge_1 = require("../core/resolve-phase-edge");
17
18
  // Directories scanned for employee jobs at runtime, in lowest-to-highest
18
19
  // precedence order. Later entries win on {categoryId, jobId} collision.
19
20
  //
@@ -411,21 +412,6 @@ function findJobStubPath(projectPath, jobId) {
411
412
  }
412
413
  return null;
413
414
  }
414
- // Resolve a phase's `onSuccess` edge to the next phase id given the run's
415
- // discriminant. Returns null when the edge is absent or terminal.
416
- function nextPhase(edge, discriminant) {
417
- if (edge == null)
418
- return null;
419
- if (typeof edge === 'string')
420
- return edge;
421
- if (typeof edge === 'object') {
422
- if (typeof edge[discriminant] === 'string')
423
- return edge[discriminant];
424
- if (typeof edge.default === 'string')
425
- return edge.default;
426
- }
427
- return null;
428
- }
429
415
  // Parse the ordered phase list from a job stub's ## Steps section.
430
416
  // Real FRAIM job stubs use Markdown steps rather than JSON frontmatter;
431
417
  // this is the fallback parser that makes the pizza tracker work for them.
@@ -460,7 +446,7 @@ function loadJobPhases(jobId, projectPath, discriminant = 'feature') {
460
446
  const phaseDef = fm.phases[cursor];
461
447
  if (!phaseDef)
462
448
  break;
463
- cursor = nextPhase(phaseDef.onSuccess, discriminant);
449
+ cursor = (0, resolve_phase_edge_1.resolvePhaseEdge)(fm.phases[cursor]?.onSuccess, discriminant);
464
450
  }
465
451
  const labels = fm.phaseLabels || {};
466
452
  return ordered.map((id) => ({ id, label: friendlyPhaseLabel(id, labels[id]) }));
@@ -482,7 +468,7 @@ function resolveJobPhaseTransition(jobId, projectPath, phaseId, outcome, discrim
482
468
  if (!phaseDef)
483
469
  return null;
484
470
  const edge = outcome === 'complete' ? phaseDef.onSuccess : phaseDef.onFailure;
485
- return nextPhase(edge, discriminant);
471
+ return (0, resolve_phase_edge_1.resolvePhaseEdge)(edge, discriminant);
486
472
  }
487
473
  function loadAllJobPhaseIds(jobId, projectPath) {
488
474
  const stubPath = findJobStubPath(projectPath, jobId);
@@ -26,7 +26,6 @@ const HEADER_OMITTED_FIELDS = [
26
26
  'events',
27
27
  'artifacts',
28
28
  'run',
29
- 'delegation',
30
29
  'handoffSummary',
31
30
  '_bodyLoaded',
32
31
  '_stopping',
@@ -181,13 +180,34 @@ function placeConversationInBucket(bucket, conv) {
181
180
  const existingScore = conversationRichness(existing);
182
181
  const incomingScore = conversationRichness(conv);
183
182
  if (incomingScore > existingScore) {
184
- bucket.conversations[idx] = conv;
183
+ bucket.conversations[idx] = withStableConversationCreatedAt(existing, conv);
185
184
  }
186
185
  else if (incomingScore === existingScore
187
186
  && timestampValue(value?.lastUpdatedAt) > timestampValue(existing.lastUpdatedAt)) {
188
- bucket.conversations[idx] = conv;
187
+ bucket.conversations[idx] = withStableConversationCreatedAt(existing, conv);
189
188
  }
190
189
  }
190
+ function stableConversationCreatedAt(existing, incoming) {
191
+ const existingValue = existing?.createdAt;
192
+ const incomingValue = incoming?.createdAt;
193
+ const existingTs = timestampValue(existingValue);
194
+ const incomingTs = timestampValue(incomingValue);
195
+ if (existingTs > 0 && incomingTs > 0)
196
+ return existingTs <= incomingTs ? existingValue : incomingValue;
197
+ if (existingTs > 0)
198
+ return existingValue;
199
+ if (incomingTs > 0)
200
+ return incomingValue;
201
+ return existingValue ?? incomingValue;
202
+ }
203
+ function withStableConversationCreatedAt(existing, incoming) {
204
+ if (!incoming || typeof incoming !== 'object')
205
+ return incoming;
206
+ const createdAt = stableConversationCreatedAt(existing, incoming);
207
+ if (createdAt === undefined)
208
+ return incoming;
209
+ return { ...incoming, createdAt };
210
+ }
191
211
  // Relocate any project-scoped record mis-filed under the wrong project bucket back to its own
192
212
  // project (see docs/rca/hub-conversation-cross-project-leak.md). Sentinel buckets are left as-is.
193
213
  function migrateProjectBuckets(store) {
@@ -303,14 +323,45 @@ function newestFirst(a, b) {
303
323
  }
304
324
  function toHeader(conv) {
305
325
  const header = { ...conv };
326
+ const delegation = compactDelegationForHeader(header.delegation);
306
327
  for (const field of HEADER_OMITTED_FIELDS)
307
328
  delete header[field];
329
+ if (delegation)
330
+ header.delegation = delegation;
308
331
  for (const field of Object.keys(header)) {
309
332
  if (field.startsWith('_'))
310
333
  delete header[field];
311
334
  }
312
335
  return header;
313
336
  }
337
+ function compactDelegationForHeader(raw) {
338
+ if (!raw || typeof raw !== 'object')
339
+ return undefined;
340
+ const delegation = raw;
341
+ const tasks = Array.isArray(delegation.tasks) ? delegation.tasks : [];
342
+ if (!tasks.length)
343
+ return undefined;
344
+ return {
345
+ delegationRequired: delegation.delegationRequired === true,
346
+ objective: delegation.objective,
347
+ orchestratorPersonaKey: delegation.orchestratorPersonaKey,
348
+ rootRunId: delegation.rootRunId,
349
+ managerRunId: delegation.managerRunId,
350
+ tasks: tasks.map((task) => {
351
+ const value = task && typeof task === 'object' ? task : {};
352
+ return {
353
+ taskId: value.taskId,
354
+ title: value.title,
355
+ status: value.status,
356
+ personaKey: value.personaKey,
357
+ jobId: value.jobId,
358
+ runId: value.runId,
359
+ conversationId: value.conversationId,
360
+ dependsOn: Array.isArray(value.dependsOn) ? value.dependsOn : [],
361
+ };
362
+ }),
363
+ };
364
+ }
314
365
  function headerNeedsSanitization(header) {
315
366
  const value = header;
316
367
  return Object.keys(value).some((field) => HEADER_OMITTED_FIELD_SET.has(field) || field.startsWith('_'));
@@ -345,6 +396,17 @@ class AiHubConversationStore {
345
396
  // Sibling directory of the legacy file, e.g. ~/.fraim/ai-hub-conversations/
346
397
  this.shardRoot = path_1.default.join(dir, base);
347
398
  }
399
+ /**
400
+ * Root directory holding the per-bucket shards.
401
+ *
402
+ * Issue #1164: exposed read-only so callers that walk the shard layout, such as
403
+ * the stale-bucket sweep, ask the store where its data lives instead of
404
+ * re-deriving the path. Two independent derivations of the same layout drift the
405
+ * moment one changes, and a sweep pointed at the wrong directory fails silently.
406
+ */
407
+ get shardRootPath() {
408
+ return this.shardRoot;
409
+ }
348
410
  // ---- path helpers ----
349
411
  bucketDir(bucketKey) {
350
412
  const canonical = bucketKey === exports.MANAGER_SCOPE_KEY || bucketKey === exports.COMPANY_SCOPE_KEY
@@ -732,6 +794,7 @@ class AiHubConversationStore {
732
794
  id: existing.id,
733
795
  projectPath: key,
734
796
  agentName: patch.agentName || existing.agentName,
797
+ createdAt: stableConversationCreatedAt(existing, patch) ?? existing.createdAt,
735
798
  lastUpdatedAt: patch.lastUpdatedAt ?? new Date().toISOString(),
736
799
  }) ?? existing;
737
800
  this.writeConvFile(bucketDir, key, merged);
@@ -381,7 +381,19 @@ function extractSignalFromArgs(args) {
381
381
  : 'starting';
382
382
  const findings = args.findings;
383
383
  const findingsText = findings && typeof findings.summary === 'string' ? findings.summary : undefined;
384
- const discriminant = typeof args.runDiscriminant === 'string' ? args.runDiscriminant : undefined;
384
+ // Issue #1135: `runDiscriminant` is not a field of the seekMentoring tool schema
385
+ // and no agent sends it, so before issue #1123 this was populated only by the
386
+ // scripted test double and production always resolved with the literal default
387
+ // 'feature'. The real discriminant is the one the mentor routes on
388
+ // (`findings.phaseOutcome`), so read that first and keep the legacy field as a
389
+ // fallback for the test double.
390
+ const evidenceArgs = args.evidence;
391
+ const discriminantFromFindings = findings && typeof findings.phaseOutcome === 'string' ? findings.phaseOutcome
392
+ : findings && typeof findings.issueType === 'string' ? findings.issueType
393
+ : evidenceArgs && typeof evidenceArgs.issueType === 'string' ? evidenceArgs.issueType
394
+ : undefined;
395
+ const discriminant = discriminantFromFindings
396
+ ?? (typeof args.runDiscriminant === 'string' ? args.runDiscriminant : undefined);
385
397
  const jobName = typeof args.jobName === 'string' ? args.jobName : undefined;
386
398
  const jobId = typeof args.jobId === 'string' ? args.jobId : undefined;
387
399
  const issueNumber = typeof args.issueNumber === 'string' ? args.issueNumber
@@ -1787,6 +1799,7 @@ class CliHostRuntime {
1787
1799
  exports.CliHostRuntime = CliHostRuntime;
1788
1800
  class FakeHostRuntime {
1789
1801
  constructor() {
1802
+ this.isTestDouble = true;
1790
1803
  this.employees = [
1791
1804
  { id: 'codex', label: 'Codex', available: true, detail: 'Test double employee.', supportsRaw: true },
1792
1805
  { id: 'claude', label: 'Claude Code', available: true, detail: 'Test double employee.', supportsRaw: true },
@@ -1878,6 +1891,7 @@ exports.FakeHostRuntime = FakeHostRuntime;
1878
1891
  // FakeHostRuntime (smaller surface, no seekMentoring).
1879
1892
  class ScriptedHostRuntime {
1880
1893
  constructor() {
1894
+ this.isTestDouble = true;
1881
1895
  this.employees = [
1882
1896
  { id: 'codex', label: 'Codex', available: true, detail: 'Scripted test double.', supportsRaw: true },
1883
1897
  { id: 'claude', label: 'Claude Code', available: true, detail: 'Scripted test double.', supportsRaw: true },
@@ -8,6 +8,7 @@ const fs_1 = __importDefault(require("fs"));
8
8
  const path_1 = __importDefault(require("path"));
9
9
  const crypto_1 = require("crypto");
10
10
  const conversation_store_1 = require("./conversation-store");
11
+ const process_liveness_1 = require("./process-liveness");
11
12
  exports.DEFAULT_RESTART_RECOVERY_LEASE_MS = 60_000;
12
13
  function normalizedDirectoryPath(projectPath) {
13
14
  const resolved = path_1.default.resolve(projectPath);
@@ -49,6 +50,8 @@ class RestartRecoveryPolicy {
49
50
  this.recoveryLeaseMs = options.recoveryLeaseMs ?? exports.DEFAULT_RESTART_RECOVERY_LEASE_MS;
50
51
  this.projectExists = options.projectExists || ((projectPath) => fs_1.default.existsSync(projectPath));
51
52
  this.machineLevelJobIds = options.machineLevelJobIds || new Set();
53
+ this.currentPid = options.currentPid ?? process.pid;
54
+ this.pidAlive = options.pidAlive || process_liveness_1.isPidAlive;
52
55
  }
53
56
  classify(conversation, bucketKey, options = {}) {
54
57
  const bucketReason = restartRecoveryBucketOwnershipReason(conversation, bucketKey);
@@ -81,6 +84,17 @@ class RestartRecoveryPolicy {
81
84
  if (options.activeRunExists) {
82
85
  return { action: 'defer', reason: 'active_run_exists' };
83
86
  }
87
+ // Issue #1159: `activeRunExists` only sees this process's run registry, so it
88
+ // cannot tell that a *different* live Hub owns this run. Two Hubs on one
89
+ // machine is the normal case here: the desktop Hub plus any Hub a job starts
90
+ // for validation. Without this check the second one adopts the first one's
91
+ // in-flight work, replaces its run id, and parks it as "Waiting on you".
92
+ // A dead owner pid is exactly the restart case recovery exists for, so only
93
+ // a live foreign owner defers.
94
+ const ownerPid = typeof conversation.ownerPid === 'number' ? conversation.ownerPid : 0;
95
+ if (ownerPid > 0 && ownerPid !== this.currentPid && this.pidAlive(ownerPid)) {
96
+ return { action: 'defer', reason: 'owner_process_alive' };
97
+ }
84
98
  const recoveredAt = timestampMs(conversation.restartRecovery?.recoveredAt);
85
99
  if (recoveredAt > 0 && this.nowMs() - recoveredAt < this.recoveryLeaseMs) {
86
100
  return { action: 'defer', reason: 'recent_recovery' };
@@ -325,11 +325,14 @@ function directoryExists(projectPath) {
325
325
  // when none is recorded the Hub has no active project and opens on an empty
326
326
  // project list. This deliberately never falls back to process.cwd() — the launch
327
327
  // directory must not masquerade as a project.
328
- function resolveInitialHubProjectPath(preferencesStore) {
328
+ function resolveInitialHubProjectPath(preferencesStore, options = {}) {
329
329
  const recorded = preferencesStore.load('').projectPath;
330
330
  if (typeof recorded === 'string' && recorded.trim().length > 0) {
331
331
  const resolved = path_1.default.resolve(recorded);
332
- if (directoryExists(resolved))
332
+ const allowedTemporary = options.allowTemporaryRelatedTo
333
+ ? isRelatedTemporaryProjectPath(resolved, options.allowTemporaryRelatedTo)
334
+ : false;
335
+ if (directoryExists(resolved) && (!isLikelyTemporaryProjectPath(resolved) || allowedTemporary))
333
336
  return resolved;
334
337
  }
335
338
  return '';
@@ -1267,6 +1270,11 @@ function isCodexMissingReasoningResumeError(run) {
1267
1270
  text.includes('provided without its required "reasoning" item');
1268
1271
  });
1269
1272
  }
1273
+ function isCodexMissingReasoningResumeText(value) {
1274
+ const text = String(value || '');
1275
+ return text.includes("provided without its required 'reasoning' item") ||
1276
+ text.includes('provided without its required "reasoning" item');
1277
+ }
1270
1278
  // Apply a parsed seekMentoring tool-use signal from the host stream to
1271
1279
  // the run state. Returns the updated currentPhase.
1272
1280
  function applySeekMentoringSignal(run, signal) {
@@ -1302,6 +1310,7 @@ function applySeekMentoringSignal(run, signal) {
1302
1310
  : [];
1303
1311
  if (run.reviewHandoff?.reviewRequired === true) {
1304
1312
  run.pauseReason = 'awaiting_review';
1313
+ run.nextJobRecommendations = null;
1305
1314
  }
1306
1315
  else if (run.reviewHandoff?.reviewRequired === false && run.pauseReason === 'awaiting_review') {
1307
1316
  run.pauseReason = 'working';
@@ -1682,6 +1691,22 @@ function normalizedDirectoryPath(projectPath) {
1682
1691
  function sameDirectoryPath(left, right) {
1683
1692
  return normalizedDirectoryPath(left) === normalizedDirectoryPath(right);
1684
1693
  }
1694
+ function isPathInsideDirectory(candidatePath, parentPath) {
1695
+ const relative = path_1.default.relative(path_1.default.resolve(parentPath), path_1.default.resolve(candidatePath));
1696
+ return relative === '' || (!!relative && !relative.startsWith('..') && !path_1.default.isAbsolute(relative));
1697
+ }
1698
+ function isLikelyTemporaryProjectPath(projectPath) {
1699
+ if (!projectPath)
1700
+ return false;
1701
+ return isPathInsideDirectory(projectPath, os_1.default.tmpdir());
1702
+ }
1703
+ function isRelatedTemporaryProjectPath(projectPath, activeProjectPath) {
1704
+ if (!isLikelyTemporaryProjectPath(projectPath))
1705
+ return true;
1706
+ if (!activeProjectPath || !isLikelyTemporaryProjectPath(activeProjectPath))
1707
+ return false;
1708
+ return isPathInsideDirectory(projectPath, path_1.default.dirname(path_1.default.resolve(activeProjectPath)));
1709
+ }
1685
1710
  function deploymentProjectFilter(rawProjectPath, fallbackProjectPath) {
1686
1711
  return ensureDirectoryPath(typeof rawProjectPath === 'string' && rawProjectPath.trim() ? rawProjectPath : fallbackProjectPath);
1687
1712
  }
@@ -1768,6 +1793,14 @@ function countMarkdownFilesRecursive(dirPath) {
1768
1793
  // conservative park.
1769
1794
  // ---------------------------------------------------------------------------
1770
1795
  const MAX_RECOVERY_ATTEMPTS = 3;
1796
+ // Issue #1150: a run launched through a user-defined configured agent depends on that
1797
+ // profile's setup-script env (CODEX_HOME, AWS_REGION, ...) to reach the right provider
1798
+ // and the right host session store. A synthesized `<host>-default` agent has no setup
1799
+ // script, so it contributes nothing and its absence costs nothing.
1800
+ function usesCustomConfiguredAgent(run) {
1801
+ const configuredAgentId = typeof run.configuredAgentId === 'string' ? run.configuredAgentId.trim() : '';
1802
+ return configuredAgentId.length > 0 && !configuredAgentId.endsWith('-default');
1803
+ }
1771
1804
  function recoveryBackoffMs(attempt) {
1772
1805
  const base = 600 * Math.pow(2, attempt - 1); // 600, 1200, 2400ms
1773
1806
  return base + Math.floor(0.2 * base); // +20% deterministic jitter
@@ -1997,6 +2030,9 @@ class AiHubServer {
1997
2030
  res.setHeader('Expires', '0');
1998
2031
  next();
1999
2032
  });
2033
+ this.app.get('/favicon.ico', (_req, res) => {
2034
+ res.status(204).end();
2035
+ });
2000
2036
  this.app.use('/ai-hub', express_1.default.static(resolveAiHubPublicDir()));
2001
2037
  this.app.use('/_fraim-hub-ui', (req, res, next) => {
2002
2038
  try {
@@ -2135,7 +2171,7 @@ class AiHubServer {
2135
2171
  // Prefer the recorded project (reflects a project switch), then the active
2136
2172
  // project resolved at construction, then '' when no project has been chosen
2137
2173
  // (#866 R2). Never falls back to cwd.
2138
- return resolveInitialHubProjectPath(this.preferencesStore) || this.projectPath;
2174
+ return resolveInitialHubProjectPath(this.preferencesStore, { allowTemporaryRelatedTo: this.projectPath }) || this.projectPath;
2139
2175
  }
2140
2176
  // Issue #892: resolve the working directory for a run start/resume. Project-scoped
2141
2177
  // runs require an existing project directory (unchanged — an empty path still throws
@@ -2296,12 +2332,14 @@ class AiHubServer {
2296
2332
  const preferences = this.preferencesStore.load(normalizedProjectPath);
2297
2333
  const includeConversationProjects = options.includeConversationProjects !== false;
2298
2334
  const conversationProjects = includeConversationProjects
2299
- ? this.conversationStore.listProjectPaths().map((folderPath) => ({ folderPath }))
2335
+ ? this.conversationStore.listProjectPaths()
2336
+ .filter((folderPath) => isRelatedTemporaryProjectPath(folderPath, normalizedProjectPath))
2337
+ .map((folderPath) => ({ folderPath }))
2300
2338
  : [];
2301
2339
  return (0, preferences_1.normalizeAiHubProjectList)([
2302
- ...(preferences.projects || []),
2340
+ ...(preferences.projects || []).filter((project) => isRelatedTemporaryProjectPath(project.folderPath, normalizedProjectPath)),
2303
2341
  ...conversationProjects,
2304
- ...extras,
2342
+ ...extras.filter((project) => isRelatedTemporaryProjectPath(project.folderPath, normalizedProjectPath)),
2305
2343
  ], normalizedProjectPath, { removedProjectPaths: preferences.removedProjectPaths || [] });
2306
2344
  }
2307
2345
  /**
@@ -2652,6 +2690,9 @@ class AiHubServer {
2652
2690
  status: run.status,
2653
2691
  // Issue #904: carry auxiliary exit classification to the persisted record.
2654
2692
  ...(run.pauseReason !== undefined && { pauseReason: run.pauseReason }),
2693
+ // Issue #1159: record which Hub process owns this run, so another Hub can
2694
+ // ask whether the owner is still alive before adopting it on restart.
2695
+ ownerPid: process.pid,
2655
2696
  createdAt: run.createdAt,
2656
2697
  lastUpdatedAt,
2657
2698
  messages: run.messages.map((message) => {
@@ -2769,7 +2810,7 @@ class AiHubServer {
2769
2810
  const startedFresh = this.runRegistry.get(run.id);
2770
2811
  if (startedFresh)
2771
2812
  this.persistRunConversation(startedFresh, startedFresh.conversationId || startedFresh.id);
2772
- this.runRegistry.create(run, {});
2813
+ this.runRegistry.create(startedFresh || run, {});
2773
2814
  const freshLaunch = this.resolveLaunchAgent(run.configuredAgentId, run.hostId);
2774
2815
  const freshChild = this.hostRuntime.startRun(run.hostId, run.projectPath, freshPayload.message, {
2775
2816
  onEvent: (event, channel) => {
@@ -2828,13 +2869,48 @@ class AiHubServer {
2828
2869
  fs_1.default.mkdirSync(dir, { recursive: true });
2829
2870
  return path_1.default.join(dir, 'hub-restart-recovery.lock');
2830
2871
  }
2872
+ /**
2873
+ * Issue #1159: recover only what this Hub actually manages.
2874
+ *
2875
+ * This used to iterate `conversationStore.listProjectPaths()`, i.e. every bucket
2876
+ * in the store. Because tests boot real `AiHubServer` instances against the
2877
+ * default store at `~/.fraim/ai-hub-conversations/`, a Hub started for a temp
2878
+ * fixture directory swept the developer's real projects, took ownership of
2879
+ * three in-flight runs, and parked them as "Waiting on you".
2880
+ *
2881
+ * The bound is the Hub's own project list, the same bound issue #1065 applied
2882
+ * to search scope. `includeConversationProjects: false` matters: the default
2883
+ * form folds `listProjectPaths()` back in, and `isRelatedTemporaryProjectPath`
2884
+ * returns true for every non-temp path, so the developer's real projects would
2885
+ * survive the filter.
2886
+ */
2831
2887
  restartRecoveryBucketKeys() {
2832
- const keys = new Set(this.conversationStore.listProjectPaths());
2888
+ const keys = new Set(this.knownProjects(this.projectPath, [], { includeConversationProjects: false })
2889
+ .map((project) => project.folderPath)
2890
+ .filter((folderPath) => typeof folderPath === 'string' && !!folderPath));
2833
2891
  keys.add(conversation_store_1.MANAGER_SCOPE_KEY);
2834
2892
  keys.add(conversation_store_1.COMPANY_SCOPE_KEY);
2835
2893
  return Array.from(keys);
2836
2894
  }
2895
+ /**
2896
+ * Issue #1159: a test double answers any continue with a canned line and exits
2897
+ * 0 within milliseconds, which `handleRunExit` parks as completed/awaiting_user.
2898
+ * Running restart recovery under one therefore converts live work into
2899
+ * "Waiting on you" on contact. Recovery is a production-only concern; tests that
2900
+ * exercise it inject a purpose-built runtime instead.
2901
+ *
2902
+ * Reads the runtime's own `isTestDouble` marker rather than testing `instanceof`
2903
+ * against specific classes, so a new double opts itself in and this file never
2904
+ * imports a test class.
2905
+ */
2906
+ hostRuntimeIsTestDouble() {
2907
+ return this.hostRuntime.isTestDouble === true;
2908
+ }
2837
2909
  recoverInProgressConversationsAfterRestart() {
2910
+ if (this.hostRuntimeIsTestDouble()) {
2911
+ console.warn('[ai-hub] restart recovery skipped: host runtime is a test double.');
2912
+ return;
2913
+ }
2838
2914
  try {
2839
2915
  (0, conversation_store_lock_1.withBucketLock)(this.restartRecoveryLockPath(), () => {
2840
2916
  const bucketKeys = this.restartRecoveryBucketKeys();
@@ -3001,7 +3077,7 @@ class AiHubServer {
3001
3077
  },
3002
3078
  restartRecoverySkippedReason: null,
3003
3079
  hostLifecycle: conversation.hostLifecycle || undefined,
3004
- nextJobRecommendations: conversation.nextJobRecommendations || null,
3080
+ nextJobRecommendations: null,
3005
3081
  issueNumber: conversation.issueNumber ?? null,
3006
3082
  agentSwitches: conversation.agentSwitches || [],
3007
3083
  handoffSummary: conversation.handoffSummary || null,
@@ -3538,7 +3614,7 @@ class AiHubServer {
3538
3614
  if (latestManager)
3539
3615
  this.drainPendingDelegatedReviews(latestManager);
3540
3616
  },
3541
- });
3617
+ }, managerLaunch.launchContext);
3542
3618
  this.runRegistry.attachChildIfRunning(managerRun.id, child);
3543
3619
  }
3544
3620
  computeFirstRun(projectPath, jobCount, personas) {
@@ -4341,7 +4417,7 @@ class AiHubServer {
4341
4417
  reviewHandoff: conversation.reviewHandoff || null,
4342
4418
  delegation: conversation.delegation || null,
4343
4419
  delegationTaskId: conversation.delegationTaskId || null,
4344
- nextJobRecommendations: conversation.nextJobRecommendations || null,
4420
+ nextJobRecommendations: null,
4345
4421
  issueNumber: conversation.issueNumber ?? null,
4346
4422
  managedByRunId: conversation.managedByRunId || null,
4347
4423
  managedByPersonaKey: conversation.managedByPersonaKey || null,
@@ -5346,8 +5422,15 @@ class AiHubServer {
5346
5422
  this.persistRunConversation(started, started.conversationId || started.id);
5347
5423
  this.runRegistry.create(run, {});
5348
5424
  const continueLaunch = this.resolveLaunchAgent(run.configuredAgentId, run.hostId);
5425
+ let codexMissingReasoningResumeError = false;
5426
+ let startedCodexReviewApprovalFallback = false;
5349
5427
  const child = this.hostRuntime.continueRun(run.hostId, run.projectPath, run.sessionId, message, {
5350
5428
  onEvent: (event, channel) => {
5429
+ if (run.hostId === 'codex' && reviewApprovalSystemEventText) {
5430
+ codexMissingReasoningResumeError = codexMissingReasoningResumeError ||
5431
+ isCodexMissingReasoningResumeText(event.raw) ||
5432
+ isCodexMissingReasoningResumeText(event.message);
5433
+ }
5351
5434
  this.runRegistry.update(run.id, (current) => {
5352
5435
  if (event.sessionId) {
5353
5436
  this.applyRunHostSessionSignal(current, event.sessionId, run.hostId);
@@ -5372,7 +5455,8 @@ class AiHubServer {
5372
5455
  },
5373
5456
  onExit: (exitCode) => {
5374
5457
  const exited = this.runRegistry.get(run.id);
5375
- if (exitCode !== 0 && run.hostId === 'codex' && reviewApprovalSystemEventText && exited && isCodexMissingReasoningResumeError(exited)) {
5458
+ if (exitCode !== 0 && run.hostId === 'codex' && reviewApprovalSystemEventText && exited && (codexMissingReasoningResumeError || isCodexMissingReasoningResumeError(exited))) {
5459
+ startedCodexReviewApprovalFallback = true;
5376
5460
  this.startFreshCodexReviewApprovalFallback(run.id, prepared.display || message);
5377
5461
  return;
5378
5462
  }
@@ -5381,7 +5465,8 @@ class AiHubServer {
5381
5465
  });
5382
5466
  },
5383
5467
  }, continueLaunch.launchContext);
5384
- this.runRegistry.attachChildIfRunning(run.id, child);
5468
+ if (!startedCodexReviewApprovalFallback)
5469
+ this.runRegistry.attachChildIfRunning(run.id, child);
5385
5470
  const refreshed = this.runRegistry.get(run.id);
5386
5471
  res.json(refreshed ? this.enrichRunForResponse(refreshed) : refreshed);
5387
5472
  }
@@ -6343,6 +6428,28 @@ class AiHubServer {
6343
6428
  return run;
6344
6429
  }
6345
6430
  // ─── End Issue #578 helpers ───────────────────────────────────────────────
6431
+ // Issue #1150: terminal park for a run whose auto-recovery must stop rather than
6432
+ // relaunch. Mirrors the tail of the normal park path in handleRunExit, so a delegated
6433
+ // child parked here still hands control back to its manager.
6434
+ parkRunAsRecoveryError(runId, exitCode, reason, postPark) {
6435
+ this.runRegistry.update(runId, (run) => {
6436
+ run.exitCode = exitCode;
6437
+ run.status = 'failed';
6438
+ run.pauseReason = 'error';
6439
+ clearCompactionLifecycle(run);
6440
+ run.events.push((0, hosts_1.createHubEvent)('system', reason));
6441
+ });
6442
+ const parked = this.runRegistry.get(runId);
6443
+ if (parked) {
6444
+ if (postPark)
6445
+ postPark(parked);
6446
+ this.finalizeRunConversationProjection(runId, parked.conversationId || parked.id);
6447
+ }
6448
+ this.runRegistry.dispose(runId);
6449
+ const latest = this.runRegistry.get(runId);
6450
+ if (latest)
6451
+ this.drainPendingDelegatedReviews(latest);
6452
+ }
6346
6453
  // Issue #904: shared exit handler for all FRAIM onExit chokepoints.
6347
6454
  // Classifies the exit (classifyExit), writes pauseReason onto the run,
6348
6455
  // then either parks the run or schedules a recovery continue turn.
@@ -6384,6 +6491,29 @@ class AiHubServer {
6384
6491
  const message = classification.recoveryKind === 'compaction'
6385
6492
  ? buildHubCompactionRecoveryContinueMessage(current, exitCode, attempt)
6386
6493
  : buildHubRecoveryContinueMessage(current, exitCode, attempt);
6494
+ // Issue #1150: recovery must relaunch as the SAME configured agent. Its setup
6495
+ // script supplies the env that selects the profile the host session lives in
6496
+ // (CODEX_HOME for Codex), and resuming without it sends `codex exec resume`
6497
+ // looking for the thread in a home that never held it. HostRuntime now requires
6498
+ // a launch context on every relaunch, so this can no longer be omitted silently.
6499
+ let launchContext;
6500
+ try {
6501
+ launchContext = this.resolveLaunchAgent(current.configuredAgentId, current.hostId).launchContext;
6502
+ }
6503
+ catch (error) {
6504
+ if (usesCustomConfiguredAgent(current)) {
6505
+ // A real profile that we can no longer resolve. Resuming anyway would run
6506
+ // the host against the wrong provider/home, which is the failure this issue
6507
+ // is about, so park with the reason rather than retry blind.
6508
+ this.parkRunAsRecoveryError(runId, exitCode, `Auto-recovery stopped: ${current.configuredAgentLabel || current.configuredAgentId} could not be resolved for this run. `
6509
+ + `${error instanceof Error ? error.message : String(error)}`, postPark);
6510
+ return;
6511
+ }
6512
+ // Synthesized `<host>-default` agents carry no setup-script env, so there is
6513
+ // nothing to preserve and nothing to protect against. An empty context is
6514
+ // exactly what one resolves to, and leaves the host plan undecorated.
6515
+ launchContext = {};
6516
+ }
6387
6517
  const child = this.hostRuntime.continueRun(current.hostId, current.projectPath, current.sessionId, message, {
6388
6518
  onEvent: (event, channel) => {
6389
6519
  this.runRegistry.update(runId, (r) => {
@@ -6403,7 +6533,7 @@ class AiHubServer {
6403
6533
  this.scheduleRunConversationPersistence(updated, updated.conversationId || updated.id);
6404
6534
  },
6405
6535
  onExit: (code) => this.handleRunExit(runId, code, postPark),
6406
- });
6536
+ }, launchContext);
6407
6537
  this.runRegistry.attachChildIfRunning(runId, child);
6408
6538
  }, delay);
6409
6539
  if (tid.unref)