fix: stabilize team launch runtime status
This commit is contained in:
parent
46df757f49
commit
e96f97d83d
22 changed files with 1784 additions and 81 deletions
|
|
@ -264,6 +264,7 @@ function drawLaunchStage(
|
||||||
|
|
||||||
ctx.save();
|
ctx.save();
|
||||||
switch (visualState) {
|
switch (visualState) {
|
||||||
|
case 'queued':
|
||||||
case 'waiting': {
|
case 'waiting': {
|
||||||
const ringR = r + 8 + Math.sin(time * 3.2) * 1.4;
|
const ringR = r + 8 + Math.sin(time * 3.2) * 1.4;
|
||||||
const pulseAlpha = 0.28 + 0.18 * (0.5 + 0.5 * Math.sin(time * 3.2));
|
const pulseAlpha = 0.28 + 0.18 * (0.5 + 0.5 * Math.sin(time * 3.2));
|
||||||
|
|
@ -778,6 +779,7 @@ function truncateSubLabel(ctx: CanvasRenderingContext2D, label: string, r: numbe
|
||||||
|
|
||||||
function getLaunchStatusColor(visualState: GraphNode['launchVisualState']): string {
|
function getLaunchStatusColor(visualState: GraphNode['launchVisualState']): string {
|
||||||
switch (visualState) {
|
switch (visualState) {
|
||||||
|
case 'queued':
|
||||||
case 'waiting':
|
case 'waiting':
|
||||||
return hexWithAlpha('#d4d4d8', 0.8);
|
return hexWithAlpha('#d4d4d8', 0.8);
|
||||||
case 'spawning':
|
case 'spawning':
|
||||||
|
|
|
||||||
|
|
@ -27,6 +27,7 @@ export type GraphLaunchVisualState =
|
||||||
| 'registered_only'
|
| 'registered_only'
|
||||||
| 'stale_runtime'
|
| 'stale_runtime'
|
||||||
| 'settling'
|
| 'settling'
|
||||||
|
| 'queued'
|
||||||
| 'error'
|
| 'error'
|
||||||
| 'skipped';
|
| 'skipped';
|
||||||
|
|
||||||
|
|
|
||||||
|
|
@ -136,6 +136,7 @@ import {
|
||||||
writeTeamControlApiState,
|
writeTeamControlApiState,
|
||||||
} from './services/team/TeamControlApiState';
|
} from './services/team/TeamControlApiState';
|
||||||
import { TeamInboxReader } from './services/team/TeamInboxReader';
|
import { TeamInboxReader } from './services/team/TeamInboxReader';
|
||||||
|
import { getTeamDataWorkerClient } from './services/team/TeamDataWorkerClient';
|
||||||
import { TeamMemberRuntimeAdvisoryService } from './services/team/TeamMemberRuntimeAdvisoryService';
|
import { TeamMemberRuntimeAdvisoryService } from './services/team/TeamMemberRuntimeAdvisoryService';
|
||||||
import {
|
import {
|
||||||
createTeamReconcileDrainScheduler,
|
createTeamReconcileDrainScheduler,
|
||||||
|
|
@ -807,6 +808,11 @@ function wireFileWatcherEvents(context: ServiceContext): void {
|
||||||
const detail = typeof row.detail === 'string' ? row.detail : '';
|
const detail = typeof row.detail === 'string' ? row.detail : '';
|
||||||
memberWorkSyncFeature?.noteTeamChange(row as TeamChangeEvent);
|
memberWorkSyncFeature?.noteTeamChange(row as TeamChangeEvent);
|
||||||
|
|
||||||
|
if (row.type === 'config' && detail === 'config.json') {
|
||||||
|
TeamConfigReader.invalidateTeam(teamName);
|
||||||
|
getTeamDataWorkerClient().invalidateTeamConfig(teamName);
|
||||||
|
}
|
||||||
|
|
||||||
if (
|
if (
|
||||||
teamDataService &&
|
teamDataService &&
|
||||||
(row.type === 'inbox' || row.type === 'lead-message' || row.type === 'config')
|
(row.type === 'inbox' || row.type === 'lead-message' || row.type === 'config')
|
||||||
|
|
@ -1197,6 +1203,10 @@ async function initializeServices(): Promise<void> {
|
||||||
});
|
});
|
||||||
|
|
||||||
const forwardTeamChange = (event: TeamChangeEvent): void => {
|
const forwardTeamChange = (event: TeamChangeEvent): void => {
|
||||||
|
if (event.type === 'config' && event.detail === 'config.json') {
|
||||||
|
TeamConfigReader.invalidateTeam(event.teamName);
|
||||||
|
getTeamDataWorkerClient().invalidateTeamConfig(event.teamName);
|
||||||
|
}
|
||||||
if (
|
if (
|
||||||
teamDataService &&
|
teamDataService &&
|
||||||
(event.type === 'inbox' || event.type === 'lead-message' || event.type === 'config')
|
(event.type === 'inbox' || event.type === 'lead-message' || event.type === 'config')
|
||||||
|
|
|
||||||
|
|
@ -11,6 +11,8 @@ import {
|
||||||
} from '@main/utils/pathDecoder';
|
} from '@main/utils/pathDecoder';
|
||||||
import { createLogger } from '@shared/utils/logger';
|
import { createLogger } from '@shared/utils/logger';
|
||||||
|
|
||||||
|
import { TeamConfigReader } from './TeamConfigReader';
|
||||||
|
|
||||||
const logger = createLogger('TeamBackupService');
|
const logger = createLogger('TeamBackupService');
|
||||||
|
|
||||||
// ---------------------------------------------------------------------------
|
// ---------------------------------------------------------------------------
|
||||||
|
|
@ -602,6 +604,7 @@ export class TeamBackupService {
|
||||||
if (config._backupIdentityId === identityId) return;
|
if (config._backupIdentityId === identityId) return;
|
||||||
config._backupIdentityId = identityId;
|
config._backupIdentityId = identityId;
|
||||||
await atomicWriteAsync(configPath, JSON.stringify(config, null, 2));
|
await atomicWriteAsync(configPath, JSON.stringify(config, null, 2));
|
||||||
|
TeamConfigReader.invalidateTeam(teamName);
|
||||||
} catch {
|
} catch {
|
||||||
// best-effort — config may not exist yet
|
// best-effort — config may not exist yet
|
||||||
}
|
}
|
||||||
|
|
@ -661,6 +664,7 @@ export class TeamBackupService {
|
||||||
await fs.promises.mkdir(path.dirname(configDest), { recursive: true });
|
await fs.promises.mkdir(path.dirname(configDest), { recursive: true });
|
||||||
const content = await fs.promises.readFile(configBackup, 'utf8');
|
const content = await fs.promises.readFile(configBackup, 'utf8');
|
||||||
await atomicWriteAsync(configDest, content);
|
await atomicWriteAsync(configDest, content);
|
||||||
|
TeamConfigReader.invalidateTeam(teamName);
|
||||||
count++;
|
count++;
|
||||||
} catch (err: unknown) {
|
} catch (err: unknown) {
|
||||||
logger.warn(`[Backup] Failed to restore config.json for ${teamName}: ${String(err)}`);
|
logger.warn(`[Backup] Failed to restore config.json for ${teamName}: ${String(err)}`);
|
||||||
|
|
|
||||||
|
|
@ -37,16 +37,38 @@ const LARGE_CONFIG_BYTES = 512 * 1024;
|
||||||
const CONFIG_HEAD_BYTES = 64 * 1024;
|
const CONFIG_HEAD_BYTES = 64 * 1024;
|
||||||
const MAX_CONFIG_READ_BYTES = 10 * 1024 * 1024; // 10MB hard limit for full config reads
|
const MAX_CONFIG_READ_BYTES = 10 * 1024 * 1024; // 10MB hard limit for full config reads
|
||||||
const PER_TEAM_READ_TIMEOUT_MS = 5_000;
|
const PER_TEAM_READ_TIMEOUT_MS = 5_000;
|
||||||
const GET_CONFIG_CACHE_TTL_MS = 750;
|
|
||||||
const GET_CONFIG_SLOW_READ_WARN_MS = 500;
|
const GET_CONFIG_SLOW_READ_WARN_MS = 500;
|
||||||
|
const CONFIG_SNAPSHOT_RECENT_STAT_FAILURE_FALLBACK_MS = 5_000;
|
||||||
|
const COARSE_FS_FULL_VERIFY_MS = 1_500;
|
||||||
const MAX_SESSION_HISTORY_IN_SUMMARY = 2000;
|
const MAX_SESSION_HISTORY_IN_SUMMARY = 2000;
|
||||||
const MAX_PROJECT_PATH_HISTORY_IN_SUMMARY = 200;
|
const MAX_PROJECT_PATH_HISTORY_IN_SUMMARY = 200;
|
||||||
const MAX_LAUNCH_STATE_BYTES = 32 * 1024;
|
const MAX_LAUNCH_STATE_BYTES = 32 * 1024;
|
||||||
const TEAM_LAUNCH_STATE_FILE = 'launch-state.json';
|
const TEAM_LAUNCH_STATE_FILE = 'launch-state.json';
|
||||||
|
|
||||||
|
export interface TeamConfigFingerprint {
|
||||||
|
size: string;
|
||||||
|
mode: string;
|
||||||
|
dev?: string;
|
||||||
|
ino?: string;
|
||||||
|
mtimeNs?: string;
|
||||||
|
ctimeNs?: string;
|
||||||
|
birthtimeNs?: string;
|
||||||
|
mtimeMs: number;
|
||||||
|
ctimeMs: number;
|
||||||
|
birthtimeMs: number;
|
||||||
|
}
|
||||||
|
|
||||||
|
interface InternalTeamConfigFingerprint extends TeamConfigFingerprint {
|
||||||
|
isFile: boolean;
|
||||||
|
highResolution: boolean;
|
||||||
|
numericSize: number;
|
||||||
|
}
|
||||||
|
|
||||||
interface CachedTeamConfig {
|
interface CachedTeamConfig {
|
||||||
value: TeamConfig;
|
value: TeamConfig;
|
||||||
expiresAt: number;
|
fingerprint: InternalTeamConfigFingerprint | null;
|
||||||
|
verifiedAt: number;
|
||||||
|
fullVerifiedAt: number;
|
||||||
}
|
}
|
||||||
|
|
||||||
interface ConfigReadTiming {
|
interface ConfigReadTiming {
|
||||||
|
|
@ -178,10 +200,65 @@ function cloneConfig(config: TeamConfig): TeamConfig {
|
||||||
export class TeamConfigReader {
|
export class TeamConfigReader {
|
||||||
private static readonly configCacheByPath = new Map<string, CachedTeamConfig>();
|
private static readonly configCacheByPath = new Map<string, CachedTeamConfig>();
|
||||||
private static readonly configReadInFlightByPath = new Map<string, Promise<TeamConfig | null>>();
|
private static readonly configReadInFlightByPath = new Map<string, Promise<TeamConfig | null>>();
|
||||||
|
private static readonly configStatInFlightByPath = new Map<
|
||||||
|
string,
|
||||||
|
Promise<InternalTeamConfigFingerprint | null>
|
||||||
|
>();
|
||||||
|
private static readonly configGenerationByPath = new Map<string, number>();
|
||||||
|
|
||||||
static clearCacheForTests(): void {
|
static clearCacheForTests(): void {
|
||||||
TeamConfigReader.configCacheByPath.clear();
|
TeamConfigReader.configCacheByPath.clear();
|
||||||
TeamConfigReader.configReadInFlightByPath.clear();
|
TeamConfigReader.configReadInFlightByPath.clear();
|
||||||
|
TeamConfigReader.configStatInFlightByPath.clear();
|
||||||
|
TeamConfigReader.configGenerationByPath.clear();
|
||||||
|
}
|
||||||
|
|
||||||
|
static invalidateTeam(teamName: string): void {
|
||||||
|
const configPath = path.join(getTeamsBasePath(), teamName, 'config.json');
|
||||||
|
TeamConfigReader.invalidatePath(configPath);
|
||||||
|
}
|
||||||
|
|
||||||
|
static invalidatePath(configPath: string): void {
|
||||||
|
TeamConfigReader.configCacheByPath.delete(configPath);
|
||||||
|
TeamConfigReader.configReadInFlightByPath.delete(configPath);
|
||||||
|
TeamConfigReader.configStatInFlightByPath.delete(configPath);
|
||||||
|
TeamConfigReader.bumpConfigGeneration(configPath);
|
||||||
|
}
|
||||||
|
|
||||||
|
private static invalidatePathForGeneration(
|
||||||
|
configPath: string,
|
||||||
|
expectedGeneration?: number
|
||||||
|
): void {
|
||||||
|
if (
|
||||||
|
typeof expectedGeneration === 'number' &&
|
||||||
|
TeamConfigReader.getConfigGeneration(configPath) !== expectedGeneration
|
||||||
|
) {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
TeamConfigReader.invalidatePath(configPath);
|
||||||
|
}
|
||||||
|
|
||||||
|
static async primeConfig(
|
||||||
|
teamName: string,
|
||||||
|
config: TeamConfig,
|
||||||
|
fingerprint?: TeamConfigFingerprint | null
|
||||||
|
): Promise<void> {
|
||||||
|
const configPath = path.join(getTeamsBasePath(), teamName, 'config.json');
|
||||||
|
const generation = TeamConfigReader.bumpConfigGeneration(configPath);
|
||||||
|
let internalFingerprint: InternalTeamConfigFingerprint | null = null;
|
||||||
|
if (fingerprint) {
|
||||||
|
internalFingerprint = {
|
||||||
|
...fingerprint,
|
||||||
|
isFile: true,
|
||||||
|
highResolution: Boolean(fingerprint.mtimeNs || fingerprint.ctimeNs),
|
||||||
|
numericSize: Number(fingerprint.size),
|
||||||
|
};
|
||||||
|
} else {
|
||||||
|
internalFingerprint = await TeamConfigReader.readConfigFingerprint(configPath).catch(
|
||||||
|
() => null
|
||||||
|
);
|
||||||
|
}
|
||||||
|
TeamConfigReader.storeConfigCache(configPath, config, internalFingerprint, true, generation);
|
||||||
}
|
}
|
||||||
|
|
||||||
constructor(
|
constructor(
|
||||||
|
|
@ -533,27 +610,18 @@ export class TeamConfigReader {
|
||||||
}
|
}
|
||||||
|
|
||||||
async getConfig(teamName: string): Promise<TeamConfig | null> {
|
async getConfig(teamName: string): Promise<TeamConfig | null> {
|
||||||
const configPath = path.join(getTeamsBasePath(), teamName, 'config.json');
|
return this.getConfigVerified(teamName);
|
||||||
const now = Date.now();
|
}
|
||||||
const cached = TeamConfigReader.configCacheByPath.get(configPath);
|
|
||||||
if (cached && cached.expiresAt > now) {
|
|
||||||
return cloneConfig(cached.value);
|
|
||||||
}
|
|
||||||
|
|
||||||
|
async getConfigVerified(teamName: string): Promise<TeamConfig | null> {
|
||||||
|
const configPath = path.join(getTeamsBasePath(), teamName, 'config.json');
|
||||||
const existingRead = TeamConfigReader.configReadInFlightByPath.get(configPath);
|
const existingRead = TeamConfigReader.configReadInFlightByPath.get(configPath);
|
||||||
if (existingRead) {
|
if (existingRead) {
|
||||||
return this.resolveConfigRead(teamName, configPath, existingRead);
|
return this.resolveConfigRead(teamName, configPath, existingRead);
|
||||||
}
|
}
|
||||||
|
|
||||||
const readPromise = this.readConfigFromDisk(teamName, configPath).then((config) => {
|
const generation = TeamConfigReader.getConfigGeneration(configPath);
|
||||||
if (config) {
|
const readPromise = this.readConfigFromDisk(teamName, configPath, null, true, generation);
|
||||||
TeamConfigReader.configCacheByPath.set(configPath, {
|
|
||||||
value: cloneConfig(config),
|
|
||||||
expiresAt: Date.now() + GET_CONFIG_CACHE_TTL_MS,
|
|
||||||
});
|
|
||||||
}
|
|
||||||
return config;
|
|
||||||
});
|
|
||||||
TeamConfigReader.configReadInFlightByPath.set(configPath, readPromise);
|
TeamConfigReader.configReadInFlightByPath.set(configPath, readPromise);
|
||||||
|
|
||||||
try {
|
try {
|
||||||
|
|
@ -565,6 +633,88 @@ export class TeamConfigReader {
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
async getConfigSnapshot(teamName: string): Promise<TeamConfig | null> {
|
||||||
|
const configPath = path.join(getTeamsBasePath(), teamName, 'config.json');
|
||||||
|
|
||||||
|
for (let attempt = 0; attempt < 3; attempt++) {
|
||||||
|
const generationAtStart = TeamConfigReader.getConfigGeneration(configPath);
|
||||||
|
let fingerprint: InternalTeamConfigFingerprint | null;
|
||||||
|
|
||||||
|
try {
|
||||||
|
fingerprint = await TeamConfigReader.getConfigFingerprint(configPath);
|
||||||
|
} catch (error) {
|
||||||
|
if (TeamConfigReader.getConfigGeneration(configPath) !== generationAtStart) {
|
||||||
|
continue;
|
||||||
|
}
|
||||||
|
const cached = TeamConfigReader.configCacheByPath.get(configPath);
|
||||||
|
if (
|
||||||
|
cached &&
|
||||||
|
Date.now() - cached.verifiedAt <= CONFIG_SNAPSHOT_RECENT_STAT_FAILURE_FALLBACK_MS
|
||||||
|
) {
|
||||||
|
logger.warn(
|
||||||
|
`[getConfigSnapshot] config_snapshot_stat_failed_using_recent_cache team=${teamName} error=${
|
||||||
|
error instanceof Error ? error.message : String(error)
|
||||||
|
}`
|
||||||
|
);
|
||||||
|
return cloneConfig(cached.value);
|
||||||
|
}
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
|
||||||
|
if (TeamConfigReader.getConfigGeneration(configPath) !== generationAtStart) {
|
||||||
|
continue;
|
||||||
|
}
|
||||||
|
|
||||||
|
if (!fingerprint?.isFile || fingerprint.numericSize > MAX_CONFIG_READ_BYTES) {
|
||||||
|
TeamConfigReader.invalidatePathForGeneration(configPath, generationAtStart);
|
||||||
|
if (fingerprint && fingerprint.numericSize > MAX_CONFIG_READ_BYTES) {
|
||||||
|
logger.warn(
|
||||||
|
`Refusing to load oversized config.json (${fingerprint.numericSize} bytes) for team: ${teamName}`
|
||||||
|
);
|
||||||
|
}
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
|
||||||
|
const cached = TeamConfigReader.configCacheByPath.get(configPath);
|
||||||
|
if (
|
||||||
|
cached?.fingerprint &&
|
||||||
|
TeamConfigReader.fingerprintsEqual(cached.fingerprint, fingerprint)
|
||||||
|
) {
|
||||||
|
const now = Date.now();
|
||||||
|
const mustRevalidateCoarseFingerprint =
|
||||||
|
!fingerprint.highResolution && now - cached.fullVerifiedAt >= COARSE_FS_FULL_VERIFY_MS;
|
||||||
|
if (!mustRevalidateCoarseFingerprint) {
|
||||||
|
cached.verifiedAt = now;
|
||||||
|
return cloneConfig(cached.value);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
const existingRead = TeamConfigReader.configReadInFlightByPath.get(configPath);
|
||||||
|
if (existingRead) {
|
||||||
|
return this.resolveConfigRead(teamName, configPath, existingRead);
|
||||||
|
}
|
||||||
|
|
||||||
|
const generation = TeamConfigReader.getConfigGeneration(configPath);
|
||||||
|
const readPromise = this.readConfigFromDisk(
|
||||||
|
teamName,
|
||||||
|
configPath,
|
||||||
|
fingerprint,
|
||||||
|
true,
|
||||||
|
generation
|
||||||
|
);
|
||||||
|
TeamConfigReader.configReadInFlightByPath.set(configPath, readPromise);
|
||||||
|
try {
|
||||||
|
return await this.resolveConfigRead(teamName, configPath, readPromise);
|
||||||
|
} finally {
|
||||||
|
if (TeamConfigReader.configReadInFlightByPath.get(configPath) === readPromise) {
|
||||||
|
TeamConfigReader.configReadInFlightByPath.delete(configPath);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
|
||||||
private async resolveConfigRead(
|
private async resolveConfigRead(
|
||||||
teamName: string,
|
teamName: string,
|
||||||
configPath: string,
|
configPath: string,
|
||||||
|
|
@ -578,9 +728,121 @@ export class TeamConfigReader {
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
private static async getConfigFingerprint(
|
||||||
|
configPath: string
|
||||||
|
): Promise<InternalTeamConfigFingerprint | null> {
|
||||||
|
const existing = TeamConfigReader.configStatInFlightByPath.get(configPath);
|
||||||
|
if (existing) return existing;
|
||||||
|
|
||||||
|
const statPromise = TeamConfigReader.readConfigFingerprint(configPath).finally(() => {
|
||||||
|
if (TeamConfigReader.configStatInFlightByPath.get(configPath) === statPromise) {
|
||||||
|
TeamConfigReader.configStatInFlightByPath.delete(configPath);
|
||||||
|
}
|
||||||
|
});
|
||||||
|
TeamConfigReader.configStatInFlightByPath.set(configPath, statPromise);
|
||||||
|
return statPromise;
|
||||||
|
}
|
||||||
|
|
||||||
|
private static async readConfigFingerprint(
|
||||||
|
configPath: string
|
||||||
|
): Promise<InternalTeamConfigFingerprint | null> {
|
||||||
|
let stat: fs.BigIntStats;
|
||||||
|
try {
|
||||||
|
stat = await withReadTimeout(
|
||||||
|
fs.promises.stat(configPath, { bigint: true }),
|
||||||
|
PER_TEAM_READ_TIMEOUT_MS
|
||||||
|
);
|
||||||
|
} catch (error) {
|
||||||
|
const code = typeof error === 'object' && error ? (error as { code?: unknown }).code : null;
|
||||||
|
if (code === 'ENOENT') {
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
throw error;
|
||||||
|
}
|
||||||
|
|
||||||
|
const highResStat = stat as fs.BigIntStats & {
|
||||||
|
mtimeNs?: bigint;
|
||||||
|
ctimeNs?: bigint;
|
||||||
|
birthtimeNs?: bigint;
|
||||||
|
};
|
||||||
|
const mtimeNs = highResStat.mtimeNs;
|
||||||
|
const ctimeNs = highResStat.ctimeNs;
|
||||||
|
const birthtimeNs = highResStat.birthtimeNs;
|
||||||
|
|
||||||
|
return {
|
||||||
|
size: stat.size.toString(),
|
||||||
|
mode: stat.mode.toString(),
|
||||||
|
dev: stat.dev.toString(),
|
||||||
|
ino: stat.ino.toString(),
|
||||||
|
mtimeNs: typeof mtimeNs === 'bigint' ? mtimeNs.toString() : undefined,
|
||||||
|
ctimeNs: typeof ctimeNs === 'bigint' ? ctimeNs.toString() : undefined,
|
||||||
|
birthtimeNs: typeof birthtimeNs === 'bigint' ? birthtimeNs.toString() : undefined,
|
||||||
|
mtimeMs: Number(stat.mtimeMs),
|
||||||
|
ctimeMs: Number(stat.ctimeMs),
|
||||||
|
birthtimeMs: Number(stat.birthtimeMs),
|
||||||
|
isFile: stat.isFile(),
|
||||||
|
highResolution: typeof mtimeNs === 'bigint' || typeof ctimeNs === 'bigint',
|
||||||
|
numericSize: Number(stat.size),
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
private static fingerprintsEqual(
|
||||||
|
a: InternalTeamConfigFingerprint,
|
||||||
|
b: InternalTeamConfigFingerprint
|
||||||
|
): boolean {
|
||||||
|
return (
|
||||||
|
a.size === b.size &&
|
||||||
|
a.mode === b.mode &&
|
||||||
|
a.dev === b.dev &&
|
||||||
|
a.ino === b.ino &&
|
||||||
|
a.mtimeNs === b.mtimeNs &&
|
||||||
|
a.ctimeNs === b.ctimeNs &&
|
||||||
|
a.birthtimeNs === b.birthtimeNs &&
|
||||||
|
a.mtimeMs === b.mtimeMs &&
|
||||||
|
a.ctimeMs === b.ctimeMs &&
|
||||||
|
a.birthtimeMs === b.birthtimeMs
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
private static storeConfigCache(
|
||||||
|
configPath: string,
|
||||||
|
config: TeamConfig,
|
||||||
|
fingerprint: InternalTeamConfigFingerprint | null,
|
||||||
|
fullVerified: boolean,
|
||||||
|
expectedGeneration?: number
|
||||||
|
): void {
|
||||||
|
if (
|
||||||
|
typeof expectedGeneration === 'number' &&
|
||||||
|
TeamConfigReader.getConfigGeneration(configPath) !== expectedGeneration
|
||||||
|
) {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
const now = Date.now();
|
||||||
|
const previous = TeamConfigReader.configCacheByPath.get(configPath);
|
||||||
|
TeamConfigReader.configCacheByPath.set(configPath, {
|
||||||
|
value: cloneConfig(config),
|
||||||
|
fingerprint,
|
||||||
|
verifiedAt: now,
|
||||||
|
fullVerifiedAt: fullVerified ? now : (previous?.fullVerifiedAt ?? now),
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
private static getConfigGeneration(configPath: string): number {
|
||||||
|
return TeamConfigReader.configGenerationByPath.get(configPath) ?? 0;
|
||||||
|
}
|
||||||
|
|
||||||
|
private static bumpConfigGeneration(configPath: string): number {
|
||||||
|
const next = TeamConfigReader.getConfigGeneration(configPath) + 1;
|
||||||
|
TeamConfigReader.configGenerationByPath.set(configPath, next);
|
||||||
|
return next;
|
||||||
|
}
|
||||||
|
|
||||||
private async readConfigFromDisk(
|
private async readConfigFromDisk(
|
||||||
teamName: string,
|
teamName: string,
|
||||||
configPath: string
|
configPath: string,
|
||||||
|
knownFingerprint: InternalTeamConfigFingerprint | null = null,
|
||||||
|
updateCache = false,
|
||||||
|
cacheGeneration?: number
|
||||||
): Promise<TeamConfig | null> {
|
): Promise<TeamConfig | null> {
|
||||||
const startedAt = performance.now();
|
const startedAt = performance.now();
|
||||||
let size: number | null = null;
|
let size: number | null = null;
|
||||||
|
|
@ -599,17 +861,20 @@ export class TeamConfigReader {
|
||||||
|
|
||||||
try {
|
try {
|
||||||
const statStartedAt = performance.now();
|
const statStartedAt = performance.now();
|
||||||
const stat = await fs.promises.stat(configPath);
|
const fingerprint =
|
||||||
|
knownFingerprint ?? (await TeamConfigReader.getConfigFingerprint(configPath));
|
||||||
statMs = Math.round(performance.now() - statStartedAt);
|
statMs = Math.round(performance.now() - statStartedAt);
|
||||||
size = stat.size;
|
size = fingerprint?.numericSize ?? null;
|
||||||
|
|
||||||
// Safety: refuse special files and huge/binary configs
|
// Safety: refuse special files and huge/binary configs
|
||||||
if (!stat.isFile()) {
|
if (!fingerprint?.isFile) {
|
||||||
|
TeamConfigReader.invalidatePathForGeneration(configPath, cacheGeneration);
|
||||||
return null;
|
return null;
|
||||||
}
|
}
|
||||||
if (stat.size > MAX_CONFIG_READ_BYTES) {
|
if (fingerprint.numericSize > MAX_CONFIG_READ_BYTES) {
|
||||||
|
TeamConfigReader.invalidatePathForGeneration(configPath, cacheGeneration);
|
||||||
logger.warn(
|
logger.warn(
|
||||||
`Refusing to load oversized config.json (${stat.size} bytes) for team: ${teamName}`
|
`Refusing to load oversized config.json (${fingerprint.numericSize} bytes) for team: ${teamName}`
|
||||||
);
|
);
|
||||||
return null;
|
return null;
|
||||||
}
|
}
|
||||||
|
|
@ -622,6 +887,7 @@ export class TeamConfigReader {
|
||||||
const config = JSON.parse(raw) as TeamConfig;
|
const config = JSON.parse(raw) as TeamConfig;
|
||||||
parseMs = Math.round(performance.now() - parseStartedAt);
|
parseMs = Math.round(performance.now() - parseStartedAt);
|
||||||
if (typeof config.name !== 'string' || config.name.trim() === '') {
|
if (typeof config.name !== 'string' || config.name.trim() === '') {
|
||||||
|
TeamConfigReader.invalidatePathForGeneration(configPath, cacheGeneration);
|
||||||
return null;
|
return null;
|
||||||
}
|
}
|
||||||
const resolvedProjectPath = resolveProjectPathFromConfig(config);
|
const resolvedProjectPath = resolveProjectPathFromConfig(config);
|
||||||
|
|
@ -633,10 +899,24 @@ export class TeamConfigReader {
|
||||||
if (totalMs >= GET_CONFIG_SLOW_READ_WARN_MS) {
|
if (totalMs >= GET_CONFIG_SLOW_READ_WARN_MS) {
|
||||||
logger.warn(`[getConfig] slow read diag=${JSON.stringify(buildTiming())}`);
|
logger.warn(`[getConfig] slow read diag=${JSON.stringify(buildTiming())}`);
|
||||||
}
|
}
|
||||||
|
if (updateCache) {
|
||||||
|
TeamConfigReader.storeConfigCache(
|
||||||
|
configPath,
|
||||||
|
resolvedConfig,
|
||||||
|
fingerprint,
|
||||||
|
true,
|
||||||
|
cacheGeneration
|
||||||
|
);
|
||||||
|
}
|
||||||
return resolvedConfig;
|
return resolvedConfig;
|
||||||
} catch (error) {
|
} catch (error) {
|
||||||
|
TeamConfigReader.invalidatePathForGeneration(configPath, cacheGeneration);
|
||||||
if (error instanceof FileReadTimeoutError) {
|
if (error instanceof FileReadTimeoutError) {
|
||||||
logger.warn(`[getConfig] ${error.message} diag=${JSON.stringify(buildTiming())}`);
|
logger.warn(`[getConfig] ${error.message} diag=${JSON.stringify(buildTiming())}`);
|
||||||
|
} else if (error instanceof Error && error.message === 'Team config read timeout') {
|
||||||
|
logger.warn(
|
||||||
|
`[getConfig] Timed out after ${PER_TEAM_READ_TIMEOUT_MS}ms reading ${configPath} diag=${JSON.stringify(buildTiming())}`
|
||||||
|
);
|
||||||
}
|
}
|
||||||
throw error;
|
throw error;
|
||||||
}
|
}
|
||||||
|
|
@ -664,10 +944,7 @@ export class TeamConfigReader {
|
||||||
}
|
}
|
||||||
const configPath = path.join(getTeamsBasePath(), teamName, 'config.json');
|
const configPath = path.join(getTeamsBasePath(), teamName, 'config.json');
|
||||||
await fs.promises.writeFile(configPath, JSON.stringify(config, null, 2), 'utf8');
|
await fs.promises.writeFile(configPath, JSON.stringify(config, null, 2), 'utf8');
|
||||||
TeamConfigReader.configCacheByPath.set(configPath, {
|
await TeamConfigReader.primeConfig(teamName, config);
|
||||||
value: cloneConfig(config),
|
|
||||||
expiresAt: Date.now() + GET_CONFIG_CACHE_TTL_MS,
|
|
||||||
});
|
|
||||||
return config;
|
return config;
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
|
||||||
|
|
@ -267,6 +267,25 @@ function extractPassiveUserPeerSummaryBody(text: string): string | null {
|
||||||
return body.length > 0 ? body : null;
|
return body.length > 0 ? body : null;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
function readConfigForUiSnapshot(
|
||||||
|
configReader: TeamConfigReader & {
|
||||||
|
getConfigSnapshot?: (teamName: string) => Promise<TeamConfig | null>;
|
||||||
|
},
|
||||||
|
teamName: string
|
||||||
|
): Promise<TeamConfig | null> {
|
||||||
|
return typeof configReader.getConfigSnapshot === 'function'
|
||||||
|
? configReader.getConfigSnapshot(teamName)
|
||||||
|
: configReader.getConfig(teamName);
|
||||||
|
}
|
||||||
|
|
||||||
|
function createUiSnapshotProjectResolver(
|
||||||
|
configReader: TeamConfigReader
|
||||||
|
): TeamTranscriptProjectResolver {
|
||||||
|
return new TeamTranscriptProjectResolver({
|
||||||
|
getConfig: (teamName) => readConfigForUiSnapshot(configReader, teamName),
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
function isExplicitLeadRole(role: string | undefined): boolean {
|
function isExplicitLeadRole(role: string | undefined): boolean {
|
||||||
const normalized = role?.trim().toLowerCase();
|
const normalized = role?.trim().toLowerCase();
|
||||||
return normalized === 'lead' || normalized === 'team lead' || normalized === 'team-lead';
|
return normalized === 'lead' || normalized === 'team lead' || normalized === 'team-lead';
|
||||||
|
|
@ -385,13 +404,13 @@ export class TeamDataService {
|
||||||
private readonly teamMetaStore: TeamMetaStore = new TeamMetaStore(),
|
private readonly teamMetaStore: TeamMetaStore = new TeamMetaStore(),
|
||||||
private memberRuntimeAdvisoryService: TeamMemberRuntimeAdvisoryService = new TeamMemberRuntimeAdvisoryService(),
|
private memberRuntimeAdvisoryService: TeamMemberRuntimeAdvisoryService = new TeamMemberRuntimeAdvisoryService(),
|
||||||
private readonly leadSessionParseCache: LeadSessionParseCache = new LeadSessionParseCache(),
|
private readonly leadSessionParseCache: LeadSessionParseCache = new LeadSessionParseCache(),
|
||||||
private readonly projectResolver: TeamTranscriptProjectResolver = new TeamTranscriptProjectResolver(
|
private readonly projectResolver: TeamTranscriptProjectResolver = createUiSnapshotProjectResolver(
|
||||||
configReader
|
configReader
|
||||||
),
|
),
|
||||||
private readonly launchStateStore: TeamLaunchStateStore = new TeamLaunchStateStore()
|
private readonly launchStateStore: TeamLaunchStateStore = new TeamLaunchStateStore()
|
||||||
) {
|
) {
|
||||||
this.messageFeedService = new TeamMessageFeedService({
|
this.messageFeedService = new TeamMessageFeedService({
|
||||||
getConfig: (teamName) => this.configReader.getConfig(teamName),
|
getConfig: (teamName) => this.readSnapshotConfig(teamName),
|
||||||
getInboxMessages: (teamName) => this.inboxReader.getMessages(teamName),
|
getInboxMessages: (teamName) => this.inboxReader.getMessages(teamName),
|
||||||
getLeadSessionMessages: (teamName, config) => this.extractLeadSessionTexts(teamName, config),
|
getLeadSessionMessages: (teamName, config) => this.extractLeadSessionTexts(teamName, config),
|
||||||
getSentMessages: (teamName) => this.sentMessagesStore.readMessages(teamName),
|
getSentMessages: (teamName) => this.sentMessagesStore.readMessages(teamName),
|
||||||
|
|
@ -399,6 +418,10 @@ export class TeamDataService {
|
||||||
this.memberActivityMetaService = new MemberActivityMetaService(this.messageFeedService);
|
this.memberActivityMetaService = new MemberActivityMetaService(this.messageFeedService);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
private readSnapshotConfig(teamName: string): Promise<TeamConfig | null> {
|
||||||
|
return readConfigForUiSnapshot(this.configReader, teamName);
|
||||||
|
}
|
||||||
|
|
||||||
private getController(teamName: string): AgentTeamsController {
|
private getController(teamName: string): AgentTeamsController {
|
||||||
return this.controllerFactory(teamName);
|
return this.controllerFactory(teamName);
|
||||||
}
|
}
|
||||||
|
|
@ -1077,6 +1100,7 @@ export class TeamDataService {
|
||||||
config.deletedAt = new Date().toISOString();
|
config.deletedAt = new Date().toISOString();
|
||||||
const configPath = path.join(getTeamsBasePath(), teamName, 'config.json');
|
const configPath = path.join(getTeamsBasePath(), teamName, 'config.json');
|
||||||
await atomicWriteAsync(configPath, JSON.stringify(config, null, 2));
|
await atomicWriteAsync(configPath, JSON.stringify(config, null, 2));
|
||||||
|
await TeamConfigReader.primeConfig(teamName, config);
|
||||||
}
|
}
|
||||||
|
|
||||||
async restoreTeam(teamName: string): Promise<void> {
|
async restoreTeam(teamName: string): Promise<void> {
|
||||||
|
|
@ -1087,11 +1111,13 @@ export class TeamDataService {
|
||||||
delete config.deletedAt;
|
delete config.deletedAt;
|
||||||
const configPath = path.join(getTeamsBasePath(), teamName, 'config.json');
|
const configPath = path.join(getTeamsBasePath(), teamName, 'config.json');
|
||||||
await atomicWriteAsync(configPath, JSON.stringify(config, null, 2));
|
await atomicWriteAsync(configPath, JSON.stringify(config, null, 2));
|
||||||
|
await TeamConfigReader.primeConfig(teamName, config);
|
||||||
}
|
}
|
||||||
|
|
||||||
async permanentlyDeleteTeam(teamName: string): Promise<void> {
|
async permanentlyDeleteTeam(teamName: string): Promise<void> {
|
||||||
const teamsDir = path.join(getTeamsBasePath(), teamName);
|
const teamsDir = path.join(getTeamsBasePath(), teamName);
|
||||||
await fs.promises.rm(teamsDir, { recursive: true, force: true });
|
await fs.promises.rm(teamsDir, { recursive: true, force: true });
|
||||||
|
TeamConfigReader.invalidateTeam(teamName);
|
||||||
|
|
||||||
const tasksDir = path.join(getTasksBasePath(), teamName);
|
const tasksDir = path.join(getTasksBasePath(), teamName);
|
||||||
await fs.promises.rm(tasksDir, { recursive: true, force: true });
|
await fs.promises.rm(tasksDir, { recursive: true, force: true });
|
||||||
|
|
@ -1113,7 +1139,7 @@ export class TeamDataService {
|
||||||
return typeof fromTs === 'number' && typeof toTs === 'number' ? toTs - fromTs : -1;
|
return typeof fromTs === 'number' && typeof toTs === 'number' ? toTs - fromTs : -1;
|
||||||
};
|
};
|
||||||
|
|
||||||
const config = await this.configReader.getConfig(teamName);
|
const config = await this.readSnapshotConfig(teamName);
|
||||||
if (!config) {
|
if (!config) {
|
||||||
throw new Error(`Team not found: ${teamName}`);
|
throw new Error(`Team not found: ${teamName}`);
|
||||||
}
|
}
|
||||||
|
|
|
||||||
|
|
@ -68,6 +68,7 @@ export class TeamDataWorkerClient {
|
||||||
private readonly workerPath: string | null = resolveWorkerPath();
|
private readonly workerPath: string | null = resolveWorkerPath();
|
||||||
private warnedUnavailable = false;
|
private warnedUnavailable = false;
|
||||||
private pending = new Map<string, PendingEntry>();
|
private pending = new Map<string, PendingEntry>();
|
||||||
|
private getTeamDataInFlight = new Map<string, Promise<TeamViewSnapshot>>();
|
||||||
|
|
||||||
private failWorker(worker: Worker, error: Error): void {
|
private failWorker(worker: Worker, error: Error): void {
|
||||||
if (this.worker !== worker) return;
|
if (this.worker !== worker) return;
|
||||||
|
|
@ -157,7 +158,25 @@ export class TeamDataWorkerClient {
|
||||||
|
|
||||||
async getTeamData(teamName: string): Promise<TeamViewSnapshot> {
|
async getTeamData(teamName: string): Promise<TeamViewSnapshot> {
|
||||||
if (!SAFE_NAME_RE.test(teamName)) throw new Error('Invalid teamName');
|
if (!SAFE_NAME_RE.test(teamName)) throw new Error('Invalid teamName');
|
||||||
return this.call('getTeamData', { teamName }) as Promise<TeamViewSnapshot>;
|
const existing = this.getTeamDataInFlight.get(teamName);
|
||||||
|
if (existing) return existing;
|
||||||
|
|
||||||
|
const promise = (this.call('getTeamData', { teamName }) as Promise<TeamViewSnapshot>).finally(
|
||||||
|
() => {
|
||||||
|
if (this.getTeamDataInFlight.get(teamName) === promise) {
|
||||||
|
this.getTeamDataInFlight.delete(teamName);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
);
|
||||||
|
this.getTeamDataInFlight.set(teamName, promise);
|
||||||
|
return promise;
|
||||||
|
}
|
||||||
|
|
||||||
|
invalidateTeamConfig(teamName: string): void {
|
||||||
|
if (!SAFE_NAME_RE.test(teamName)) return;
|
||||||
|
this.getTeamDataInFlight.delete(teamName);
|
||||||
|
if (!this.worker) return;
|
||||||
|
void this.call('invalidateTeamConfig', { teamName }).catch(() => undefined);
|
||||||
}
|
}
|
||||||
|
|
||||||
async getMessagesPage(
|
async getMessagesPage(
|
||||||
|
|
@ -193,6 +212,7 @@ export class TeamDataWorkerClient {
|
||||||
dispose(): void {
|
dispose(): void {
|
||||||
this.worker?.terminate().catch(() => undefined);
|
this.worker?.terminate().catch(() => undefined);
|
||||||
this.worker = null;
|
this.worker = null;
|
||||||
|
this.getTeamDataInFlight.clear();
|
||||||
for (const [, entry] of this.pending) {
|
for (const [, entry] of this.pending) {
|
||||||
entry.reject(new Error('Client disposed'));
|
entry.reject(new Error('Client disposed'));
|
||||||
}
|
}
|
||||||
|
|
|
||||||
|
|
@ -1641,6 +1641,11 @@ interface PromptSizeSummary {
|
||||||
|
|
||||||
const MEMBER_LAUNCH_GRACE_MS = 120_000;
|
const MEMBER_LAUNCH_GRACE_MS = 120_000;
|
||||||
const MEMBER_BOOTSTRAP_STALL_MS = 5 * 60_000;
|
const MEMBER_BOOTSTRAP_STALL_MS = 5 * 60_000;
|
||||||
|
const OPENCODE_BOOTSTRAP_EVIDENCE_LOCK_OPTIONS = {
|
||||||
|
acquireTimeoutMs: 45_000,
|
||||||
|
staleTimeoutMs: 60_000,
|
||||||
|
retryIntervalMs: 50,
|
||||||
|
} as const;
|
||||||
|
|
||||||
export function shouldWarnOnUnreadableMemberAuditConfig(params: {
|
export function shouldWarnOnUnreadableMemberAuditConfig(params: {
|
||||||
nowMs: number;
|
nowMs: number;
|
||||||
|
|
@ -1957,10 +1962,16 @@ function hasStaleOpenCodeDiagnostics(values: readonly unknown[] | undefined): bo
|
||||||
text.includes('registered runtime metadata without live process') ||
|
text.includes('registered runtime metadata without live process') ||
|
||||||
text.includes('member has persisted runtime metadata only') ||
|
text.includes('member has persisted runtime metadata only') ||
|
||||||
text.includes('opencode bridge reported member launch failure') ||
|
text.includes('opencode bridge reported member launch failure') ||
|
||||||
|
text.includes('file lock timeout') ||
|
||||||
text.includes(OPENCODE_UNCOMMITTED_BOOTSTRAP_DIAGNOSTIC.toLowerCase())
|
text.includes(OPENCODE_UNCOMMITTED_BOOTSTRAP_DIAGNOSTIC.toLowerCase())
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
function isFileLockTimeoutError(error: unknown): boolean {
|
||||||
|
const message = error instanceof Error ? error.message : String(error);
|
||||||
|
return message.toLowerCase().includes('file lock timeout');
|
||||||
|
}
|
||||||
|
|
||||||
function hasRealOpenCodeFailureDiagnostic(text: string): boolean {
|
function hasRealOpenCodeFailureDiagnostic(text: string): boolean {
|
||||||
return (
|
return (
|
||||||
/\bauth(?:entication|orization)?\b/.test(text) ||
|
/\bauth(?:entication|orization)?\b/.test(text) ||
|
||||||
|
|
@ -8487,13 +8498,32 @@ export class TeamProvisioningService {
|
||||||
previousMember: idempotent.previousMember,
|
previousMember: idempotent.previousMember,
|
||||||
});
|
});
|
||||||
if (idempotent.state === 'duplicate') {
|
if (idempotent.state === 'duplicate') {
|
||||||
await this.commitOpenCodeRuntimeBootstrapSessionEvidence({
|
const committed = await this.hasCommittedOpenCodeRuntimeBootstrapSessionEvidence({
|
||||||
teamName,
|
teamName,
|
||||||
runId,
|
runId,
|
||||||
laneId,
|
laneId,
|
||||||
memberName,
|
memberName,
|
||||||
runtimeSessionId,
|
runtimeSessionId,
|
||||||
|
});
|
||||||
|
if (!committed) {
|
||||||
|
await this.commitOpenCodeRuntimeBootstrapSessionEvidence({
|
||||||
|
teamName,
|
||||||
|
runId,
|
||||||
|
laneId,
|
||||||
|
memberName,
|
||||||
|
runtimeSessionId,
|
||||||
|
observedAt,
|
||||||
|
});
|
||||||
|
}
|
||||||
|
await this.updateOpenCodeRuntimeMemberLiveness({
|
||||||
|
teamName,
|
||||||
|
runId,
|
||||||
|
memberName,
|
||||||
|
runtimeSessionId,
|
||||||
observedAt,
|
observedAt,
|
||||||
|
diagnostics: payload.diagnostics,
|
||||||
|
metadata: parseRuntimeToolMetadata(payload.metadata),
|
||||||
|
reason: 'OpenCode runtime bootstrap check-in accepted',
|
||||||
});
|
});
|
||||||
return {
|
return {
|
||||||
ok: true,
|
ok: true,
|
||||||
|
|
@ -8585,25 +8615,63 @@ export class TeamProvisioningService {
|
||||||
const manifestStore = createRuntimeStoreManifestStore({
|
const manifestStore = createRuntimeStoreManifestStore({
|
||||||
filePath: manifestPath,
|
filePath: manifestPath,
|
||||||
teamName: input.teamName,
|
teamName: input.teamName,
|
||||||
|
lockOptions: OPENCODE_BOOTSTRAP_EVIDENCE_LOCK_OPTIONS,
|
||||||
});
|
});
|
||||||
const receiptStore = createRuntimeStoreReceiptStore({
|
const receiptStore = createRuntimeStoreReceiptStore({
|
||||||
filePath: path.join(runtimeDirectory, 'opencode-runtime-receipts.json'),
|
filePath: path.join(runtimeDirectory, 'opencode-runtime-receipts.json'),
|
||||||
|
lockOptions: OPENCODE_BOOTSTRAP_EVIDENCE_LOCK_OPTIONS,
|
||||||
});
|
});
|
||||||
const writer = new RuntimeStoreBatchWriter(runtimeDirectory, manifestStore, receiptStore);
|
const writer = new RuntimeStoreBatchWriter(runtimeDirectory, manifestStore, receiptStore);
|
||||||
|
|
||||||
await writer.writeBatch({
|
try {
|
||||||
|
await writer.writeBatch({
|
||||||
|
teamName: input.teamName,
|
||||||
|
runId: input.runId,
|
||||||
|
capabilitySnapshotId: null,
|
||||||
|
behaviorFingerprint: null,
|
||||||
|
reason: 'launch_checkpoint',
|
||||||
|
writes: [
|
||||||
|
{
|
||||||
|
descriptor,
|
||||||
|
data: { sessions },
|
||||||
|
},
|
||||||
|
],
|
||||||
|
});
|
||||||
|
} catch (error) {
|
||||||
|
if (
|
||||||
|
isFileLockTimeoutError(error) &&
|
||||||
|
(await this.hasCommittedOpenCodeRuntimeBootstrapSessionEvidence(input))
|
||||||
|
) {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
throw error;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
private async hasCommittedOpenCodeRuntimeBootstrapSessionEvidence(input: {
|
||||||
|
teamName: string;
|
||||||
|
runId: string;
|
||||||
|
laneId: string;
|
||||||
|
memberName: string;
|
||||||
|
runtimeSessionId: string;
|
||||||
|
}): Promise<boolean> {
|
||||||
|
const evidence = await readCommittedOpenCodeBootstrapSessionEvidence({
|
||||||
|
teamsBasePath: getTeamsBasePath(),
|
||||||
teamName: input.teamName,
|
teamName: input.teamName,
|
||||||
runId: input.runId,
|
laneId: input.laneId,
|
||||||
capabilitySnapshotId: null,
|
}).catch(() => null);
|
||||||
behaviorFingerprint: null,
|
if (!evidence?.committed) {
|
||||||
reason: 'launch_checkpoint',
|
return false;
|
||||||
writes: [
|
}
|
||||||
{
|
if (evidence.activeRunId && evidence.activeRunId.trim() !== input.runId) {
|
||||||
descriptor,
|
return false;
|
||||||
data: { sessions },
|
}
|
||||||
},
|
return evidence.sessions.some(
|
||||||
],
|
(session) =>
|
||||||
});
|
session.id === input.runtimeSessionId &&
|
||||||
|
session.runId === input.runId &&
|
||||||
|
namesMatchCaseInsensitive(session.memberName, input.memberName)
|
||||||
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
private async readOpenCodeRuntimeSessionStore(
|
private async readOpenCodeRuntimeSessionStore(
|
||||||
|
|
@ -8918,6 +8986,25 @@ export class TeamProvisioningService {
|
||||||
metadata?: RuntimeToolMetadata;
|
metadata?: RuntimeToolMetadata;
|
||||||
reason: string;
|
reason: string;
|
||||||
}): Promise<void> {
|
}): Promise<void> {
|
||||||
|
const trackedUpdate = this.applyOpenCodeRuntimeBootstrapCheckinToTrackedRun(input);
|
||||||
|
if (trackedUpdate) {
|
||||||
|
await this.persistLaunchStateSnapshot(
|
||||||
|
trackedUpdate.run,
|
||||||
|
this.getMixedSecondaryLaunchPhase(trackedUpdate.run)
|
||||||
|
);
|
||||||
|
this.agentRuntimeSnapshotCache.delete(input.teamName);
|
||||||
|
this.liveTeamAgentRuntimeMetadataCache.delete(input.teamName);
|
||||||
|
if (trackedUpdate.changed) {
|
||||||
|
this.teamChangeEmitter?.({
|
||||||
|
type: 'member-spawn',
|
||||||
|
teamName: input.teamName,
|
||||||
|
runId: input.runId,
|
||||||
|
detail: input.memberName,
|
||||||
|
});
|
||||||
|
}
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
const previous = await this.launchStateStore.read(input.teamName);
|
const previous = await this.launchStateStore.read(input.teamName);
|
||||||
const expectedMembers = previous
|
const expectedMembers = previous
|
||||||
? this.getPersistedLaunchMemberNames(previous)
|
? this.getPersistedLaunchMemberNames(previous)
|
||||||
|
|
@ -9005,6 +9092,137 @@ export class TeamProvisioningService {
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
private applyOpenCodeRuntimeBootstrapCheckinToTrackedRun(input: {
|
||||||
|
teamName: string;
|
||||||
|
runId: string;
|
||||||
|
memberName: string;
|
||||||
|
runtimeSessionId: string;
|
||||||
|
observedAt: string;
|
||||||
|
diagnostics: unknown;
|
||||||
|
metadata?: RuntimeToolMetadata;
|
||||||
|
reason: string;
|
||||||
|
}): { run: ProvisioningRun; changed: boolean } | null {
|
||||||
|
const trackedRunId = this.getTrackedRunId(input.teamName);
|
||||||
|
const run = trackedRunId ? this.runs.get(trackedRunId) : undefined;
|
||||||
|
if (!run || run.processKilled || run.cancelRequested) {
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
|
||||||
|
const lane = (run.mixedSecondaryLanes ?? []).find((candidate) => {
|
||||||
|
if (candidate.providerId !== 'opencode') {
|
||||||
|
return false;
|
||||||
|
}
|
||||||
|
if (!matchesTeamMemberIdentity(candidate.member.name, input.memberName)) {
|
||||||
|
return false;
|
||||||
|
}
|
||||||
|
return !candidate.runId || candidate.runId === input.runId;
|
||||||
|
});
|
||||||
|
if (!lane) {
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
|
||||||
|
const runtimePid = input.metadata?.runtimePid;
|
||||||
|
const runtimeDiagnostics = mergeRuntimeDiagnostics(
|
||||||
|
lane.result?.members[input.memberName]?.diagnostics ?? lane.diagnostics,
|
||||||
|
[
|
||||||
|
...normalizeRuntimeStringArray(input.diagnostics),
|
||||||
|
...buildRuntimeToolMetadataDiagnostics(input.metadata),
|
||||||
|
'opencode_bootstrap_evidence_committed',
|
||||||
|
],
|
||||||
|
input.reason
|
||||||
|
);
|
||||||
|
const evidence: TeamRuntimeMemberLaunchEvidence = {
|
||||||
|
memberName: input.memberName,
|
||||||
|
providerId: 'opencode',
|
||||||
|
launchState: 'confirmed_alive',
|
||||||
|
agentToolAccepted: true,
|
||||||
|
runtimeAlive: true,
|
||||||
|
bootstrapConfirmed: true,
|
||||||
|
hardFailure: false,
|
||||||
|
sessionId: input.runtimeSessionId,
|
||||||
|
backendType: 'process',
|
||||||
|
...(runtimePid ? { runtimePid, pidSource: 'runtime_bootstrap' as const } : {}),
|
||||||
|
livenessKind: 'confirmed_bootstrap',
|
||||||
|
runtimeDiagnostic: input.reason,
|
||||||
|
runtimeDiagnosticSeverity: 'info',
|
||||||
|
diagnostics: runtimeDiagnostics ?? [input.reason],
|
||||||
|
};
|
||||||
|
|
||||||
|
const previousLaneState = lane.state;
|
||||||
|
const previousLaneRunId = lane.runId;
|
||||||
|
const previousLaneMember = lane.result?.members[input.memberName];
|
||||||
|
lane.runId = input.runId;
|
||||||
|
lane.state = 'finished';
|
||||||
|
lane.diagnostics = runtimeDiagnostics ?? lane.diagnostics;
|
||||||
|
lane.result = {
|
||||||
|
...(lane.result ?? {
|
||||||
|
runId: input.runId,
|
||||||
|
teamName: input.teamName,
|
||||||
|
launchPhase: 'finished' as const,
|
||||||
|
teamLaunchState: 'partial_pending' as const,
|
||||||
|
members: {},
|
||||||
|
warnings: lane.warnings,
|
||||||
|
diagnostics: [],
|
||||||
|
}),
|
||||||
|
runId: input.runId,
|
||||||
|
teamName: input.teamName,
|
||||||
|
launchPhase: 'finished',
|
||||||
|
members: {
|
||||||
|
...(lane.result?.members ?? {}),
|
||||||
|
[input.memberName]: evidence,
|
||||||
|
},
|
||||||
|
warnings: lane.result?.warnings ?? lane.warnings,
|
||||||
|
diagnostics: runtimeDiagnostics ?? lane.result?.diagnostics ?? lane.diagnostics,
|
||||||
|
};
|
||||||
|
lane.result.teamLaunchState = summarizeRuntimeLaunchResultMembers(lane.result.members);
|
||||||
|
|
||||||
|
const previousStatus =
|
||||||
|
run.memberSpawnStatuses.get(input.memberName) ?? createInitialMemberSpawnStatusEntry();
|
||||||
|
const nextStatus: MemberSpawnStatusEntry = {
|
||||||
|
...previousStatus,
|
||||||
|
status: 'online',
|
||||||
|
launchState: 'confirmed_alive',
|
||||||
|
error: undefined,
|
||||||
|
hardFailureReason: undefined,
|
||||||
|
skippedForLaunch: undefined,
|
||||||
|
skipReason: undefined,
|
||||||
|
skippedAt: undefined,
|
||||||
|
livenessSource: 'heartbeat',
|
||||||
|
agentToolAccepted: true,
|
||||||
|
runtimeAlive: true,
|
||||||
|
bootstrapConfirmed: true,
|
||||||
|
hardFailure: false,
|
||||||
|
pendingPermissionRequestIds: undefined,
|
||||||
|
firstSpawnAcceptedAt: previousStatus.firstSpawnAcceptedAt ?? input.observedAt,
|
||||||
|
lastHeartbeatAt: input.observedAt,
|
||||||
|
runtimeModel: lane.member.model,
|
||||||
|
livenessKind: 'confirmed_bootstrap',
|
||||||
|
runtimeDiagnostic: input.reason,
|
||||||
|
runtimeDiagnosticSeverity: 'info',
|
||||||
|
livenessLastCheckedAt: input.observedAt,
|
||||||
|
updatedAt: input.observedAt,
|
||||||
|
};
|
||||||
|
run.memberSpawnStatuses.set(input.memberName, nextStatus);
|
||||||
|
run.pendingMemberRestarts?.delete(input.memberName);
|
||||||
|
this.syncMemberLaunchGraceCheck(run, input.memberName, nextStatus);
|
||||||
|
|
||||||
|
const statusChanged =
|
||||||
|
previousStatus.status !== nextStatus.status ||
|
||||||
|
previousStatus.launchState !== nextStatus.launchState ||
|
||||||
|
previousStatus.bootstrapConfirmed !== nextStatus.bootstrapConfirmed ||
|
||||||
|
previousStatus.runtimeAlive !== nextStatus.runtimeAlive ||
|
||||||
|
previousStatus.hardFailure !== nextStatus.hardFailure ||
|
||||||
|
previousStatus.livenessKind !== nextStatus.livenessKind;
|
||||||
|
const laneChanged =
|
||||||
|
previousLaneState !== lane.state ||
|
||||||
|
previousLaneRunId !== lane.runId ||
|
||||||
|
previousLaneMember?.sessionId !== evidence.sessionId ||
|
||||||
|
previousLaneMember?.launchState !== evidence.launchState ||
|
||||||
|
previousLaneMember?.bootstrapConfirmed !== evidence.bootstrapConfirmed;
|
||||||
|
|
||||||
|
return { run, changed: statusChanged || laneChanged };
|
||||||
|
}
|
||||||
|
|
||||||
private shouldEmitOpenCodeRuntimeLivenessMemberSpawnChange(input: {
|
private shouldEmitOpenCodeRuntimeLivenessMemberSpawnChange(input: {
|
||||||
previousMember?: PersistedTeamLaunchMemberState;
|
previousMember?: PersistedTeamLaunchMemberState;
|
||||||
runtimeRunId: string;
|
runtimeRunId: string;
|
||||||
|
|
@ -17663,6 +17881,7 @@ export class TeamProvisioningService {
|
||||||
}).catch((error: unknown) => ({
|
}).catch((error: unknown) => ({
|
||||||
state: 'invalid_store' as const,
|
state: 'invalid_store' as const,
|
||||||
committed: false,
|
committed: false,
|
||||||
|
activeRunId: null,
|
||||||
sessions: [],
|
sessions: [],
|
||||||
diagnostics: [
|
diagnostics: [
|
||||||
`OpenCode committed bootstrap evidence read failed: ${getErrorMessage(error)}`,
|
`OpenCode committed bootstrap evidence read failed: ${getErrorMessage(error)}`,
|
||||||
|
|
@ -17675,6 +17894,7 @@ export class TeamProvisioningService {
|
||||||
previous,
|
previous,
|
||||||
laneEntry,
|
laneEntry,
|
||||||
metaMembers,
|
metaMembers,
|
||||||
|
activeRunId: evidence.activeRunId,
|
||||||
sessions: evidence.committed ? evidence.sessions : [],
|
sessions: evidence.committed ? evidence.sessions : [],
|
||||||
diagnostics: evidence.diagnostics,
|
diagnostics: evidence.diagnostics,
|
||||||
});
|
});
|
||||||
|
|
@ -17785,6 +18005,7 @@ export class TeamProvisioningService {
|
||||||
previous: PersistedTeamLaunchMemberState | null;
|
previous: PersistedTeamLaunchMemberState | null;
|
||||||
laneEntry: OpenCodeRuntimeLaneIndexEntry | null;
|
laneEntry: OpenCodeRuntimeLaneIndexEntry | null;
|
||||||
metaMembers: Awaited<ReturnType<TeamMembersMetaStore['getMembers']>>;
|
metaMembers: Awaited<ReturnType<TeamMembersMetaStore['getMembers']>>;
|
||||||
|
activeRunId: string | null;
|
||||||
sessions: OpenCodeCommittedBootstrapSessionRecord[];
|
sessions: OpenCodeCommittedBootstrapSessionRecord[];
|
||||||
diagnostics: readonly string[];
|
diagnostics: readonly string[];
|
||||||
}): Promise<
|
}): Promise<
|
||||||
|
|
@ -17838,6 +18059,16 @@ export class TeamProvisioningService {
|
||||||
],
|
],
|
||||||
};
|
};
|
||||||
}
|
}
|
||||||
|
if (
|
||||||
|
params.activeRunId &&
|
||||||
|
selected.runId &&
|
||||||
|
params.activeRunId.trim() !== selected.runId.trim()
|
||||||
|
) {
|
||||||
|
return {
|
||||||
|
kind: 'conflict',
|
||||||
|
diagnostics: ['opencode_overlay_session_run_mismatch'],
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
if (selected.runId) {
|
if (selected.runId) {
|
||||||
const tombstoneStore = createRuntimeRunTombstoneStore({
|
const tombstoneStore = createRuntimeRunTombstoneStore({
|
||||||
|
|
|
||||||
|
|
@ -186,7 +186,9 @@ export class TeamTranscriptProjectResolver {
|
||||||
{ value: TeamTranscriptProjectContext; expiresAt: number }
|
{ value: TeamTranscriptProjectContext; expiresAt: number }
|
||||||
>();
|
>();
|
||||||
|
|
||||||
constructor(private readonly configReader: TeamConfigReader = new TeamConfigReader()) {}
|
constructor(
|
||||||
|
private readonly configReader: Pick<TeamConfigReader, 'getConfig'> = new TeamConfigReader()
|
||||||
|
) {}
|
||||||
|
|
||||||
async getContext(
|
async getContext(
|
||||||
teamName: string,
|
teamName: string,
|
||||||
|
|
@ -605,6 +607,7 @@ export class TeamTranscriptProjectResolver {
|
||||||
normalizedNextPath
|
normalizedNextPath
|
||||||
);
|
);
|
||||||
await atomicWriteAsync(configPath, JSON.stringify(parsed, null, 2));
|
await atomicWriteAsync(configPath, JSON.stringify(parsed, null, 2));
|
||||||
|
TeamConfigReader.invalidateTeam(teamName);
|
||||||
logger.info(
|
logger.info(
|
||||||
`[${teamName}] Repaired transcript projectPath via exact session match: ${normalizedNextPath}`
|
`[${teamName}] Repaired transcript projectPath via exact session match: ${normalizedNextPath}`
|
||||||
);
|
);
|
||||||
|
|
|
||||||
|
|
@ -71,6 +71,7 @@ export interface OpenCodeCommittedBootstrapSessionRecord {
|
||||||
export interface OpenCodeCommittedBootstrapSessionEvidence {
|
export interface OpenCodeCommittedBootstrapSessionEvidence {
|
||||||
state: RuntimeStoreManifestEntryState | 'invalid_store' | 'descriptor_missing';
|
state: RuntimeStoreManifestEntryState | 'invalid_store' | 'descriptor_missing';
|
||||||
committed: boolean;
|
committed: boolean;
|
||||||
|
activeRunId: string | null;
|
||||||
sessions: OpenCodeCommittedBootstrapSessionRecord[];
|
sessions: OpenCodeCommittedBootstrapSessionRecord[];
|
||||||
diagnostics: string[];
|
diagnostics: string[];
|
||||||
}
|
}
|
||||||
|
|
@ -497,6 +498,7 @@ export async function readCommittedOpenCodeBootstrapSessionEvidence(params: {
|
||||||
return {
|
return {
|
||||||
state: 'descriptor_missing',
|
state: 'descriptor_missing',
|
||||||
committed: false,
|
committed: false,
|
||||||
|
activeRunId: null,
|
||||||
sessions: [],
|
sessions: [],
|
||||||
diagnostics: ['OpenCode session store descriptor is not registered.'],
|
diagnostics: ['OpenCode session store descriptor is not registered.'],
|
||||||
};
|
};
|
||||||
|
|
@ -521,6 +523,7 @@ export async function readCommittedOpenCodeBootstrapSessionEvidence(params: {
|
||||||
return {
|
return {
|
||||||
state: 'invalid_store',
|
state: 'invalid_store',
|
||||||
committed: false,
|
committed: false,
|
||||||
|
activeRunId: null,
|
||||||
sessions: [],
|
sessions: [],
|
||||||
diagnostics: ['OpenCode runtime manifest could not be read.'],
|
diagnostics: ['OpenCode runtime manifest could not be read.'],
|
||||||
};
|
};
|
||||||
|
|
@ -539,6 +542,7 @@ export async function readCommittedOpenCodeBootstrapSessionEvidence(params: {
|
||||||
return {
|
return {
|
||||||
state: inspection.state,
|
state: inspection.state,
|
||||||
committed: false,
|
committed: false,
|
||||||
|
activeRunId: manifest.activeRunId,
|
||||||
sessions: [],
|
sessions: [],
|
||||||
diagnostics,
|
diagnostics,
|
||||||
};
|
};
|
||||||
|
|
@ -561,6 +565,7 @@ export async function readCommittedOpenCodeBootstrapSessionEvidence(params: {
|
||||||
return {
|
return {
|
||||||
state: 'healthy',
|
state: 'healthy',
|
||||||
committed: true,
|
committed: true,
|
||||||
|
activeRunId: manifest.activeRunId,
|
||||||
sessions,
|
sessions,
|
||||||
diagnostics,
|
diagnostics,
|
||||||
};
|
};
|
||||||
|
|
|
||||||
|
|
@ -3,6 +3,8 @@ import { createHash, randomUUID } from 'crypto';
|
||||||
import { promises as fs } from 'fs';
|
import { promises as fs } from 'fs';
|
||||||
import * as path from 'path';
|
import * as path from 'path';
|
||||||
|
|
||||||
|
import type { FileLockOptions } from '../../fileLock';
|
||||||
|
|
||||||
import { VersionedJsonStore, VersionedJsonStoreError } from './VersionedJsonStore';
|
import { VersionedJsonStore, VersionedJsonStoreError } from './VersionedJsonStore';
|
||||||
|
|
||||||
export const OPENCODE_RUNTIME_STORE_MANIFEST_SCHEMA_VERSION = 1;
|
export const OPENCODE_RUNTIME_STORE_MANIFEST_SCHEMA_VERSION = 1;
|
||||||
|
|
@ -917,6 +919,7 @@ export function createRuntimeStoreManifestStore(options: {
|
||||||
filePath: string;
|
filePath: string;
|
||||||
teamName: string;
|
teamName: string;
|
||||||
clock?: () => Date;
|
clock?: () => Date;
|
||||||
|
lockOptions?: FileLockOptions;
|
||||||
}): RuntimeStoreManifestStore {
|
}): RuntimeStoreManifestStore {
|
||||||
const clock = options.clock ?? (() => new Date());
|
const clock = options.clock ?? (() => new Date());
|
||||||
return new RuntimeStoreManifestStore(
|
return new RuntimeStoreManifestStore(
|
||||||
|
|
@ -926,6 +929,7 @@ export function createRuntimeStoreManifestStore(options: {
|
||||||
defaultData: () => createDefaultRuntimeStoreManifest(options.teamName, clock().toISOString()),
|
defaultData: () => createDefaultRuntimeStoreManifest(options.teamName, clock().toISOString()),
|
||||||
validate: validateRuntimeStoreManifest,
|
validate: validateRuntimeStoreManifest,
|
||||||
clock,
|
clock,
|
||||||
|
lockOptions: options.lockOptions,
|
||||||
}),
|
}),
|
||||||
clock
|
clock
|
||||||
);
|
);
|
||||||
|
|
@ -934,6 +938,7 @@ export function createRuntimeStoreManifestStore(options: {
|
||||||
export function createRuntimeStoreReceiptStore(options: {
|
export function createRuntimeStoreReceiptStore(options: {
|
||||||
filePath: string;
|
filePath: string;
|
||||||
clock?: () => Date;
|
clock?: () => Date;
|
||||||
|
lockOptions?: FileLockOptions;
|
||||||
}): RuntimeStoreReceiptStore {
|
}): RuntimeStoreReceiptStore {
|
||||||
const clock = options.clock ?? (() => new Date());
|
const clock = options.clock ?? (() => new Date());
|
||||||
return new RuntimeStoreReceiptStore(
|
return new RuntimeStoreReceiptStore(
|
||||||
|
|
@ -943,6 +948,7 @@ export function createRuntimeStoreReceiptStore(options: {
|
||||||
defaultData: () => [],
|
defaultData: () => [],
|
||||||
validate: validateRuntimeStoreWriteBatches,
|
validate: validateRuntimeStoreWriteBatches,
|
||||||
clock,
|
clock,
|
||||||
|
lockOptions: options.lockOptions,
|
||||||
}),
|
}),
|
||||||
clock
|
clock
|
||||||
);
|
);
|
||||||
|
|
|
||||||
|
|
@ -2,7 +2,7 @@ import { atomicWriteAsync } from '@main/utils/atomicWrite';
|
||||||
import { promises as fs } from 'fs';
|
import { promises as fs } from 'fs';
|
||||||
import * as path from 'path';
|
import * as path from 'path';
|
||||||
|
|
||||||
import { withFileLock } from '../../fileLock';
|
import { type FileLockOptions, withFileLock } from '../../fileLock';
|
||||||
|
|
||||||
export interface VersionedJsonStoreEnvelope<TData> {
|
export interface VersionedJsonStoreEnvelope<TData> {
|
||||||
schemaVersion: number;
|
schemaVersion: number;
|
||||||
|
|
@ -45,6 +45,7 @@ export interface VersionedJsonStoreOptions<TData> {
|
||||||
validate: (value: unknown) => TData;
|
validate: (value: unknown) => TData;
|
||||||
clock?: () => Date;
|
clock?: () => Date;
|
||||||
quarantineDir?: string;
|
quarantineDir?: string;
|
||||||
|
lockOptions?: FileLockOptions;
|
||||||
}
|
}
|
||||||
|
|
||||||
export class VersionedJsonStoreError extends Error {
|
export class VersionedJsonStoreError extends Error {
|
||||||
|
|
@ -65,6 +66,7 @@ export class VersionedJsonStore<TData> {
|
||||||
private readonly validate: (value: unknown) => TData;
|
private readonly validate: (value: unknown) => TData;
|
||||||
private readonly clock: () => Date;
|
private readonly clock: () => Date;
|
||||||
private readonly quarantineDir: string | null;
|
private readonly quarantineDir: string | null;
|
||||||
|
private readonly lockOptions: FileLockOptions | undefined;
|
||||||
|
|
||||||
constructor(options: VersionedJsonStoreOptions<TData>) {
|
constructor(options: VersionedJsonStoreOptions<TData>) {
|
||||||
this.filePath = options.filePath;
|
this.filePath = options.filePath;
|
||||||
|
|
@ -73,6 +75,7 @@ export class VersionedJsonStore<TData> {
|
||||||
this.validate = options.validate;
|
this.validate = options.validate;
|
||||||
this.clock = options.clock ?? (() => new Date());
|
this.clock = options.clock ?? (() => new Date());
|
||||||
this.quarantineDir = options.quarantineDir ?? null;
|
this.quarantineDir = options.quarantineDir ?? null;
|
||||||
|
this.lockOptions = options.lockOptions;
|
||||||
}
|
}
|
||||||
|
|
||||||
async read(): Promise<VersionedJsonStoreReadResult<TData>> {
|
async read(): Promise<VersionedJsonStoreReadResult<TData>> {
|
||||||
|
|
@ -82,36 +85,44 @@ export class VersionedJsonStore<TData> {
|
||||||
async updateLocked(
|
async updateLocked(
|
||||||
updater: (current: TData) => TData | Promise<TData>
|
updater: (current: TData) => TData | Promise<TData>
|
||||||
): Promise<VersionedJsonStoreUpdateResult<TData>> {
|
): Promise<VersionedJsonStoreUpdateResult<TData>> {
|
||||||
return withFileLock(this.filePath, async () => {
|
return withFileLock(
|
||||||
const current = await this.readUnlocked();
|
this.filePath,
|
||||||
if (!current.ok) {
|
async () => {
|
||||||
throw new VersionedJsonStoreError(current.message, current.reason, current.quarantinePath);
|
const current = await this.readUnlocked();
|
||||||
}
|
if (!current.ok) {
|
||||||
|
throw new VersionedJsonStoreError(
|
||||||
|
current.message,
|
||||||
|
current.reason,
|
||||||
|
current.quarantinePath
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
const nextData = await updater(cloneJson(current.data));
|
const nextData = await updater(cloneJson(current.data));
|
||||||
const validatedNextData = this.validate(nextData);
|
const validatedNextData = this.validate(nextData);
|
||||||
const currentJson = stableJsonStringify(current.data);
|
const currentJson = stableJsonStringify(current.data);
|
||||||
const nextJson = stableJsonStringify(validatedNextData);
|
const nextJson = stableJsonStringify(validatedNextData);
|
||||||
const changed = current.status === 'missing' || currentJson !== nextJson;
|
const changed = current.status === 'missing' || currentJson !== nextJson;
|
||||||
const envelope: VersionedJsonStoreEnvelope<TData> = {
|
const envelope: VersionedJsonStoreEnvelope<TData> = {
|
||||||
schemaVersion: this.schemaVersion,
|
schemaVersion: this.schemaVersion,
|
||||||
updatedAt: changed
|
updatedAt: changed
|
||||||
? this.clock().toISOString()
|
? this.clock().toISOString()
|
||||||
: (current.envelope?.updatedAt ?? this.clock().toISOString()),
|
: (current.envelope?.updatedAt ?? this.clock().toISOString()),
|
||||||
data: changed ? validatedNextData : current.data,
|
data: changed ? validatedNextData : current.data,
|
||||||
};
|
};
|
||||||
|
|
||||||
if (changed) {
|
if (changed) {
|
||||||
await fs.mkdir(path.dirname(this.filePath), { recursive: true });
|
await fs.mkdir(path.dirname(this.filePath), { recursive: true });
|
||||||
await atomicWriteAsync(this.filePath, `${JSON.stringify(envelope, null, 2)}\n`);
|
await atomicWriteAsync(this.filePath, `${JSON.stringify(envelope, null, 2)}\n`);
|
||||||
}
|
}
|
||||||
|
|
||||||
return {
|
return {
|
||||||
changed,
|
changed,
|
||||||
data: envelope.data,
|
data: envelope.data,
|
||||||
envelope,
|
envelope,
|
||||||
};
|
};
|
||||||
});
|
},
|
||||||
|
this.lockOptions
|
||||||
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
private async readUnlocked(): Promise<VersionedJsonStoreReadResult<TData>> {
|
private async readUnlocked(): Promise<VersionedJsonStoreReadResult<TData>> {
|
||||||
|
|
|
||||||
|
|
@ -38,18 +38,23 @@ export interface FindLogsForTaskPayload {
|
||||||
};
|
};
|
||||||
}
|
}
|
||||||
|
|
||||||
|
export interface InvalidateTeamConfigPayload {
|
||||||
|
teamName: string;
|
||||||
|
}
|
||||||
|
|
||||||
// ── Request / Response ──
|
// ── Request / Response ──
|
||||||
|
|
||||||
export type TeamDataWorkerRequest =
|
export type TeamDataWorkerRequest =
|
||||||
| { id: string; op: 'getTeamData'; payload: GetTeamDataPayload }
|
| { id: string; op: 'getTeamData'; payload: GetTeamDataPayload }
|
||||||
| { id: string; op: 'getMessagesPage'; payload: GetMessagesPagePayload }
|
| { id: string; op: 'getMessagesPage'; payload: GetMessagesPagePayload }
|
||||||
| { id: string; op: 'getMemberActivityMeta'; payload: GetMemberActivityMetaPayload }
|
| { id: string; op: 'getMemberActivityMeta'; payload: GetMemberActivityMetaPayload }
|
||||||
| { id: string; op: 'findLogsForTask'; payload: FindLogsForTaskPayload };
|
| { id: string; op: 'findLogsForTask'; payload: FindLogsForTaskPayload }
|
||||||
|
| { id: string; op: 'invalidateTeamConfig'; payload: InvalidateTeamConfigPayload };
|
||||||
|
|
||||||
export type TeamDataWorkerResponse =
|
export type TeamDataWorkerResponse =
|
||||||
| {
|
| {
|
||||||
id: string;
|
id: string;
|
||||||
ok: true;
|
ok: true;
|
||||||
result: TeamViewSnapshot | MessagesPage | TeamMemberActivityMeta | MemberLogSummary[];
|
result: TeamViewSnapshot | MessagesPage | TeamMemberActivityMeta | MemberLogSummary[] | null;
|
||||||
}
|
}
|
||||||
| { id: string; ok: false; error: string };
|
| { id: string; ok: false; error: string };
|
||||||
|
|
|
||||||
|
|
@ -8,6 +8,7 @@
|
||||||
|
|
||||||
import { parentPort } from 'node:worker_threads';
|
import { parentPort } from 'node:worker_threads';
|
||||||
|
|
||||||
|
import { TeamConfigReader } from '@main/services/team/TeamConfigReader';
|
||||||
import { TeamDataService } from '@main/services/team/TeamDataService';
|
import { TeamDataService } from '@main/services/team/TeamDataService';
|
||||||
import { TeamMemberLogsFinder } from '@main/services/team/TeamMemberLogsFinder';
|
import { TeamMemberLogsFinder } from '@main/services/team/TeamMemberLogsFinder';
|
||||||
import { createLogger } from '@shared/utils/logger';
|
import { createLogger } from '@shared/utils/logger';
|
||||||
|
|
@ -55,6 +56,11 @@ parentPort?.on('message', async (msg: TeamDataWorkerRequest) => {
|
||||||
respond({ id: msg.id, ok: true, result });
|
respond({ id: msg.id, ok: true, result });
|
||||||
break;
|
break;
|
||||||
}
|
}
|
||||||
|
case 'invalidateTeamConfig': {
|
||||||
|
TeamConfigReader.invalidateTeam(msg.payload.teamName);
|
||||||
|
respond({ id: msg.id, ok: true, result: null });
|
||||||
|
break;
|
||||||
|
}
|
||||||
case 'findLogsForTask': {
|
case 'findLogsForTask': {
|
||||||
const { teamName, taskId, options } = msg.payload;
|
const { teamName, taskId, options } = msg.payload;
|
||||||
const intervalsKey = options?.intervals
|
const intervalsKey = options?.intervals
|
||||||
|
|
|
||||||
|
|
@ -156,6 +156,7 @@ export const MemberCard = ({
|
||||||
const launchVisualState = launchPresentation.launchVisualState;
|
const launchVisualState = launchPresentation.launchVisualState;
|
||||||
const launchStatusLabel = launchPresentation.launchStatusLabel;
|
const launchStatusLabel = launchPresentation.launchStatusLabel;
|
||||||
const displayPresenceLabel =
|
const displayPresenceLabel =
|
||||||
|
launchVisualState === 'queued' ||
|
||||||
launchVisualState === 'runtime_pending' ||
|
launchVisualState === 'runtime_pending' ||
|
||||||
launchVisualState === 'permission_pending' ||
|
launchVisualState === 'permission_pending' ||
|
||||||
launchVisualState === 'shell_only' ||
|
launchVisualState === 'shell_only' ||
|
||||||
|
|
@ -200,6 +201,7 @@ export const MemberCard = ({
|
||||||
!runtimeAdvisoryLabel &&
|
!runtimeAdvisoryLabel &&
|
||||||
(presenceLabel === 'starting' ||
|
(presenceLabel === 'starting' ||
|
||||||
presenceLabel === 'connecting' ||
|
presenceLabel === 'connecting' ||
|
||||||
|
launchVisualState === 'queued' ||
|
||||||
launchVisualState === 'runtime_pending' ||
|
launchVisualState === 'runtime_pending' ||
|
||||||
launchVisualState === 'shell_only' ||
|
launchVisualState === 'shell_only' ||
|
||||||
launchVisualState === 'runtime_candidate' ||
|
launchVisualState === 'runtime_candidate' ||
|
||||||
|
|
|
||||||
|
|
@ -546,6 +546,7 @@ export function getLaunchAwarePresenceLabel(
|
||||||
}
|
}
|
||||||
|
|
||||||
export type MemberLaunchVisualState =
|
export type MemberLaunchVisualState =
|
||||||
|
| 'queued'
|
||||||
| 'waiting'
|
| 'waiting'
|
||||||
| 'spawning'
|
| 'spawning'
|
||||||
| 'permission_pending'
|
| 'permission_pending'
|
||||||
|
|
@ -573,6 +574,8 @@ export interface MemberLaunchPresentation {
|
||||||
|
|
||||||
export function getMemberLaunchStatusLabel(visualState: MemberLaunchVisualState): string | null {
|
export function getMemberLaunchStatusLabel(visualState: MemberLaunchVisualState): string | null {
|
||||||
switch (visualState) {
|
switch (visualState) {
|
||||||
|
case 'queued':
|
||||||
|
return 'queued';
|
||||||
case 'waiting':
|
case 'waiting':
|
||||||
return 'waiting to start';
|
return 'waiting to start';
|
||||||
case 'spawning':
|
case 'spawning':
|
||||||
|
|
@ -602,6 +605,8 @@ export function getMemberLaunchStatusLabel(visualState: MemberLaunchVisualState)
|
||||||
|
|
||||||
function getLaunchVisualStateDotClass(visualState: MemberLaunchVisualState): string | null {
|
function getLaunchVisualStateDotClass(visualState: MemberLaunchVisualState): string | null {
|
||||||
switch (visualState) {
|
switch (visualState) {
|
||||||
|
case 'queued':
|
||||||
|
return SPAWN_DOT_COLORS.waiting;
|
||||||
case 'permission_pending':
|
case 'permission_pending':
|
||||||
case 'runtime_pending':
|
case 'runtime_pending':
|
||||||
case 'runtime_candidate':
|
case 'runtime_candidate':
|
||||||
|
|
@ -617,6 +622,29 @@ function getLaunchVisualStateDotClass(visualState: MemberLaunchVisualState): str
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
function isQueuedOpenCodeLaunch(
|
||||||
|
member: ResolvedTeamMember,
|
||||||
|
spawnStatus: MemberSpawnStatus | undefined,
|
||||||
|
spawnLaunchState: MemberLaunchState | undefined,
|
||||||
|
runtimeEntry: TeamAgentRuntimeEntry | undefined,
|
||||||
|
isLaunchSettling: boolean,
|
||||||
|
isTeamProvisioning: boolean | undefined
|
||||||
|
): boolean {
|
||||||
|
if (member.providerId !== 'opencode') {
|
||||||
|
return false;
|
||||||
|
}
|
||||||
|
if (isTeamProvisioning !== true && !isLaunchSettling) {
|
||||||
|
return false;
|
||||||
|
}
|
||||||
|
if (spawnStatus !== 'waiting' || spawnLaunchState !== 'starting') {
|
||||||
|
return false;
|
||||||
|
}
|
||||||
|
|
||||||
|
// Only label lanes as queued before runtime evidence appears. Once the
|
||||||
|
// backend has any liveness signal, show the exact runtime state instead.
|
||||||
|
return runtimeEntry == null || runtimeEntry.livenessKind == null;
|
||||||
|
}
|
||||||
|
|
||||||
function hasElapsedSinceIso(
|
function hasElapsedSinceIso(
|
||||||
value: string | undefined,
|
value: string | undefined,
|
||||||
thresholdMs: number,
|
thresholdMs: number,
|
||||||
|
|
@ -783,6 +811,17 @@ export function buildMemberLaunchPresentation({
|
||||||
runtimeEntry?.livenessKind === 'not_found'
|
runtimeEntry?.livenessKind === 'not_found'
|
||||||
) {
|
) {
|
||||||
launchVisualState = 'stale_runtime';
|
launchVisualState = 'stale_runtime';
|
||||||
|
} else if (
|
||||||
|
isQueuedOpenCodeLaunch(
|
||||||
|
member,
|
||||||
|
spawnStatus,
|
||||||
|
spawnLaunchState,
|
||||||
|
runtimeEntry,
|
||||||
|
isLaunchSettling,
|
||||||
|
isTeamProvisioning
|
||||||
|
)
|
||||||
|
) {
|
||||||
|
launchVisualState = 'queued';
|
||||||
} else if (
|
} else if (
|
||||||
isLaunchStillStarting(
|
isLaunchStillStarting(
|
||||||
spawnStatus,
|
spawnStatus,
|
||||||
|
|
@ -810,6 +849,7 @@ export function buildMemberLaunchPresentation({
|
||||||
const launchStatusLabel = getMemberLaunchStatusLabel(launchVisualState);
|
const launchStatusLabel = getMemberLaunchStatusLabel(launchVisualState);
|
||||||
const launchVisualStateDotClass = getLaunchVisualStateDotClass(launchVisualState);
|
const launchVisualStateDotClass = getLaunchVisualStateDotClass(launchVisualState);
|
||||||
const shouldShowLaunchStatusAsPresence =
|
const shouldShowLaunchStatusAsPresence =
|
||||||
|
launchVisualState === 'queued' ||
|
||||||
launchVisualState === 'permission_pending' ||
|
launchVisualState === 'permission_pending' ||
|
||||||
launchVisualState === 'runtime_pending' ||
|
launchVisualState === 'runtime_pending' ||
|
||||||
launchVisualState === 'shell_only' ||
|
launchVisualState === 'shell_only' ||
|
||||||
|
|
|
||||||
|
|
@ -49,6 +49,7 @@ const { mockTeamDataWorkerClient } = vi.hoisted(() => ({
|
||||||
getMessagesPage: vi.fn(),
|
getMessagesPage: vi.fn(),
|
||||||
getMemberActivityMeta: vi.fn(),
|
getMemberActivityMeta: vi.fn(),
|
||||||
findLogsForTask: vi.fn(),
|
findLogsForTask: vi.fn(),
|
||||||
|
invalidateTeamConfig: vi.fn(),
|
||||||
},
|
},
|
||||||
}));
|
}));
|
||||||
vi.mock('@main/services/infrastructure/NotificationManager', () => ({
|
vi.mock('@main/services/infrastructure/NotificationManager', () => ({
|
||||||
|
|
@ -313,6 +314,7 @@ describe('ipc teams handlers', () => {
|
||||||
mockTeamDataWorkerClient.getMessagesPage.mockReset();
|
mockTeamDataWorkerClient.getMessagesPage.mockReset();
|
||||||
mockTeamDataWorkerClient.getMemberActivityMeta.mockReset();
|
mockTeamDataWorkerClient.getMemberActivityMeta.mockReset();
|
||||||
mockTeamDataWorkerClient.findLogsForTask.mockReset();
|
mockTeamDataWorkerClient.findLogsForTask.mockReset();
|
||||||
|
mockTeamDataWorkerClient.invalidateTeamConfig.mockReset();
|
||||||
initializeTeamHandlers(
|
initializeTeamHandlers(
|
||||||
service as never,
|
service as never,
|
||||||
provisioningService as never,
|
provisioningService as never,
|
||||||
|
|
|
||||||
|
|
@ -22,6 +22,20 @@ vi.mock('../../../../src/main/services/team/TeamFsWorkerClient', () => ({
|
||||||
import { TeamConfigReader } from '../../../../src/main/services/team/TeamConfigReader';
|
import { TeamConfigReader } from '../../../../src/main/services/team/TeamConfigReader';
|
||||||
import { createPersistedLaunchSummaryProjection } from '../../../../src/main/services/team/TeamLaunchSummaryProjection';
|
import { createPersistedLaunchSummaryProjection } from '../../../../src/main/services/team/TeamLaunchSummaryProjection';
|
||||||
|
|
||||||
|
function createDeferred<T>(): {
|
||||||
|
promise: Promise<T>;
|
||||||
|
resolve: (value: T) => void;
|
||||||
|
reject: (error: unknown) => void;
|
||||||
|
} {
|
||||||
|
let resolve!: (value: T) => void;
|
||||||
|
let reject!: (error: unknown) => void;
|
||||||
|
const promise = new Promise<T>((res, rej) => {
|
||||||
|
resolve = res;
|
||||||
|
reject = rej;
|
||||||
|
});
|
||||||
|
return { promise, resolve, reject };
|
||||||
|
}
|
||||||
|
|
||||||
describe('TeamConfigReader', () => {
|
describe('TeamConfigReader', () => {
|
||||||
let tempDir = '';
|
let tempDir = '';
|
||||||
|
|
||||||
|
|
@ -31,6 +45,9 @@ describe('TeamConfigReader', () => {
|
||||||
});
|
});
|
||||||
|
|
||||||
afterEach(async () => {
|
afterEach(async () => {
|
||||||
|
vi.useRealTimers();
|
||||||
|
vi.restoreAllMocks();
|
||||||
|
TeamConfigReader.clearCacheForTests();
|
||||||
if (tempDir) {
|
if (tempDir) {
|
||||||
await fs.rm(tempDir, { recursive: true, force: true });
|
await fs.rm(tempDir, { recursive: true, force: true });
|
||||||
}
|
}
|
||||||
|
|
@ -256,7 +273,7 @@ describe('TeamConfigReader', () => {
|
||||||
});
|
});
|
||||||
});
|
});
|
||||||
|
|
||||||
it('shares in-flight getConfig reads and returns cloned cached configs', async () => {
|
it('shares in-flight verified reads without reusing completed cache', async () => {
|
||||||
const teamName = 'cached-config-team';
|
const teamName = 'cached-config-team';
|
||||||
const teamDir = path.join(tempDir, teamName);
|
const teamDir = path.join(tempDir, teamName);
|
||||||
await fs.mkdir(teamDir, { recursive: true });
|
await fs.mkdir(teamDir, { recursive: true });
|
||||||
|
|
@ -273,17 +290,481 @@ describe('TeamConfigReader', () => {
|
||||||
|
|
||||||
const reader = new TeamConfigReader();
|
const reader = new TeamConfigReader();
|
||||||
const [first, second] = await Promise.all([
|
const [first, second] = await Promise.all([
|
||||||
reader.getConfig(teamName),
|
reader.getConfigVerified(teamName),
|
||||||
reader.getConfig(teamName),
|
reader.getConfigVerified(teamName),
|
||||||
]);
|
]);
|
||||||
if (!first) {
|
if (!first) {
|
||||||
throw new Error('Expected config to load.');
|
throw new Error('Expected config to load.');
|
||||||
}
|
}
|
||||||
first.name = 'Mutated In Caller';
|
first.name = 'Mutated In Caller';
|
||||||
const third = await reader.getConfig(teamName);
|
const third = await reader.getConfigVerified(teamName);
|
||||||
|
|
||||||
expect(second?.name).toBe('Cached Config Team');
|
expect(second?.name).toBe('Cached Config Team');
|
||||||
expect(third?.name).toBe('Cached Config Team');
|
expect(third?.name).toBe('Cached Config Team');
|
||||||
|
expect(readFileSpy).toHaveBeenCalledTimes(2);
|
||||||
|
});
|
||||||
|
|
||||||
|
it('uses fingerprint-validated snapshot cache without rereading unchanged config content', async () => {
|
||||||
|
const teamName = 'snapshot-cache-team';
|
||||||
|
const teamDir = path.join(tempDir, teamName);
|
||||||
|
await fs.mkdir(teamDir, { recursive: true });
|
||||||
|
await fs.writeFile(
|
||||||
|
path.join(teamDir, 'config.json'),
|
||||||
|
JSON.stringify({
|
||||||
|
name: 'Snapshot Cache Team',
|
||||||
|
members: [{ name: 'team-lead', agentType: 'team-lead' }],
|
||||||
|
}),
|
||||||
|
'utf8'
|
||||||
|
);
|
||||||
|
const readFileSpy = vi.spyOn(nodeFs.promises, 'readFile');
|
||||||
|
const statSpy = vi.spyOn(nodeFs.promises, 'stat');
|
||||||
|
|
||||||
|
const reader = new TeamConfigReader();
|
||||||
|
const first = await reader.getConfigSnapshot(teamName);
|
||||||
|
if (!first) {
|
||||||
|
throw new Error('Expected config to load.');
|
||||||
|
}
|
||||||
|
first.name = 'Mutated In Caller';
|
||||||
|
const second = await reader.getConfigSnapshot(teamName);
|
||||||
|
|
||||||
|
expect(second?.name).toBe('Snapshot Cache Team');
|
||||||
|
expect(statSpy).toHaveBeenCalledTimes(2);
|
||||||
expect(readFileSpy).toHaveBeenCalledTimes(1);
|
expect(readFileSpy).toHaveBeenCalledTimes(1);
|
||||||
});
|
});
|
||||||
|
|
||||||
|
it('shares in-flight snapshot stat and read work for concurrent calls', async () => {
|
||||||
|
const teamName = 'snapshot-inflight-team';
|
||||||
|
const teamDir = path.join(tempDir, teamName);
|
||||||
|
await fs.mkdir(teamDir, { recursive: true });
|
||||||
|
await fs.writeFile(
|
||||||
|
path.join(teamDir, 'config.json'),
|
||||||
|
JSON.stringify({
|
||||||
|
name: 'Snapshot Inflight Team',
|
||||||
|
members: [{ name: 'team-lead', agentType: 'team-lead' }],
|
||||||
|
}),
|
||||||
|
'utf8'
|
||||||
|
);
|
||||||
|
const readFileSpy = vi.spyOn(nodeFs.promises, 'readFile');
|
||||||
|
const statSpy = vi.spyOn(nodeFs.promises, 'stat');
|
||||||
|
|
||||||
|
const reader = new TeamConfigReader();
|
||||||
|
const [first, second] = await Promise.all([
|
||||||
|
reader.getConfigSnapshot(teamName),
|
||||||
|
reader.getConfigSnapshot(teamName),
|
||||||
|
]);
|
||||||
|
|
||||||
|
expect(first?.name).toBe('Snapshot Inflight Team');
|
||||||
|
expect(second?.name).toBe('Snapshot Inflight Team');
|
||||||
|
expect(statSpy).toHaveBeenCalledTimes(1);
|
||||||
|
expect(readFileSpy).toHaveBeenCalledTimes(1);
|
||||||
|
});
|
||||||
|
|
||||||
|
it('rereads snapshot when ctime changes even if mtime is unchanged', async () => {
|
||||||
|
const teamName = 'snapshot-ctime-team';
|
||||||
|
const teamDir = path.join(tempDir, teamName);
|
||||||
|
const configPath = path.join(teamDir, 'config.json');
|
||||||
|
await fs.mkdir(teamDir, { recursive: true });
|
||||||
|
await fs.writeFile(
|
||||||
|
configPath,
|
||||||
|
JSON.stringify({
|
||||||
|
name: 'Before Ctime',
|
||||||
|
members: [{ name: 'team-lead', agentType: 'team-lead' }],
|
||||||
|
}),
|
||||||
|
'utf8'
|
||||||
|
);
|
||||||
|
let ctimeMs = 1000;
|
||||||
|
vi.spyOn(nodeFs.promises, 'stat').mockImplementation(async () => ({
|
||||||
|
size: BigInt(4096),
|
||||||
|
mode: BigInt(33188),
|
||||||
|
dev: BigInt(1),
|
||||||
|
ino: BigInt(2),
|
||||||
|
mtimeMs: 1000,
|
||||||
|
ctimeMs,
|
||||||
|
birthtimeMs: 1000,
|
||||||
|
isFile: () => true,
|
||||||
|
}) as never);
|
||||||
|
const readFileSpy = vi.spyOn(nodeFs.promises, 'readFile');
|
||||||
|
|
||||||
|
const reader = new TeamConfigReader();
|
||||||
|
expect((await reader.getConfigSnapshot(teamName))?.name).toBe('Before Ctime');
|
||||||
|
await fs.writeFile(
|
||||||
|
configPath,
|
||||||
|
JSON.stringify({
|
||||||
|
name: 'After Ctime',
|
||||||
|
members: [{ name: 'team-lead', agentType: 'team-lead' }],
|
||||||
|
}),
|
||||||
|
'utf8'
|
||||||
|
);
|
||||||
|
ctimeMs = 2000;
|
||||||
|
|
||||||
|
expect((await reader.getConfigSnapshot(teamName))?.name).toBe('After Ctime');
|
||||||
|
expect(readFileSpy).toHaveBeenCalledTimes(2);
|
||||||
|
});
|
||||||
|
|
||||||
|
it('rereads snapshot when the config fingerprint changes', async () => {
|
||||||
|
const teamName = 'snapshot-reread-team';
|
||||||
|
const teamDir = path.join(tempDir, teamName);
|
||||||
|
const configPath = path.join(teamDir, 'config.json');
|
||||||
|
await fs.mkdir(teamDir, { recursive: true });
|
||||||
|
await fs.writeFile(
|
||||||
|
configPath,
|
||||||
|
JSON.stringify({
|
||||||
|
name: 'Before',
|
||||||
|
members: [{ name: 'team-lead', agentType: 'team-lead' }],
|
||||||
|
}),
|
||||||
|
'utf8'
|
||||||
|
);
|
||||||
|
const readFileSpy = vi.spyOn(nodeFs.promises, 'readFile');
|
||||||
|
|
||||||
|
const reader = new TeamConfigReader();
|
||||||
|
expect((await reader.getConfigSnapshot(teamName))?.name).toBe('Before');
|
||||||
|
await fs.writeFile(
|
||||||
|
configPath,
|
||||||
|
JSON.stringify({
|
||||||
|
name: 'After',
|
||||||
|
members: [{ name: 'team-lead', agentType: 'team-lead' }],
|
||||||
|
}),
|
||||||
|
'utf8'
|
||||||
|
);
|
||||||
|
|
||||||
|
expect((await reader.getConfigSnapshot(teamName))?.name).toBe('After');
|
||||||
|
expect(readFileSpy).toHaveBeenCalledTimes(2);
|
||||||
|
});
|
||||||
|
|
||||||
|
it('primeConfig updates snapshot cache immediately after app-owned writes', async () => {
|
||||||
|
const teamName = 'prime-cache-team';
|
||||||
|
const teamDir = path.join(tempDir, teamName);
|
||||||
|
await fs.mkdir(teamDir, { recursive: true });
|
||||||
|
await fs.writeFile(
|
||||||
|
path.join(teamDir, 'config.json'),
|
||||||
|
JSON.stringify({
|
||||||
|
name: 'Before Prime',
|
||||||
|
members: [{ name: 'team-lead', agentType: 'team-lead' }],
|
||||||
|
}),
|
||||||
|
'utf8'
|
||||||
|
);
|
||||||
|
|
||||||
|
const reader = new TeamConfigReader();
|
||||||
|
expect((await reader.getConfigSnapshot(teamName))?.name).toBe('Before Prime');
|
||||||
|
await fs.writeFile(
|
||||||
|
path.join(teamDir, 'config.json'),
|
||||||
|
JSON.stringify({
|
||||||
|
name: 'After Prime',
|
||||||
|
members: [{ name: 'team-lead', agentType: 'team-lead' }],
|
||||||
|
}),
|
||||||
|
'utf8'
|
||||||
|
);
|
||||||
|
await TeamConfigReader.primeConfig(teamName, {
|
||||||
|
name: 'After Prime',
|
||||||
|
members: [{ name: 'team-lead', agentType: 'team-lead' }],
|
||||||
|
} as never);
|
||||||
|
|
||||||
|
const snapshot = await reader.getConfigSnapshot(teamName);
|
||||||
|
expect(snapshot?.name).toBe('After Prime');
|
||||||
|
});
|
||||||
|
|
||||||
|
it('does not let stale in-flight snapshot reads overwrite a primed config cache', async () => {
|
||||||
|
const teamName = 'stale-read-prime-team';
|
||||||
|
const teamDir = path.join(tempDir, teamName);
|
||||||
|
const configPath = path.join(teamDir, 'config.json');
|
||||||
|
await fs.mkdir(teamDir, { recursive: true });
|
||||||
|
const staleRaw = JSON.stringify({
|
||||||
|
name: 'Stale Read',
|
||||||
|
members: [{ name: 'team-lead', agentType: 'team-lead' }],
|
||||||
|
});
|
||||||
|
await fs.writeFile(configPath, staleRaw, 'utf8');
|
||||||
|
|
||||||
|
const readDeferred = createDeferred<string>();
|
||||||
|
const realReadFile = nodeFs.promises.readFile.bind(nodeFs.promises);
|
||||||
|
let intercepted = false;
|
||||||
|
vi.spyOn(nodeFs.promises, 'readFile').mockImplementation(
|
||||||
|
((file: unknown, ...args: unknown[]) => {
|
||||||
|
if (!intercepted && String(file) === configPath) {
|
||||||
|
intercepted = true;
|
||||||
|
return readDeferred.promise as never;
|
||||||
|
}
|
||||||
|
return realReadFile(file as never, ...(args as never[])) as never;
|
||||||
|
}) as never
|
||||||
|
);
|
||||||
|
|
||||||
|
const reader = new TeamConfigReader();
|
||||||
|
const staleSnapshot = reader.getConfigSnapshot(teamName);
|
||||||
|
await vi.waitFor(() => expect(intercepted).toBe(true));
|
||||||
|
|
||||||
|
await fs.writeFile(
|
||||||
|
configPath,
|
||||||
|
JSON.stringify({
|
||||||
|
name: 'Fresh Prime',
|
||||||
|
members: [{ name: 'team-lead', agentType: 'team-lead' }],
|
||||||
|
}),
|
||||||
|
'utf8'
|
||||||
|
);
|
||||||
|
await TeamConfigReader.primeConfig(teamName, {
|
||||||
|
name: 'Fresh Prime',
|
||||||
|
members: [{ name: 'team-lead', agentType: 'team-lead' }],
|
||||||
|
} as never);
|
||||||
|
|
||||||
|
readDeferred.resolve(staleRaw);
|
||||||
|
expect((await staleSnapshot)?.name).toBe('Stale Read');
|
||||||
|
|
||||||
|
vi.spyOn(nodeFs.promises, 'stat').mockRejectedValue(new Error('stat unavailable'));
|
||||||
|
expect((await reader.getConfigSnapshot(teamName))?.name).toBe('Fresh Prime');
|
||||||
|
});
|
||||||
|
|
||||||
|
it('does not let stale in-flight snapshot read failures invalidate a primed config cache', async () => {
|
||||||
|
const teamName = 'stale-read-failure-prime-team';
|
||||||
|
const teamDir = path.join(tempDir, teamName);
|
||||||
|
const configPath = path.join(teamDir, 'config.json');
|
||||||
|
await fs.mkdir(teamDir, { recursive: true });
|
||||||
|
await fs.writeFile(
|
||||||
|
configPath,
|
||||||
|
JSON.stringify({
|
||||||
|
name: 'Before Failure',
|
||||||
|
members: [{ name: 'team-lead', agentType: 'team-lead' }],
|
||||||
|
}),
|
||||||
|
'utf8'
|
||||||
|
);
|
||||||
|
|
||||||
|
const readDeferred = createDeferred<string>();
|
||||||
|
const realReadFile = nodeFs.promises.readFile.bind(nodeFs.promises);
|
||||||
|
let intercepted = false;
|
||||||
|
vi.spyOn(nodeFs.promises, 'readFile').mockImplementation(
|
||||||
|
((file: unknown, ...args: unknown[]) => {
|
||||||
|
if (!intercepted && String(file) === configPath) {
|
||||||
|
intercepted = true;
|
||||||
|
return readDeferred.promise as never;
|
||||||
|
}
|
||||||
|
return realReadFile(file as never, ...(args as never[])) as never;
|
||||||
|
}) as never
|
||||||
|
);
|
||||||
|
|
||||||
|
const reader = new TeamConfigReader();
|
||||||
|
const staleSnapshot = reader.getConfigSnapshot(teamName);
|
||||||
|
await vi.waitFor(() => expect(intercepted).toBe(true));
|
||||||
|
|
||||||
|
await fs.writeFile(
|
||||||
|
configPath,
|
||||||
|
JSON.stringify({
|
||||||
|
name: 'Fresh After Failure',
|
||||||
|
members: [{ name: 'team-lead', agentType: 'team-lead' }],
|
||||||
|
}),
|
||||||
|
'utf8'
|
||||||
|
);
|
||||||
|
await TeamConfigReader.primeConfig(teamName, {
|
||||||
|
name: 'Fresh After Failure',
|
||||||
|
members: [{ name: 'team-lead', agentType: 'team-lead' }],
|
||||||
|
} as never);
|
||||||
|
|
||||||
|
readDeferred.reject(new Error('old read failed'));
|
||||||
|
await expect(staleSnapshot).resolves.toBeNull();
|
||||||
|
|
||||||
|
vi.spyOn(nodeFs.promises, 'stat').mockRejectedValue(new Error('stat unavailable'));
|
||||||
|
expect((await reader.getConfigSnapshot(teamName))?.name).toBe('Fresh After Failure');
|
||||||
|
});
|
||||||
|
|
||||||
|
it('does not let stale in-flight snapshot stat results invalidate a primed config cache', async () => {
|
||||||
|
const teamName = 'stale-stat-prime-team';
|
||||||
|
const teamDir = path.join(tempDir, teamName);
|
||||||
|
const configPath = path.join(teamDir, 'config.json');
|
||||||
|
await fs.mkdir(teamDir, { recursive: true });
|
||||||
|
await fs.writeFile(
|
||||||
|
configPath,
|
||||||
|
JSON.stringify({
|
||||||
|
name: 'Before Stat Race',
|
||||||
|
members: [{ name: 'team-lead', agentType: 'team-lead' }],
|
||||||
|
}),
|
||||||
|
'utf8'
|
||||||
|
);
|
||||||
|
|
||||||
|
const statDeferred = createDeferred<unknown>();
|
||||||
|
let statCalls = 0;
|
||||||
|
vi.spyOn(nodeFs.promises, 'stat').mockImplementation(async () => {
|
||||||
|
statCalls++;
|
||||||
|
if (statCalls === 1) {
|
||||||
|
return (await statDeferred.promise) as never;
|
||||||
|
}
|
||||||
|
throw new Error('stat unavailable');
|
||||||
|
});
|
||||||
|
|
||||||
|
const reader = new TeamConfigReader();
|
||||||
|
const snapshot = reader.getConfigSnapshot(teamName);
|
||||||
|
await vi.waitFor(() => expect(statCalls).toBe(1));
|
||||||
|
|
||||||
|
await fs.writeFile(
|
||||||
|
configPath,
|
||||||
|
JSON.stringify({
|
||||||
|
name: 'Fresh After Stat Race',
|
||||||
|
members: [{ name: 'team-lead', agentType: 'team-lead' }],
|
||||||
|
}),
|
||||||
|
'utf8'
|
||||||
|
);
|
||||||
|
await TeamConfigReader.primeConfig(teamName, {
|
||||||
|
name: 'Fresh After Stat Race',
|
||||||
|
members: [{ name: 'team-lead', agentType: 'team-lead' }],
|
||||||
|
} as never);
|
||||||
|
|
||||||
|
statDeferred.resolve({
|
||||||
|
size: BigInt(4096),
|
||||||
|
mode: BigInt(33188),
|
||||||
|
dev: BigInt(1),
|
||||||
|
ino: BigInt(2),
|
||||||
|
mtimeMs: 1000,
|
||||||
|
ctimeMs: 1000,
|
||||||
|
birthtimeMs: 1000,
|
||||||
|
isFile: () => false,
|
||||||
|
});
|
||||||
|
|
||||||
|
expect((await snapshot)?.name).toBe('Fresh After Stat Race');
|
||||||
|
});
|
||||||
|
|
||||||
|
it('invalidateTeam forces the next snapshot to reread config content', async () => {
|
||||||
|
const teamName = 'invalidate-cache-team';
|
||||||
|
const teamDir = path.join(tempDir, teamName);
|
||||||
|
const configPath = path.join(teamDir, 'config.json');
|
||||||
|
await fs.mkdir(teamDir, { recursive: true });
|
||||||
|
await fs.writeFile(
|
||||||
|
configPath,
|
||||||
|
JSON.stringify({
|
||||||
|
name: 'Before Invalidate',
|
||||||
|
members: [{ name: 'team-lead', agentType: 'team-lead' }],
|
||||||
|
}),
|
||||||
|
'utf8'
|
||||||
|
);
|
||||||
|
const readFileSpy = vi.spyOn(nodeFs.promises, 'readFile');
|
||||||
|
|
||||||
|
const reader = new TeamConfigReader();
|
||||||
|
expect((await reader.getConfigSnapshot(teamName))?.name).toBe('Before Invalidate');
|
||||||
|
await fs.writeFile(
|
||||||
|
configPath,
|
||||||
|
JSON.stringify({
|
||||||
|
name: 'After Invalidate',
|
||||||
|
members: [{ name: 'team-lead', agentType: 'team-lead' }],
|
||||||
|
}),
|
||||||
|
'utf8'
|
||||||
|
);
|
||||||
|
TeamConfigReader.invalidateTeam(teamName);
|
||||||
|
|
||||||
|
expect((await reader.getConfigSnapshot(teamName))?.name).toBe('After Invalidate');
|
||||||
|
expect(readFileSpy).toHaveBeenCalledTimes(2);
|
||||||
|
});
|
||||||
|
|
||||||
|
it('uses recent snapshot cache on stat failure but verified mode does not', async () => {
|
||||||
|
const teamName = 'stat-failure-team';
|
||||||
|
const teamDir = path.join(tempDir, teamName);
|
||||||
|
await fs.mkdir(teamDir, { recursive: true });
|
||||||
|
await fs.writeFile(
|
||||||
|
path.join(teamDir, 'config.json'),
|
||||||
|
JSON.stringify({
|
||||||
|
name: 'Recent Cache',
|
||||||
|
members: [{ name: 'team-lead', agentType: 'team-lead' }],
|
||||||
|
}),
|
||||||
|
'utf8'
|
||||||
|
);
|
||||||
|
|
||||||
|
const reader = new TeamConfigReader();
|
||||||
|
expect((await reader.getConfigSnapshot(teamName))?.name).toBe('Recent Cache');
|
||||||
|
vi.spyOn(nodeFs.promises, 'stat').mockRejectedValue(new Error('stat unavailable'));
|
||||||
|
|
||||||
|
expect((await reader.getConfigSnapshot(teamName))?.name).toBe('Recent Cache');
|
||||||
|
await expect(reader.getConfigVerified(teamName)).resolves.toBeNull();
|
||||||
|
});
|
||||||
|
|
||||||
|
it('clears snapshot cache after parse failure', async () => {
|
||||||
|
const teamName = 'parse-failure-team';
|
||||||
|
const teamDir = path.join(tempDir, teamName);
|
||||||
|
const configPath = path.join(teamDir, 'config.json');
|
||||||
|
await fs.mkdir(teamDir, { recursive: true });
|
||||||
|
await fs.writeFile(
|
||||||
|
configPath,
|
||||||
|
JSON.stringify({
|
||||||
|
name: 'Valid Config',
|
||||||
|
members: [{ name: 'team-lead', agentType: 'team-lead' }],
|
||||||
|
}),
|
||||||
|
'utf8'
|
||||||
|
);
|
||||||
|
|
||||||
|
const reader = new TeamConfigReader();
|
||||||
|
expect((await reader.getConfigSnapshot(teamName))?.name).toBe('Valid Config');
|
||||||
|
await fs.writeFile(configPath, '{"name":', 'utf8');
|
||||||
|
|
||||||
|
expect(await reader.getConfigSnapshot(teamName)).toBeNull();
|
||||||
|
await fs.rm(configPath);
|
||||||
|
expect(await reader.getConfigSnapshot(teamName)).toBeNull();
|
||||||
|
});
|
||||||
|
|
||||||
|
it('clears snapshot cache when config disappears and reloads after recreation', async () => {
|
||||||
|
const teamName = 'missing-then-recreated-team';
|
||||||
|
const teamDir = path.join(tempDir, teamName);
|
||||||
|
const configPath = path.join(teamDir, 'config.json');
|
||||||
|
await fs.mkdir(teamDir, { recursive: true });
|
||||||
|
await fs.writeFile(
|
||||||
|
configPath,
|
||||||
|
JSON.stringify({
|
||||||
|
name: 'Before Delete',
|
||||||
|
members: [{ name: 'team-lead', agentType: 'team-lead' }],
|
||||||
|
}),
|
||||||
|
'utf8'
|
||||||
|
);
|
||||||
|
|
||||||
|
const reader = new TeamConfigReader();
|
||||||
|
expect((await reader.getConfigSnapshot(teamName))?.name).toBe('Before Delete');
|
||||||
|
await fs.rm(configPath);
|
||||||
|
|
||||||
|
expect(await reader.getConfigSnapshot(teamName)).toBeNull();
|
||||||
|
await fs.writeFile(
|
||||||
|
configPath,
|
||||||
|
JSON.stringify({
|
||||||
|
name: 'After Recreate',
|
||||||
|
members: [{ name: 'team-lead', agentType: 'team-lead' }],
|
||||||
|
}),
|
||||||
|
'utf8'
|
||||||
|
);
|
||||||
|
|
||||||
|
expect((await reader.getConfigSnapshot(teamName))?.name).toBe('After Recreate');
|
||||||
|
});
|
||||||
|
|
||||||
|
it('bounds stale snapshots on coarse fingerprints with periodic full verification', async () => {
|
||||||
|
vi.useFakeTimers();
|
||||||
|
vi.setSystemTime(new Date('2026-04-30T12:00:00.000Z'));
|
||||||
|
|
||||||
|
const teamName = 'coarse-fs-team';
|
||||||
|
const teamDir = path.join(tempDir, teamName);
|
||||||
|
const configPath = path.join(teamDir, 'config.json');
|
||||||
|
await fs.mkdir(teamDir, { recursive: true });
|
||||||
|
await fs.writeFile(
|
||||||
|
configPath,
|
||||||
|
JSON.stringify({
|
||||||
|
name: 'Alpha',
|
||||||
|
members: [{ name: 'team-lead', agentType: 'team-lead' }],
|
||||||
|
}),
|
||||||
|
'utf8'
|
||||||
|
);
|
||||||
|
const readFileSpy = vi.spyOn(nodeFs.promises, 'readFile');
|
||||||
|
vi.spyOn(nodeFs.promises, 'stat').mockResolvedValue({
|
||||||
|
size: BigInt(4096),
|
||||||
|
mode: BigInt(33188),
|
||||||
|
dev: BigInt(1),
|
||||||
|
ino: BigInt(2),
|
||||||
|
mtimeMs: 1000,
|
||||||
|
ctimeMs: 1000,
|
||||||
|
birthtimeMs: 1000,
|
||||||
|
isFile: () => true,
|
||||||
|
} as never);
|
||||||
|
|
||||||
|
const reader = new TeamConfigReader();
|
||||||
|
expect((await reader.getConfigSnapshot(teamName))?.name).toBe('Alpha');
|
||||||
|
await fs.writeFile(
|
||||||
|
configPath,
|
||||||
|
JSON.stringify({
|
||||||
|
name: 'Bravo',
|
||||||
|
members: [{ name: 'team-lead', agentType: 'team-lead' }],
|
||||||
|
}),
|
||||||
|
'utf8'
|
||||||
|
);
|
||||||
|
|
||||||
|
expect((await reader.getConfigSnapshot(teamName))?.name).toBe('Alpha');
|
||||||
|
vi.advanceTimersByTime(1_501);
|
||||||
|
expect((await reader.getConfigSnapshot(teamName))?.name).toBe('Bravo');
|
||||||
|
expect(readFileSpy).toHaveBeenCalledTimes(2);
|
||||||
|
});
|
||||||
});
|
});
|
||||||
|
|
|
||||||
|
|
@ -423,6 +423,9 @@ function createGetTeamDataHarness(
|
||||||
const getConfig = vi.fn(async () =>
|
const getConfig = vi.fn(async () =>
|
||||||
options.config === undefined ? buildDefaultTeamConfig() : options.config
|
options.config === undefined ? buildDefaultTeamConfig() : options.config
|
||||||
);
|
);
|
||||||
|
const getConfigSnapshot = vi.fn(async () =>
|
||||||
|
options.config === undefined ? buildDefaultTeamConfig() : options.config
|
||||||
|
);
|
||||||
const getTasks =
|
const getTasks =
|
||||||
options.getTasks ??
|
options.getTasks ??
|
||||||
(async () => {
|
(async () => {
|
||||||
|
|
@ -496,6 +499,7 @@ function createGetTeamDataHarness(
|
||||||
{
|
{
|
||||||
listTeams: vi.fn(),
|
listTeams: vi.fn(),
|
||||||
getConfig,
|
getConfig,
|
||||||
|
getConfigSnapshot,
|
||||||
} as never,
|
} as never,
|
||||||
taskReader as never,
|
taskReader as never,
|
||||||
inboxReader as never,
|
inboxReader as never,
|
||||||
|
|
@ -522,6 +526,7 @@ function createGetTeamDataHarness(
|
||||||
return {
|
return {
|
||||||
service,
|
service,
|
||||||
getConfig,
|
getConfig,
|
||||||
|
getConfigSnapshot,
|
||||||
taskReader,
|
taskReader,
|
||||||
inboxReader,
|
inboxReader,
|
||||||
membersMetaStore,
|
membersMetaStore,
|
||||||
|
|
@ -4431,6 +4436,24 @@ describe('TeamDataService', () => {
|
||||||
expect(harness.listProcessesSpy).not.toHaveBeenCalled();
|
expect(harness.listProcessesSpy).not.toHaveBeenCalled();
|
||||||
});
|
});
|
||||||
|
|
||||||
|
it('uses snapshot config reads for UI team data snapshots', async () => {
|
||||||
|
const harness = createGetTeamDataHarness();
|
||||||
|
|
||||||
|
await harness.service.getTeamData('my-team');
|
||||||
|
|
||||||
|
expect(harness.getConfigSnapshot).toHaveBeenCalledWith('my-team');
|
||||||
|
expect(harness.getConfig).not.toHaveBeenCalled();
|
||||||
|
});
|
||||||
|
|
||||||
|
it('uses snapshot config reads for UI message feed snapshots', async () => {
|
||||||
|
const harness = createGetTeamDataHarness();
|
||||||
|
|
||||||
|
await harness.service.getMessageFeed('my-team');
|
||||||
|
|
||||||
|
expect(harness.getConfigSnapshot).toHaveBeenCalledWith('my-team');
|
||||||
|
expect(harness.getConfig).not.toHaveBeenCalled();
|
||||||
|
});
|
||||||
|
|
||||||
it('starts light reads immediately, bounds heavy reads, and keeps processes outside the parallel phase', async () => {
|
it('starts light reads immediately, bounds heavy reads, and keeps processes outside the parallel phase', async () => {
|
||||||
const order: string[] = [];
|
const order: string[] = [];
|
||||||
const tasksDeferred = createDeferred<TeamTask[]>();
|
const tasksDeferred = createDeferred<TeamTask[]>();
|
||||||
|
|
|
||||||
143
test/main/services/team/TeamDataWorkerClient.test.ts
Normal file
143
test/main/services/team/TeamDataWorkerClient.test.ts
Normal file
|
|
@ -0,0 +1,143 @@
|
||||||
|
import { afterEach, describe, expect, it, vi } from 'vitest';
|
||||||
|
|
||||||
|
const hoisted = vi.hoisted(() => {
|
||||||
|
const workers: Array<{
|
||||||
|
messages: unknown[];
|
||||||
|
handlers: Map<string, (value: unknown) => void>;
|
||||||
|
postMessage: (message: unknown) => void;
|
||||||
|
on: (event: string, handler: (value: unknown) => void) => void;
|
||||||
|
terminate: () => Promise<void>;
|
||||||
|
}> = [];
|
||||||
|
const createMockWorker = vi.fn().mockImplementation(() => {
|
||||||
|
const worker = {
|
||||||
|
messages: [] as unknown[],
|
||||||
|
handlers: new Map<string, (value: unknown) => void>(),
|
||||||
|
postMessage(message: unknown) {
|
||||||
|
worker.messages.push(message);
|
||||||
|
const request = message as { id: string; op: string; payload?: { teamName?: string } };
|
||||||
|
queueMicrotask(() => {
|
||||||
|
const handler = worker.handlers.get('message');
|
||||||
|
if (!handler) return;
|
||||||
|
handler({
|
||||||
|
id: request.id,
|
||||||
|
ok: true,
|
||||||
|
result:
|
||||||
|
request.op === 'getTeamData'
|
||||||
|
? { teamName: request.payload?.teamName, config: { name: 'Team' } }
|
||||||
|
: null,
|
||||||
|
});
|
||||||
|
});
|
||||||
|
},
|
||||||
|
on(event: string, handler: (value: unknown) => void) {
|
||||||
|
worker.handlers.set(event, handler);
|
||||||
|
},
|
||||||
|
terminate: vi.fn(async () => undefined),
|
||||||
|
};
|
||||||
|
workers.push(worker);
|
||||||
|
return worker;
|
||||||
|
});
|
||||||
|
return {
|
||||||
|
workers,
|
||||||
|
createMockWorker,
|
||||||
|
};
|
||||||
|
});
|
||||||
|
|
||||||
|
vi.mock('node:fs', async () => {
|
||||||
|
const actual = await vi.importActual<typeof import('node:fs')>('node:fs');
|
||||||
|
return {
|
||||||
|
...actual,
|
||||||
|
existsSync: vi.fn(() => true),
|
||||||
|
};
|
||||||
|
});
|
||||||
|
|
||||||
|
vi.mock('node:worker_threads', () => ({
|
||||||
|
Worker: hoisted.createMockWorker,
|
||||||
|
default: {
|
||||||
|
Worker: hoisted.createMockWorker,
|
||||||
|
},
|
||||||
|
}));
|
||||||
|
|
||||||
|
describe('TeamDataWorkerClient', () => {
|
||||||
|
afterEach(() => {
|
||||||
|
vi.resetModules();
|
||||||
|
vi.clearAllMocks();
|
||||||
|
hoisted.workers.length = 0;
|
||||||
|
});
|
||||||
|
|
||||||
|
it('deduplicates concurrent getTeamData calls for the same team', async () => {
|
||||||
|
const { TeamDataWorkerClient } = await import(
|
||||||
|
'../../../../src/main/services/team/TeamDataWorkerClient'
|
||||||
|
);
|
||||||
|
const client = new TeamDataWorkerClient();
|
||||||
|
|
||||||
|
const [first, second] = await Promise.all([
|
||||||
|
client.getTeamData('my-team'),
|
||||||
|
client.getTeamData('my-team'),
|
||||||
|
]);
|
||||||
|
|
||||||
|
expect(first).toEqual(second);
|
||||||
|
expect(hoisted.workers).toHaveLength(1);
|
||||||
|
expect(hoisted.workers[0].messages).toHaveLength(1);
|
||||||
|
expect(hoisted.workers[0].messages[0]).toMatchObject({
|
||||||
|
op: 'getTeamData',
|
||||||
|
payload: { teamName: 'my-team' },
|
||||||
|
});
|
||||||
|
|
||||||
|
client.dispose();
|
||||||
|
});
|
||||||
|
|
||||||
|
it('sends best-effort team config invalidation to the worker', async () => {
|
||||||
|
const { TeamDataWorkerClient } = await import(
|
||||||
|
'../../../../src/main/services/team/TeamDataWorkerClient'
|
||||||
|
);
|
||||||
|
const client = new TeamDataWorkerClient();
|
||||||
|
await client.getTeamData('my-team');
|
||||||
|
hoisted.workers[0].messages.length = 0;
|
||||||
|
|
||||||
|
client.invalidateTeamConfig('my-team');
|
||||||
|
await new Promise((resolve) => setTimeout(resolve, 0));
|
||||||
|
|
||||||
|
expect(hoisted.workers).toHaveLength(1);
|
||||||
|
expect(hoisted.workers[0].messages).toHaveLength(1);
|
||||||
|
expect(hoisted.workers[0].messages[0]).toMatchObject({
|
||||||
|
op: 'invalidateTeamConfig',
|
||||||
|
payload: { teamName: 'my-team' },
|
||||||
|
});
|
||||||
|
|
||||||
|
client.dispose();
|
||||||
|
});
|
||||||
|
|
||||||
|
it('clears in-flight getTeamData dedupe when invalidating team config', async () => {
|
||||||
|
const { TeamDataWorkerClient } = await import(
|
||||||
|
'../../../../src/main/services/team/TeamDataWorkerClient'
|
||||||
|
);
|
||||||
|
const client = new TeamDataWorkerClient();
|
||||||
|
|
||||||
|
const first = client.getTeamData('my-team');
|
||||||
|
client.invalidateTeamConfig('my-team');
|
||||||
|
const second = client.getTeamData('my-team');
|
||||||
|
|
||||||
|
await Promise.all([first, second]);
|
||||||
|
|
||||||
|
expect(hoisted.workers).toHaveLength(1);
|
||||||
|
expect(hoisted.workers[0].messages.map((message) => (message as { op: string }).op)).toEqual([
|
||||||
|
'getTeamData',
|
||||||
|
'invalidateTeamConfig',
|
||||||
|
'getTeamData',
|
||||||
|
]);
|
||||||
|
|
||||||
|
client.dispose();
|
||||||
|
});
|
||||||
|
|
||||||
|
it('does not spawn a worker only to send config invalidation', async () => {
|
||||||
|
const { TeamDataWorkerClient } = await import(
|
||||||
|
'../../../../src/main/services/team/TeamDataWorkerClient'
|
||||||
|
);
|
||||||
|
const client = new TeamDataWorkerClient();
|
||||||
|
|
||||||
|
client.invalidateTeamConfig('my-team');
|
||||||
|
await new Promise((resolve) => setTimeout(resolve, 0));
|
||||||
|
|
||||||
|
expect(hoisted.workers).toHaveLength(0);
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
@ -6463,7 +6463,7 @@ describe('TeamProvisioningService', () => {
|
||||||
expect(writtenSnapshot?.expectedMembers).toEqual(['bob', 'alice']);
|
expect(writtenSnapshot?.expectedMembers).toEqual(['bob', 'alice']);
|
||||||
});
|
});
|
||||||
|
|
||||||
it('accepts duplicate OpenCode bootstrap check-ins for the same runtime session without rewriting liveness', async () => {
|
it('accepts duplicate OpenCode bootstrap check-ins for the same runtime session and refreshes liveness', async () => {
|
||||||
const svc = new TeamProvisioningService();
|
const svc = new TeamProvisioningService();
|
||||||
const previousSnapshot = {
|
const previousSnapshot = {
|
||||||
version: 2 as const,
|
version: 2 as const,
|
||||||
|
|
@ -6520,7 +6520,14 @@ describe('TeamProvisioningService', () => {
|
||||||
diagnostics: ['opencode_bootstrap_checkin_duplicate_accepted'],
|
diagnostics: ['opencode_bootstrap_checkin_duplicate_accepted'],
|
||||||
runtimeSessionId: 'session-bob',
|
runtimeSessionId: 'session-bob',
|
||||||
});
|
});
|
||||||
expect(updateLiveness).not.toHaveBeenCalled();
|
expect(updateLiveness).toHaveBeenCalledWith(
|
||||||
|
expect.objectContaining({
|
||||||
|
teamName: 'mixed-team',
|
||||||
|
runId: 'opencode-run-1',
|
||||||
|
memberName: 'bob',
|
||||||
|
runtimeSessionId: 'session-bob',
|
||||||
|
})
|
||||||
|
);
|
||||||
});
|
});
|
||||||
|
|
||||||
it('rejects duplicate OpenCode bootstrap check-ins for members removed after the first check-in', async () => {
|
it('rejects duplicate OpenCode bootstrap check-ins for members removed after the first check-in', async () => {
|
||||||
|
|
@ -6869,6 +6876,114 @@ describe('TeamProvisioningService', () => {
|
||||||
]);
|
]);
|
||||||
});
|
});
|
||||||
|
|
||||||
|
it('updates the live mixed OpenCode lane when bootstrap check-in arrives after launch command completion', async () => {
|
||||||
|
const svc = new TeamProvisioningService();
|
||||||
|
const persistLaunchStateSnapshot = vi.spyOn(svc as any, 'persistLaunchStateSnapshot');
|
||||||
|
const teamName = 'mixed-live-checkin-team';
|
||||||
|
const laneId = 'secondary:opencode:tom';
|
||||||
|
const runId = 'opencode-run-tom';
|
||||||
|
const run = createMemberSpawnRun({
|
||||||
|
runId: 'lead-run',
|
||||||
|
teamName,
|
||||||
|
expectedMembers: ['bob', 'tom'],
|
||||||
|
memberSpawnStatuses: new Map([
|
||||||
|
[
|
||||||
|
'bob',
|
||||||
|
createMemberSpawnStatusEntry({
|
||||||
|
status: 'online',
|
||||||
|
launchState: 'confirmed_alive',
|
||||||
|
runtimeAlive: true,
|
||||||
|
bootstrapConfirmed: true,
|
||||||
|
hardFailure: false,
|
||||||
|
}),
|
||||||
|
],
|
||||||
|
[
|
||||||
|
'tom',
|
||||||
|
createMemberSpawnStatusEntry({
|
||||||
|
status: 'waiting',
|
||||||
|
launchState: 'runtime_pending_bootstrap',
|
||||||
|
runtimeAlive: false,
|
||||||
|
bootstrapConfirmed: false,
|
||||||
|
hardFailure: false,
|
||||||
|
}),
|
||||||
|
],
|
||||||
|
]),
|
||||||
|
});
|
||||||
|
Object.assign(run, {
|
||||||
|
isLaunch: true,
|
||||||
|
request: { providerId: 'codex', members: [] },
|
||||||
|
effectiveMembers: [{ name: 'bob', providerId: 'codex', model: 'gpt-5.5' }],
|
||||||
|
allEffectiveMembers: [
|
||||||
|
{ name: 'bob', providerId: 'codex', model: 'gpt-5.5' },
|
||||||
|
{ name: 'tom', providerId: 'opencode', model: 'openrouter/minimax/minimax-m2.5' },
|
||||||
|
],
|
||||||
|
mixedSecondaryLanes: [
|
||||||
|
{
|
||||||
|
laneId,
|
||||||
|
providerId: 'opencode',
|
||||||
|
member: {
|
||||||
|
name: 'tom',
|
||||||
|
providerId: 'opencode',
|
||||||
|
model: 'openrouter/minimax/minimax-m2.5',
|
||||||
|
},
|
||||||
|
runId,
|
||||||
|
state: 'finished',
|
||||||
|
result: {
|
||||||
|
runId,
|
||||||
|
teamName,
|
||||||
|
launchPhase: 'active',
|
||||||
|
teamLaunchState: 'partial_pending',
|
||||||
|
members: {
|
||||||
|
tom: {
|
||||||
|
memberName: 'tom',
|
||||||
|
providerId: 'opencode',
|
||||||
|
launchState: 'runtime_pending_bootstrap',
|
||||||
|
agentToolAccepted: true,
|
||||||
|
runtimeAlive: false,
|
||||||
|
bootstrapConfirmed: false,
|
||||||
|
hardFailure: false,
|
||||||
|
livenessKind: 'registered_only',
|
||||||
|
diagnostics: ['registered runtime metadata without live process'],
|
||||||
|
},
|
||||||
|
},
|
||||||
|
warnings: [],
|
||||||
|
diagnostics: ['registered runtime metadata without live process'],
|
||||||
|
},
|
||||||
|
warnings: [],
|
||||||
|
diagnostics: ['registered runtime metadata without live process'],
|
||||||
|
},
|
||||||
|
],
|
||||||
|
});
|
||||||
|
(svc as any).aliveRunByTeam.set(teamName, 'lead-run');
|
||||||
|
(svc as any).runs.set('lead-run', run);
|
||||||
|
|
||||||
|
await (svc as any).updateOpenCodeRuntimeMemberLiveness({
|
||||||
|
teamName,
|
||||||
|
runId,
|
||||||
|
memberName: 'tom',
|
||||||
|
runtimeSessionId: 'ses_tom_live',
|
||||||
|
observedAt: '2026-04-22T12:05:00.000Z',
|
||||||
|
diagnostics: undefined,
|
||||||
|
metadata: undefined,
|
||||||
|
reason: 'OpenCode runtime bootstrap check-in accepted',
|
||||||
|
});
|
||||||
|
|
||||||
|
expect(run.memberSpawnStatuses.get('tom')).toMatchObject({
|
||||||
|
status: 'online',
|
||||||
|
launchState: 'confirmed_alive',
|
||||||
|
runtimeAlive: true,
|
||||||
|
bootstrapConfirmed: true,
|
||||||
|
hardFailure: false,
|
||||||
|
livenessKind: 'confirmed_bootstrap',
|
||||||
|
});
|
||||||
|
expect(run.mixedSecondaryLanes[0]?.result?.members.tom).toMatchObject({
|
||||||
|
launchState: 'confirmed_alive',
|
||||||
|
bootstrapConfirmed: true,
|
||||||
|
sessionId: 'ses_tom_live',
|
||||||
|
});
|
||||||
|
expect(persistLaunchStateSnapshot).toHaveBeenCalledWith(run, 'finished');
|
||||||
|
});
|
||||||
|
|
||||||
it('uses the secondary lane run id for OpenCode runtime delivery journal acceptance', async () => {
|
it('uses the secondary lane run id for OpenCode runtime delivery journal acceptance', async () => {
|
||||||
const svc = new TeamProvisioningService();
|
const svc = new TeamProvisioningService();
|
||||||
const delivered = new Map<
|
const delivered = new Map<
|
||||||
|
|
@ -13188,6 +13303,222 @@ describe('TeamProvisioningService', () => {
|
||||||
});
|
});
|
||||||
});
|
});
|
||||||
|
|
||||||
|
it('recovers degraded OpenCode file-lock failures when bootstrap evidence committed later', async () => {
|
||||||
|
const teamName = 'atlas-hq-file-lock-late-evidence';
|
||||||
|
const tomLaneId = 'secondary:opencode:tom';
|
||||||
|
const tomRunId = 'tom-runtime-run';
|
||||||
|
writeTeamMeta(teamName, {
|
||||||
|
providerId: 'codex',
|
||||||
|
providerBackendId: 'codex-native',
|
||||||
|
model: 'gpt-5.5',
|
||||||
|
});
|
||||||
|
writeMembersMeta(teamName, [
|
||||||
|
{ name: 'bob', providerId: 'codex', model: 'gpt-5.5' },
|
||||||
|
{ name: 'jack', providerId: 'codex', model: 'gpt-5.4' },
|
||||||
|
{ name: 'tom', providerId: 'opencode', model: 'openrouter/minimax/minimax-m2.5' },
|
||||||
|
]);
|
||||||
|
writeLaunchConfig(teamName, '/Users/test/proj', 'lead-session', ['bob', 'jack']);
|
||||||
|
writeBootstrapState(teamName, [
|
||||||
|
{ name: 'bob', status: 'bootstrap_confirmed', lastObservedAt: Date.now() - 60_000 },
|
||||||
|
{ name: 'jack', status: 'bootstrap_confirmed', lastObservedAt: Date.now() - 60_000 },
|
||||||
|
]);
|
||||||
|
fs.writeFileSync(
|
||||||
|
getTeamLaunchStatePath(teamName),
|
||||||
|
`${JSON.stringify(
|
||||||
|
createPersistedLaunchSnapshot({
|
||||||
|
teamName,
|
||||||
|
leadSessionId: 'lead-session',
|
||||||
|
launchPhase: 'finished',
|
||||||
|
expectedMembers: ['bob', 'jack', 'tom'],
|
||||||
|
bootstrapExpectedMembers: ['bob', 'jack'],
|
||||||
|
members: {
|
||||||
|
bob: {
|
||||||
|
name: 'bob',
|
||||||
|
providerId: 'codex',
|
||||||
|
laneId: 'primary',
|
||||||
|
laneKind: 'primary',
|
||||||
|
laneOwnerProviderId: 'codex',
|
||||||
|
launchState: 'failed_to_start',
|
||||||
|
agentToolAccepted: false,
|
||||||
|
runtimeAlive: false,
|
||||||
|
bootstrapConfirmed: false,
|
||||||
|
hardFailure: true,
|
||||||
|
hardFailureReason: 'Teammate was never spawned during launch.',
|
||||||
|
lastEvaluatedAt: '2026-04-23T10:00:00.000Z',
|
||||||
|
},
|
||||||
|
jack: {
|
||||||
|
name: 'jack',
|
||||||
|
providerId: 'codex',
|
||||||
|
laneId: 'primary',
|
||||||
|
laneKind: 'primary',
|
||||||
|
laneOwnerProviderId: 'codex',
|
||||||
|
launchState: 'failed_to_start',
|
||||||
|
agentToolAccepted: false,
|
||||||
|
runtimeAlive: false,
|
||||||
|
bootstrapConfirmed: false,
|
||||||
|
hardFailure: true,
|
||||||
|
hardFailureReason: 'Teammate was never spawned during launch.',
|
||||||
|
lastEvaluatedAt: '2026-04-23T10:00:00.000Z',
|
||||||
|
},
|
||||||
|
tom: {
|
||||||
|
name: 'tom',
|
||||||
|
providerId: 'opencode',
|
||||||
|
model: 'openrouter/minimax/minimax-m2.5',
|
||||||
|
laneId: tomLaneId,
|
||||||
|
laneKind: 'secondary',
|
||||||
|
laneOwnerProviderId: 'opencode',
|
||||||
|
launchState: 'failed_to_start',
|
||||||
|
agentToolAccepted: false,
|
||||||
|
runtimeAlive: false,
|
||||||
|
bootstrapConfirmed: false,
|
||||||
|
hardFailure: true,
|
||||||
|
hardFailureReason: `File lock timeout: ${path.join(
|
||||||
|
tempTeamsBase,
|
||||||
|
teamName,
|
||||||
|
'.opencode-runtime',
|
||||||
|
'lanes',
|
||||||
|
encodeURIComponent(tomLaneId),
|
||||||
|
'opencode-runtime-receipts.json'
|
||||||
|
)}`,
|
||||||
|
diagnostics: ['File lock timeout: opencode-runtime-receipts.json'],
|
||||||
|
lastEvaluatedAt: '2026-04-23T10:00:00.000Z',
|
||||||
|
},
|
||||||
|
},
|
||||||
|
updatedAt: '2026-04-23T10:00:00.000Z',
|
||||||
|
}),
|
||||||
|
null,
|
||||||
|
2
|
||||||
|
)}\n`,
|
||||||
|
'utf8'
|
||||||
|
);
|
||||||
|
await upsertOpenCodeRuntimeLaneIndexEntry({
|
||||||
|
teamsBasePath: tempTeamsBase,
|
||||||
|
teamName,
|
||||||
|
laneId: tomLaneId,
|
||||||
|
state: 'degraded',
|
||||||
|
diagnostics: ['File lock timeout: opencode-runtime-receipts.json'],
|
||||||
|
});
|
||||||
|
await writeCommittedOpenCodeSessionStore({
|
||||||
|
teamName,
|
||||||
|
laneId: tomLaneId,
|
||||||
|
runId: tomRunId,
|
||||||
|
sessions: [
|
||||||
|
{
|
||||||
|
id: 'ses_tom_late',
|
||||||
|
teamName,
|
||||||
|
memberName: 'tom',
|
||||||
|
runId: tomRunId,
|
||||||
|
laneId: tomLaneId,
|
||||||
|
providerId: 'opencode',
|
||||||
|
observedAt: '2026-04-23T10:01:00.000Z',
|
||||||
|
source: 'runtime_bootstrap_checkin',
|
||||||
|
},
|
||||||
|
],
|
||||||
|
});
|
||||||
|
|
||||||
|
const svc = new TeamProvisioningService();
|
||||||
|
const result = await svc.getMemberSpawnStatuses(teamName);
|
||||||
|
|
||||||
|
expect(result.statuses.tom).toMatchObject({
|
||||||
|
status: 'online',
|
||||||
|
launchState: 'confirmed_alive',
|
||||||
|
hardFailure: false,
|
||||||
|
bootstrapConfirmed: true,
|
||||||
|
});
|
||||||
|
expect(result.statuses.bob).toMatchObject({
|
||||||
|
status: 'online',
|
||||||
|
launchState: 'confirmed_alive',
|
||||||
|
hardFailure: false,
|
||||||
|
});
|
||||||
|
expect(result.statuses.jack).toMatchObject({
|
||||||
|
status: 'online',
|
||||||
|
launchState: 'confirmed_alive',
|
||||||
|
hardFailure: false,
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
it('does not recover degraded OpenCode file-lock failures from stale run evidence', async () => {
|
||||||
|
const teamName = 'atlas-hq-file-lock-stale-run-evidence';
|
||||||
|
const tomLaneId = 'secondary:opencode:tom';
|
||||||
|
writeTeamMeta(teamName, {
|
||||||
|
providerId: 'codex',
|
||||||
|
providerBackendId: 'codex-native',
|
||||||
|
model: 'gpt-5.5',
|
||||||
|
});
|
||||||
|
writeMembersMeta(teamName, [
|
||||||
|
{ name: 'tom', providerId: 'opencode', model: 'openrouter/minimax/minimax-m2.5' },
|
||||||
|
]);
|
||||||
|
writeLaunchConfig(teamName, '/Users/test/proj', 'lead-session', []);
|
||||||
|
fs.writeFileSync(
|
||||||
|
getTeamLaunchStatePath(teamName),
|
||||||
|
`${JSON.stringify(
|
||||||
|
createPersistedLaunchSnapshot({
|
||||||
|
teamName,
|
||||||
|
leadSessionId: 'lead-session',
|
||||||
|
launchPhase: 'finished',
|
||||||
|
expectedMembers: ['tom'],
|
||||||
|
bootstrapExpectedMembers: [],
|
||||||
|
members: {
|
||||||
|
tom: {
|
||||||
|
name: 'tom',
|
||||||
|
providerId: 'opencode',
|
||||||
|
model: 'openrouter/minimax/minimax-m2.5',
|
||||||
|
laneId: tomLaneId,
|
||||||
|
laneKind: 'secondary',
|
||||||
|
laneOwnerProviderId: 'opencode',
|
||||||
|
launchState: 'failed_to_start',
|
||||||
|
agentToolAccepted: false,
|
||||||
|
runtimeAlive: false,
|
||||||
|
bootstrapConfirmed: false,
|
||||||
|
hardFailure: true,
|
||||||
|
hardFailureReason: 'File lock timeout: opencode-runtime-receipts.json',
|
||||||
|
diagnostics: ['File lock timeout: opencode-runtime-receipts.json'],
|
||||||
|
lastEvaluatedAt: '2026-04-23T10:00:00.000Z',
|
||||||
|
},
|
||||||
|
},
|
||||||
|
updatedAt: '2026-04-23T10:00:00.000Z',
|
||||||
|
}),
|
||||||
|
null,
|
||||||
|
2
|
||||||
|
)}\n`,
|
||||||
|
'utf8'
|
||||||
|
);
|
||||||
|
await upsertOpenCodeRuntimeLaneIndexEntry({
|
||||||
|
teamsBasePath: tempTeamsBase,
|
||||||
|
teamName,
|
||||||
|
laneId: tomLaneId,
|
||||||
|
state: 'degraded',
|
||||||
|
diagnostics: ['File lock timeout: opencode-runtime-receipts.json'],
|
||||||
|
});
|
||||||
|
await writeCommittedOpenCodeSessionStore({
|
||||||
|
teamName,
|
||||||
|
laneId: tomLaneId,
|
||||||
|
runId: 'current-runtime-run',
|
||||||
|
sessions: [
|
||||||
|
{
|
||||||
|
id: 'ses_tom_old',
|
||||||
|
teamName,
|
||||||
|
memberName: 'tom',
|
||||||
|
runId: 'old-runtime-run',
|
||||||
|
laneId: tomLaneId,
|
||||||
|
providerId: 'opencode',
|
||||||
|
observedAt: '2026-04-23T09:00:00.000Z',
|
||||||
|
source: 'runtime_bootstrap_checkin',
|
||||||
|
},
|
||||||
|
],
|
||||||
|
});
|
||||||
|
|
||||||
|
const svc = new TeamProvisioningService();
|
||||||
|
const result = await svc.getMemberSpawnStatuses(teamName);
|
||||||
|
|
||||||
|
expect(result.statuses.tom).toMatchObject({
|
||||||
|
status: 'error',
|
||||||
|
launchState: 'failed_to_start',
|
||||||
|
hardFailure: true,
|
||||||
|
bootstrapConfirmed: false,
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
it('reconciles stale persisted mixed pending OpenCode lanes instead of keeping them pending forever', async () => {
|
it('reconciles stale persisted mixed pending OpenCode lanes instead of keeping them pending forever', async () => {
|
||||||
const teamName = 'signal-ops-7';
|
const teamName = 'signal-ops-7';
|
||||||
writeTeamMeta(teamName, {
|
writeTeamMeta(teamName, {
|
||||||
|
|
|
||||||
|
|
@ -83,6 +83,80 @@ describe('memberHelpers spawn-aware presence', () => {
|
||||||
).toBe('starting');
|
).toBe('starting');
|
||||||
});
|
});
|
||||||
|
|
||||||
|
it('labels queued OpenCode lanes separately from active startup', () => {
|
||||||
|
const openCodeMember: ResolvedTeamMember = { ...member, providerId: 'opencode' };
|
||||||
|
|
||||||
|
expect(
|
||||||
|
buildMemberLaunchPresentation({
|
||||||
|
member: openCodeMember,
|
||||||
|
spawnStatus: 'waiting',
|
||||||
|
spawnLaunchState: 'starting',
|
||||||
|
spawnLivenessSource: undefined,
|
||||||
|
spawnRuntimeAlive: false,
|
||||||
|
runtimeAdvisory: undefined,
|
||||||
|
isLaunchSettling: true,
|
||||||
|
isTeamAlive: true,
|
||||||
|
isTeamProvisioning: false,
|
||||||
|
})
|
||||||
|
).toMatchObject({
|
||||||
|
presenceLabel: 'queued',
|
||||||
|
launchVisualState: 'queued',
|
||||||
|
launchStatusLabel: 'queued',
|
||||||
|
dotClass: expect.stringContaining('bg-zinc-400'),
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
it('does not label non-OpenCode waiting lanes as queued', () => {
|
||||||
|
expect(
|
||||||
|
buildMemberLaunchPresentation({
|
||||||
|
member,
|
||||||
|
spawnStatus: 'waiting',
|
||||||
|
spawnLaunchState: 'starting',
|
||||||
|
spawnLivenessSource: undefined,
|
||||||
|
spawnRuntimeAlive: false,
|
||||||
|
runtimeAdvisory: undefined,
|
||||||
|
isLaunchSettling: true,
|
||||||
|
isTeamAlive: true,
|
||||||
|
isTeamProvisioning: false,
|
||||||
|
})
|
||||||
|
).toMatchObject({
|
||||||
|
presenceLabel: 'starting',
|
||||||
|
launchVisualState: 'waiting',
|
||||||
|
launchStatusLabel: 'waiting to start',
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
it('keeps OpenCode runtime evidence states more specific than queued', () => {
|
||||||
|
const openCodeMember: ResolvedTeamMember = { ...member, providerId: 'opencode' };
|
||||||
|
|
||||||
|
expect(
|
||||||
|
buildMemberLaunchPresentation({
|
||||||
|
member: openCodeMember,
|
||||||
|
spawnStatus: 'waiting',
|
||||||
|
spawnLaunchState: 'starting',
|
||||||
|
spawnLivenessSource: undefined,
|
||||||
|
spawnRuntimeAlive: false,
|
||||||
|
runtimeEntry: {
|
||||||
|
memberName: 'alice',
|
||||||
|
alive: false,
|
||||||
|
restartable: true,
|
||||||
|
providerId: 'opencode',
|
||||||
|
livenessKind: 'registered_only',
|
||||||
|
runtimeDiagnostic: 'registered runtime metadata without live process',
|
||||||
|
updatedAt: '2026-04-24T12:00:00.000Z',
|
||||||
|
},
|
||||||
|
runtimeAdvisory: undefined,
|
||||||
|
isLaunchSettling: true,
|
||||||
|
isTeamAlive: true,
|
||||||
|
isTeamProvisioning: false,
|
||||||
|
})
|
||||||
|
).toMatchObject({
|
||||||
|
presenceLabel: 'registered',
|
||||||
|
launchVisualState: 'registered_only',
|
||||||
|
launchStatusLabel: 'registered',
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
it('keeps starting visuals after provisioning already transitioned out of active state', () => {
|
it('keeps starting visuals after provisioning already transitioned out of active state', () => {
|
||||||
expect(
|
expect(
|
||||||
getSpawnAwarePresenceLabel(
|
getSpawnAwarePresenceLabel(
|
||||||
|
|
|
||||||
Loading…
Reference in a new issue