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.
- package/dist/src/ai-hub/catalog.js +3 -17
- package/dist/src/ai-hub/conversation-store.js +66 -3
- package/dist/src/ai-hub/hosts.js +15 -1
- package/dist/src/ai-hub/restart-recovery-policy.js +14 -0
- package/dist/src/ai-hub/server.js +144 -14
- package/dist/src/ai-hub/stale-bucket-sweep.js +162 -0
- package/dist/src/cli/commands/add-ide.js +28 -2
- package/dist/src/cli/fraim-hub.js +3 -0
- package/dist/src/core/resolve-phase-edge.js +75 -0
- package/dist/src/core/utils/git-utils.js +24 -14
- package/dist/src/core/utils/project-fraim-paths.js +16 -1
- package/dist/src/local-mcp-server/artifact-retention-cleanup.js +306 -0
- package/dist/src/local-mcp-server/learning-context-builder.js +448 -95
- package/dist/src/local-mcp-server/learning-usage-store.js +417 -0
- package/package.json +5 -2
- package/public/ai-hub/script.js +125 -17
- package/public/ai-hub/styles.css +19 -0
|
@@ -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 =
|
|
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
|
|
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);
|
package/dist/src/ai-hub/hosts.js
CHANGED
|
@@ -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
|
-
|
|
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
|
-
|
|
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()
|
|
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.
|
|
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:
|
|
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:
|
|
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
|
-
|
|
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)
|