fix(ci): restore dev validation checks

This commit is contained in:
777genius 2026-05-19 02:49:45 +03:00
parent 85b767e247
commit dffc527424
15 changed files with 292 additions and 171 deletions

View file

@ -28,8 +28,8 @@ import type {
const LOG_PREVIEW_FALLBACK_WIDTH = 260; const LOG_PREVIEW_FALLBACK_WIDTH = 260;
const LOG_PREVIEW_FALLBACK_HEIGHT = 292; const LOG_PREVIEW_FALLBACK_HEIGHT = 292;
const NEW_LOG_HIGHLIGHT_MS = 1_000; const NEW_LOG_HIGHLIGHT_MS = 1_000;
const COMPACT_ROW_TITLE_LIMIT = 28; const COMPACT_ROW_TITLE_LIMIT = 24;
const COMPACT_ROW_TEXT_LIMIT = 160; const COMPACT_ROW_TEXT_LIMIT = 110;
const COMPACT_ROW_MIN_PREVIEW_LIMIT = 96; const COMPACT_ROW_MIN_PREVIEW_LIMIT = 96;
const INTERACTIVE_LOG_CONTROL_CLASS = 'pointer-events-auto'; const INTERACTIVE_LOG_CONTROL_CLASS = 'pointer-events-auto';
@ -256,7 +256,7 @@ function renderLoadingSkeleton(): React.JSX.Element {
key={index} key={index}
className="grid h-[72px] min-h-[72px] w-full min-w-0 grid-cols-[1rem_minmax(0,1fr)] gap-x-1.5 overflow-hidden rounded-md border border-white/10 bg-[rgba(8,14,28,0.42)] px-2 py-1.5" className="grid h-[72px] min-h-[72px] w-full min-w-0 grid-cols-[1rem_minmax(0,1fr)] gap-x-1.5 overflow-hidden rounded-md border border-white/10 bg-[rgba(8,14,28,0.42)] px-2 py-1.5"
> >
<span className="mt-0.5 inline-flex size-4 shrink-0 rounded bg-white/10" /> <span className="mt-0.5 inline-flex size-4 shrink-0 animate-pulse rounded bg-white/10" />
<span className="flex min-w-0 flex-1 flex-col gap-1 pt-0.5"> <span className="flex min-w-0 flex-1 flex-col gap-1 pt-0.5">
<span className="h-3 w-2/5 rounded bg-slate-400/20" /> <span className="h-3 w-2/5 rounded bg-slate-400/20" />
<span className="h-2.5 w-full rounded bg-slate-400/15" /> <span className="h-2.5 w-full rounded bg-slate-400/15" />

View file

@ -45,14 +45,12 @@ import {
registerHttpServerHandlers, registerHttpServerHandlers,
removeHttpServerHandlers, removeHttpServerHandlers,
} from './httpServer'; } from './httpServer';
import { registerNotificationHandlers, removeNotificationHandlers } from './notifications';
import { import {
initializeOpenCodeRuntimeHandlers, initializeOpenCodeRuntimeHandlers,
registerOpenCodeRuntimeHandlers, registerOpenCodeRuntimeHandlers,
removeOpenCodeRuntimeHandlers, removeOpenCodeRuntimeHandlers,
} from './openCodeRuntime'; } from './openCodeRuntime';
const logger = createLogger('IPC:handlers');
import { registerNotificationHandlers, removeNotificationHandlers } from './notifications';
import { import {
initializeProjectHandlers, initializeProjectHandlers,
registerProjectHandlers, registerProjectHandlers,
@ -79,12 +77,12 @@ import {
removeSubagentHandlers, removeSubagentHandlers,
} from './subagents'; } from './subagents';
import { initializeTeamHandlers, registerTeamHandlers, removeTeamHandlers } from './teams'; import { initializeTeamHandlers, registerTeamHandlers, removeTeamHandlers } from './teams';
import { registerTelemetryHandlers, removeTelemetryHandlers } from './telemetry';
import { import {
initializeTerminalHandlers, initializeTerminalHandlers,
registerTerminalHandlers, registerTerminalHandlers,
removeTerminalHandlers, removeTerminalHandlers,
} from './terminal'; } from './terminal';
import { registerTelemetryHandlers, removeTelemetryHandlers } from './telemetry';
import { registerTmuxHandlers, removeTmuxHandlers } from './tmux'; import { registerTmuxHandlers, removeTmuxHandlers } from './tmux';
import { import {
initializeUpdaterHandlers, initializeUpdaterHandlers,
@ -134,6 +132,8 @@ import type { CrossTeamService } from '../services/team/CrossTeamService';
import type { LaunchIoGovernor } from '../services/team/LaunchIoGovernor'; import type { LaunchIoGovernor } from '../services/team/LaunchIoGovernor';
import type { TeamBackupService } from '../services/team/TeamBackupService'; import type { TeamBackupService } from '../services/team/TeamBackupService';
const logger = createLogger('IPC:handlers');
/** /**
* Initializes IPC handlers with service registry. * Initializes IPC handlers with service registry.
*/ */

View file

@ -192,8 +192,8 @@ function looksLikeNodeBinaryPath(candidate: string | undefined): candidate is st
function getNodeRuntimeCommandCandidates(): string[] { function getNodeRuntimeCommandCandidates(): string[] {
const candidates = [ const candidates = [
process.env.NODE_BINARY, process.env.NODE_BINARY,
process.env.npm_node_execpath,
'node', 'node',
process.env.npm_node_execpath,
looksLikeNodeBinaryPath(process.execPath) ? process.execPath : undefined, looksLikeNodeBinaryPath(process.execPath) ? process.execPath : undefined,
]; ];
const seen = new Set<string>(); const seen = new Set<string>();

View file

@ -908,6 +908,7 @@ export function isOpenCodeSessionTransportChangedReason(
} }
const OPENCODE_SESSION_REFRESH_FAILURE_PATTERN = const OPENCODE_SESSION_REFRESH_FAILURE_PATTERN =
// eslint-disable-next-line sonarjs/regex-complexity -- Keyword taxonomy is kept literal to preserve diagnostic behavior.
/(?:^|[_\s:;.\/()-])(?:permission[_\s-]?denied|permission[_\s-]?blocked|access[_\s-]?denied|auth[_\s-]?unavailable|authentication[_\s-]?failed|unauthorized|forbidden|401|403|login[_\s-]?required|not\s+logged\s+in|missing\s+credentials?|invalid\s+credentials?|credentials?[_\s-]?required|credentials?[_\s-]?unavailable|no auth available|authorization|auth(?:entication)?(?:[_\s-]?(?:failed|unavailable))?|invalid api[_\s-]?key|api[_\s-]?key|does not have access|quota|rate[_\s-]?(?:limit|limited)|too many requests|429|model cooldown|cooling down|enospc|no space left|disk is full|capacity exceeded|quota exhausted|usage exceeded|free usage exceeded|key limit exceeded|total limit|insufficient credits|subscribe to go|error|failed|failure|timeout|timed\s+out|network|connection|unable\s+to\s+connect|connect\s+failed|econn[a-z_]*|enotfound|fetch[_\s-]?failed|connection[_\s-]?(?:refused|reset)|aborted|cancel(?:ed|led)|interrupted|service[_\s-]?unavailable|temporarily\s+unavailable|overloaded|visible[_\s-]?reply(?:[_\s-][a-z0-9]+)*|task[_\s-]?refs|relayofmessageid|relay[_\s-]?of[_\s-]?message[_\s-]?id|message[_\s-]?send|non[_\s-]?visible[_\s-]?tool(?:[_\s-][a-z0-9]+)*|protocol[_\s-]?proof)(?=$|[_\s:;.\/(),-])/i; /(?:^|[_\s:;.\/()-])(?:permission[_\s-]?denied|permission[_\s-]?blocked|access[_\s-]?denied|auth[_\s-]?unavailable|authentication[_\s-]?failed|unauthorized|forbidden|401|403|login[_\s-]?required|not\s+logged\s+in|missing\s+credentials?|invalid\s+credentials?|credentials?[_\s-]?required|credentials?[_\s-]?unavailable|no auth available|authorization|auth(?:entication)?(?:[_\s-]?(?:failed|unavailable))?|invalid api[_\s-]?key|api[_\s-]?key|does not have access|quota|rate[_\s-]?(?:limit|limited)|too many requests|429|model cooldown|cooling down|enospc|no space left|disk is full|capacity exceeded|quota exhausted|usage exceeded|free usage exceeded|key limit exceeded|total limit|insufficient credits|subscribe to go|error|failed|failure|timeout|timed\s+out|network|connection|unable\s+to\s+connect|connect\s+failed|econn[a-z_]*|enotfound|fetch[_\s-]?failed|connection[_\s-]?(?:refused|reset)|aborted|cancel(?:ed|led)|interrupted|service[_\s-]?unavailable|temporarily\s+unavailable|overloaded|visible[_\s-]?reply(?:[_\s-][a-z0-9]+)*|task[_\s-]?refs|relayofmessageid|relay[_\s-]?of[_\s-]?message[_\s-]?id|message[_\s-]?send|non[_\s-]?visible[_\s-]?tool(?:[_\s-][a-z0-9]+)*|protocol[_\s-]?proof)(?=$|[_\s:;.\/(),-])/i;
const OPENCODE_SESSION_REFRESH_ANY_REASON_PATTERN = const OPENCODE_SESSION_REFRESH_ANY_REASON_PATTERN =
/\b(?:resolved_behavior_changed|opencode_app_mcp_transport_changed):[-a-z0-9._~/=]+->[-a-z0-9._~/=]+/gi; /\b(?:resolved_behavior_changed|opencode_app_mcp_transport_changed):[-a-z0-9._~/=]+->[-a-z0-9._~/=]+/gi;

View file

@ -33,6 +33,7 @@ const OPENCODE_SESSION_REFRESH_MESSAGE =
const OPENCODE_SESSION_REFRESH_REASON_PATTERN = const OPENCODE_SESSION_REFRESH_REASON_PATTERN =
/\b(?:resolved_behavior_changed|opencode_app_mcp_transport_changed):[-a-z0-9._~/=]+->[-a-z0-9._~/=]+/i; /\b(?:resolved_behavior_changed|opencode_app_mcp_transport_changed):[-a-z0-9._~/=]+->[-a-z0-9._~/=]+/i;
const OPENCODE_SESSION_REFRESH_FAILURE_PATTERN = const OPENCODE_SESSION_REFRESH_FAILURE_PATTERN =
// eslint-disable-next-line sonarjs/regex-complexity -- Keyword taxonomy is kept literal to preserve diagnostic behavior.
/(?:^|[_\s:;.\/()-])(?:permission[_\s-]?denied|permission[_\s-]?blocked|access[_\s-]?denied|auth[_\s-]?unavailable|authentication[_\s-]?failed|unauthorized|forbidden|401|403|login[_\s-]?required|not\s+logged\s+in|missing\s+credentials?|invalid\s+credentials?|credentials?[_\s-]?required|credentials?[_\s-]?unavailable|no auth available|authorization|auth(?:entication)?(?:[_\s-]?(?:failed|unavailable))?|invalid api[_\s-]?key|api[_\s-]?key|does not have access|quota|rate[_\s-]?(?:limit|limited)|too many requests|429|model cooldown|cooling down|enospc|no space left|disk is full|capacity exceeded|quota exhausted|usage exceeded|free usage exceeded|key limit exceeded|total limit|insufficient credits|subscribe to go|error|failed|failure|timeout|timed\s+out|network|connection|unable\s+to\s+connect|connect\s+failed|econn[a-z_]*|enotfound|fetch[_\s-]?failed|connection[_\s-]?(?:refused|reset)|aborted|cancel(?:ed|led)|interrupted|service[_\s-]?unavailable|temporarily\s+unavailable|overloaded|visible[_\s-]?reply(?:[_\s-][a-z0-9]+)*|task[_\s-]?refs|relayofmessageid|relay[_\s-]?of[_\s-]?message[_\s-]?id|message[_\s-]?send|non[_\s-]?visible[_\s-]?tool(?:[_\s-][a-z0-9]+)*|protocol[_\s-]?proof)(?=$|[_\s:;.\/(),-])/i; /(?:^|[_\s:;.\/()-])(?:permission[_\s-]?denied|permission[_\s-]?blocked|access[_\s-]?denied|auth[_\s-]?unavailable|authentication[_\s-]?failed|unauthorized|forbidden|401|403|login[_\s-]?required|not\s+logged\s+in|missing\s+credentials?|invalid\s+credentials?|credentials?[_\s-]?required|credentials?[_\s-]?unavailable|no auth available|authorization|auth(?:entication)?(?:[_\s-]?(?:failed|unavailable))?|invalid api[_\s-]?key|api[_\s-]?key|does not have access|quota|rate[_\s-]?(?:limit|limited)|too many requests|429|model cooldown|cooling down|enospc|no space left|disk is full|capacity exceeded|quota exhausted|usage exceeded|free usage exceeded|key limit exceeded|total limit|insufficient credits|subscribe to go|error|failed|failure|timeout|timed\s+out|network|connection|unable\s+to\s+connect|connect\s+failed|econn[a-z_]*|enotfound|fetch[_\s-]?failed|connection[_\s-]?(?:refused|reset)|aborted|cancel(?:ed|led)|interrupted|service[_\s-]?unavailable|temporarily\s+unavailable|overloaded|visible[_\s-]?reply(?:[_\s-][a-z0-9]+)*|task[_\s-]?refs|relayofmessageid|relay[_\s-]?of[_\s-]?message[_\s-]?id|message[_\s-]?send|non[_\s-]?visible[_\s-]?tool(?:[_\s-][a-z0-9]+)*|protocol[_\s-]?proof)(?=$|[_\s:;.\/(),-])/i;
const OPENCODE_SESSION_REFRESH_ANY_REASON_PATTERN = const OPENCODE_SESSION_REFRESH_ANY_REASON_PATTERN =
/\b(?:resolved_behavior_changed|opencode_app_mcp_transport_changed):[-a-z0-9._~/=]+->[-a-z0-9._~/=]+/gi; /\b(?:resolved_behavior_changed|opencode_app_mcp_transport_changed):[-a-z0-9._~/=]+->[-a-z0-9._~/=]+/gi;

View file

@ -5,8 +5,8 @@ import {
type ExecFileOptions, type ExecFileOptions,
type ExecOptions, type ExecOptions,
spawn, spawn,
spawnSync,
type SpawnOptions, type SpawnOptions,
spawnSync,
} from 'child_process'; } from 'child_process';
import { existsSync, readFileSync } from 'fs'; import { existsSync, readFileSync } from 'fs';
import path from 'path'; import path from 'path';
@ -30,19 +30,12 @@ function execFileAsync(
let stdoutText = ''; let stdoutText = '';
let stderrText = ''; let stderrText = '';
let timeoutHandle: ReturnType<typeof setTimeout> | null = null; let timeoutHandle: ReturnType<typeof setTimeout> | null = null;
const cleanup = (): void => {
if (timeoutHandle) {
clearTimeout(timeoutHandle);
timeoutHandle = null;
}
untrackCliProcess(child);
};
child = execFile(cmd, args, execOptions, (err, stdout, stderr) => { child = execFile(cmd, args, execOptions, (err, stdout, stderr) => {
if (settled) { if (settled) {
return; return;
} }
settled = true; settled = true;
cleanup(); timeoutHandle = cleanupTimedCliProcess(child, timeoutHandle);
if (err) { if (err) {
const normalizedError = const normalizedError =
err instanceof Error ? err : new Error(typeof err === 'string' ? err : 'Unknown error'); err instanceof Error ? err : new Error(typeof err === 'string' ? err : 'Unknown error');
@ -67,7 +60,7 @@ function execFileAsync(
return; return;
} }
settled = true; settled = true;
cleanup(); timeoutHandle = cleanupTimedCliProcess(child, timeoutHandle);
killProcessTree(child, timeoutSignal); killProcessTree(child, timeoutSignal);
const error = new Error( const error = new Error(
`Command timed out after ${timeoutMs}ms: ${cmd} ${args.join(' ')}` `Command timed out after ${timeoutMs}ms: ${cmd} ${args.join(' ')}`
@ -104,20 +97,13 @@ function execShellAsync(
let stdoutText = ''; let stdoutText = '';
let stderrText = ''; let stderrText = '';
let timeoutHandle: ReturnType<typeof setTimeout> | null = null; let timeoutHandle: ReturnType<typeof setTimeout> | null = null;
const cleanup = (): void => {
if (timeoutHandle) {
clearTimeout(timeoutHandle);
timeoutHandle = null;
}
untrackCliProcess(child);
};
// eslint-disable-next-line sonarjs/os-command, security/detect-child-process -- cmd from known binaryPath+args, not user input (Windows EINVAL fallback) // eslint-disable-next-line sonarjs/os-command, security/detect-child-process -- cmd from known binaryPath+args, not user input (Windows EINVAL fallback)
child = exec(cmd, execOptions, (err, stdout, stderr) => { child = exec(cmd, execOptions, (err, stdout, stderr) => {
if (settled) { if (settled) {
return; return;
} }
settled = true; settled = true;
cleanup(); timeoutHandle = cleanupTimedCliProcess(child, timeoutHandle);
if (err) if (err)
reject( reject(
err instanceof Error ? err : new Error(typeof err === 'string' ? err : 'Unknown error') err instanceof Error ? err : new Error(typeof err === 'string' ? err : 'Unknown error')
@ -138,7 +124,7 @@ function execShellAsync(
return; return;
} }
settled = true; settled = true;
cleanup(); timeoutHandle = cleanupTimedCliProcess(child, timeoutHandle);
killProcessTree(child, timeoutSignal); killProcessTree(child, timeoutSignal);
const error = new Error(`Command timed out after ${timeoutMs}ms: ${cmd}`); const error = new Error(`Command timed out after ${timeoutMs}ms: ${cmd}`);
Object.assign(error, { Object.assign(error, {
@ -155,6 +141,17 @@ function execShellAsync(
}); });
} }
function cleanupTimedCliProcess(
child: ChildProcess | null,
timeoutHandle: ReturnType<typeof setTimeout> | null
): null {
if (timeoutHandle) {
clearTimeout(timeoutHandle);
}
untrackCliProcess(child);
return null;
}
/** /**
* Returns true if the string contains any non-ASCII character. * Returns true if the string contains any non-ASCII character.
*/ */
@ -436,8 +433,8 @@ export function spawnCli(
* `cmd.exe` shell, leaving the actual process (e.g. `claude.cmd`) orphaned. * `cmd.exe` shell, leaving the actual process (e.g. `claude.cmd`) orphaned.
* `taskkill /T /F /PID` recursively kills the entire process tree. * `taskkill /T /F /PID` recursively kills the entire process tree.
* *
* On macOS/Linux, processes are killed directly (no shell wrapper), so * On macOS/Linux, kill the child and descendants by PID so shell wrappers
* the standard `child.kill(signal)` works correctly. * and spawned grandchildren do not survive a timeout or team stop.
*/ */
export function killProcessTree( export function killProcessTree(
child: ChildProcess | null | undefined, child: ChildProcess | null | undefined,
@ -477,7 +474,7 @@ export function killProcessTree(
} }
function normalizeKillSignal(signal: ExecFileOptions['killSignal']): NodeJS.Signals { function normalizeKillSignal(signal: ExecFileOptions['killSignal']): NodeJS.Signals {
return typeof signal === 'string' ? (signal as NodeJS.Signals) : 'SIGTERM'; return typeof signal === 'string' ? signal : 'SIGTERM';
} }
function getDescendantProcessIds(parentPid: number): number[] { function getDescendantProcessIds(parentPid: number): number[] {
@ -496,7 +493,7 @@ function getDescendantProcessIds(parentPid: number): number[] {
const childrenByParent = new Map<number, number[]>(); const childrenByParent = new Map<number, number[]>();
for (const line of result.stdout.split('\n')) { for (const line of result.stdout.split('\n')) {
const match = line.trim().match(/^(\d+)\s+(\d+)$/); const match = /^(\d+)\s+(\d+)$/.exec(line.trim());
if (!match) { if (!match) {
continue; continue;
} }

View file

@ -53,9 +53,9 @@ import {
} from 'lucide-react'; } from 'lucide-react';
import { useShallow } from 'zustand/react/shallow'; import { useShallow } from 'zustand/react/shallow';
import { executeTeamRelaunch } from './dialogs/teamRelaunchFlow';
import { TeamEmptyState } from './TeamEmptyState'; import { TeamEmptyState } from './TeamEmptyState';
import { EMPTY_TEAM_FILTER, TeamListFilterPopover } from './TeamListFilterPopover'; import { EMPTY_TEAM_FILTER, TeamListFilterPopover } from './TeamListFilterPopover';
import { executeTeamRelaunch } from './dialogs/teamRelaunchFlow';
import { import {
findTeamProjectSelectionTarget, findTeamProjectSelectionTarget,
resolveTeamProjectSelection, resolveTeamProjectSelection,

View file

@ -343,11 +343,11 @@ function buildRuntimeTelemetryTitle(
return lines.join('\n'); return lines.join('\n');
} }
function RuntimeTelemetryTooltipContent({ const RuntimeTelemetryTooltipContent = ({
runtimeEntry, runtimeEntry,
}: { }: Readonly<{
runtimeEntry: TeamAgentRuntimeEntry | undefined; runtimeEntry: TeamAgentRuntimeEntry | undefined;
}): React.JSX.Element | null { }>): React.JSX.Element | null => {
if (!runtimeEntry) { if (!runtimeEntry) {
return null; return null;
} }
@ -445,7 +445,7 @@ function RuntimeTelemetryTooltipContent({
</div> </div>
</div> </div>
); );
} };
function buildTelemetryPoints( function buildTelemetryPoints(
samples: readonly TeamAgentRuntimeResourceSample[], samples: readonly TeamAgentRuntimeResourceSample[],

View file

@ -393,6 +393,7 @@ function appendRuntimeAdvisoryRawMessage(
const OPENCODE_SESSION_REFRESH_REASON_PATTERN = const OPENCODE_SESSION_REFRESH_REASON_PATTERN =
/\b(?:resolved_behavior_changed|opencode_app_mcp_transport_changed):[-a-z0-9._~/=]+->[-a-z0-9._~/=]+/i; /\b(?:resolved_behavior_changed|opencode_app_mcp_transport_changed):[-a-z0-9._~/=]+->[-a-z0-9._~/=]+/i;
const OPENCODE_SESSION_REFRESH_FAILURE_PATTERN = const OPENCODE_SESSION_REFRESH_FAILURE_PATTERN =
// eslint-disable-next-line sonarjs/regex-complexity -- Keyword taxonomy is kept literal to preserve diagnostic behavior.
/(?:^|[_\s:;.\/()-])(?:permission[_\s-]?denied|permission[_\s-]?blocked|access[_\s-]?denied|auth[_\s-]?unavailable|authentication[_\s-]?failed|unauthorized|forbidden|401|403|login[_\s-]?required|not\s+logged\s+in|missing\s+credentials?|invalid\s+credentials?|credentials?[_\s-]?required|credentials?[_\s-]?unavailable|no auth available|authorization|auth(?:entication)?(?:[_\s-]?(?:failed|unavailable))?|invalid api[_\s-]?key|api[_\s-]?key|does not have access|quota|rate[_\s-]?(?:limit|limited)|too many requests|429|model cooldown|cooling down|enospc|no space left|disk is full|capacity exceeded|quota exhausted|usage exceeded|free usage exceeded|key limit exceeded|total limit|insufficient credits|subscribe to go|error|failed|failure|timeout|timed\s+out|network|connection|unable\s+to\s+connect|connect\s+failed|econn[a-z_]*|enotfound|fetch[_\s-]?failed|connection[_\s-]?(?:refused|reset)|aborted|cancel(?:ed|led)|interrupted|service[_\s-]?unavailable|temporarily\s+unavailable|overloaded|visible[_\s-]?reply(?:[_\s-][a-z0-9]+)*|task[_\s-]?refs|relayofmessageid|relay[_\s-]?of[_\s-]?message[_\s-]?id|message[_\s-]?send|non[_\s-]?visible[_\s-]?tool(?:[_\s-][a-z0-9]+)*|protocol[_\s-]?proof)(?=$|[_\s:;.\/(),-])/i; /(?:^|[_\s:;.\/()-])(?:permission[_\s-]?denied|permission[_\s-]?blocked|access[_\s-]?denied|auth[_\s-]?unavailable|authentication[_\s-]?failed|unauthorized|forbidden|401|403|login[_\s-]?required|not\s+logged\s+in|missing\s+credentials?|invalid\s+credentials?|credentials?[_\s-]?required|credentials?[_\s-]?unavailable|no auth available|authorization|auth(?:entication)?(?:[_\s-]?(?:failed|unavailable))?|invalid api[_\s-]?key|api[_\s-]?key|does not have access|quota|rate[_\s-]?(?:limit|limited)|too many requests|429|model cooldown|cooling down|enospc|no space left|disk is full|capacity exceeded|quota exhausted|usage exceeded|free usage exceeded|key limit exceeded|total limit|insufficient credits|subscribe to go|error|failed|failure|timeout|timed\s+out|network|connection|unable\s+to\s+connect|connect\s+failed|econn[a-z_]*|enotfound|fetch[_\s-]?failed|connection[_\s-]?(?:refused|reset)|aborted|cancel(?:ed|led)|interrupted|service[_\s-]?unavailable|temporarily\s+unavailable|overloaded|visible[_\s-]?reply(?:[_\s-][a-z0-9]+)*|task[_\s-]?refs|relayofmessageid|relay[_\s-]?of[_\s-]?message[_\s-]?id|message[_\s-]?send|non[_\s-]?visible[_\s-]?tool(?:[_\s-][a-z0-9]+)*|protocol[_\s-]?proof)(?=$|[_\s:;.\/(),-])/i;
const OPENCODE_SESSION_REFRESH_ANY_REASON_PATTERN = const OPENCODE_SESSION_REFRESH_ANY_REASON_PATTERN =
/\b(?:resolved_behavior_changed|opencode_app_mcp_transport_changed):[-a-z0-9._~/=]+->[-a-z0-9._~/=]+/gi; /\b(?:resolved_behavior_changed|opencode_app_mcp_transport_changed):[-a-z0-9._~/=]+->[-a-z0-9._~/=]+/gi;
@ -1035,6 +1036,13 @@ function shouldTreatCodexNativeRuntimeAsOffline({
if (!isCodexNativeProcessTeammate(member)) { if (!isCodexNativeProcessTeammate(member)) {
return false; return false;
} }
if (
spawnLaunchState === 'starting' ||
spawnLaunchState === 'runtime_pending_bootstrap' ||
spawnLaunchState === 'runtime_pending_permission'
) {
return false;
}
if (hasLiveRuntimeProcessEvidence(runtimeEntry)) { if (hasLiveRuntimeProcessEvidence(runtimeEntry)) {
return false; return false;
} }

View file

@ -80,6 +80,7 @@ const SECRET_VALUE_PATTERN =
const OPENCODE_SESSION_REFRESH_REASON_PATTERN = const OPENCODE_SESSION_REFRESH_REASON_PATTERN =
/\b(?:resolved_behavior_changed|opencode_app_mcp_transport_changed):[-a-z0-9._~/=]+->[-a-z0-9._~/=]+/i; /\b(?:resolved_behavior_changed|opencode_app_mcp_transport_changed):[-a-z0-9._~/=]+->[-a-z0-9._~/=]+/i;
const OPENCODE_SESSION_REFRESH_FAILURE_PATTERN = const OPENCODE_SESSION_REFRESH_FAILURE_PATTERN =
// eslint-disable-next-line sonarjs/regex-complexity -- Keyword taxonomy is kept literal to preserve diagnostic behavior.
/(?:^|[_\s:;.\/()-])(?:permission[_\s-]?denied|permission[_\s-]?blocked|access[_\s-]?denied|auth[_\s-]?unavailable|authentication[_\s-]?failed|unauthorized|forbidden|401|403|login[_\s-]?required|not\s+logged\s+in|missing\s+credentials?|invalid\s+credentials?|credentials?[_\s-]?required|credentials?[_\s-]?unavailable|no auth available|authorization|auth(?:entication)?(?:[_\s-]?(?:failed|unavailable))?|invalid api[_\s-]?key|api[_\s-]?key|does not have access|quota|rate[_\s-]?(?:limit|limited)|too many requests|429|model cooldown|cooling down|enospc|no space left|disk is full|capacity exceeded|quota exhausted|usage exceeded|free usage exceeded|key limit exceeded|total limit|insufficient credits|subscribe to go|error|failed|failure|timeout|timed\s+out|network|connection|unable\s+to\s+connect|connect\s+failed|econn[a-z_]*|enotfound|fetch[_\s-]?failed|connection[_\s-]?(?:refused|reset)|aborted|cancel(?:ed|led)|interrupted|service[_\s-]?unavailable|temporarily\s+unavailable|overloaded|visible[_\s-]?reply(?:[_\s-][a-z0-9]+)*|task[_\s-]?refs|relayofmessageid|relay[_\s-]?of[_\s-]?message[_\s-]?id|message[_\s-]?send|non[_\s-]?visible[_\s-]?tool(?:[_\s-][a-z0-9]+)*|protocol[_\s-]?proof)(?=$|[_\s:;.\/(),-])/i; /(?:^|[_\s:;.\/()-])(?:permission[_\s-]?denied|permission[_\s-]?blocked|access[_\s-]?denied|auth[_\s-]?unavailable|authentication[_\s-]?failed|unauthorized|forbidden|401|403|login[_\s-]?required|not\s+logged\s+in|missing\s+credentials?|invalid\s+credentials?|credentials?[_\s-]?required|credentials?[_\s-]?unavailable|no auth available|authorization|auth(?:entication)?(?:[_\s-]?(?:failed|unavailable))?|invalid api[_\s-]?key|api[_\s-]?key|does not have access|quota|rate[_\s-]?(?:limit|limited)|too many requests|429|model cooldown|cooling down|enospc|no space left|disk is full|capacity exceeded|quota exhausted|usage exceeded|free usage exceeded|key limit exceeded|total limit|insufficient credits|subscribe to go|error|failed|failure|timeout|timed\s+out|network|connection|unable\s+to\s+connect|connect\s+failed|econn[a-z_]*|enotfound|fetch[_\s-]?failed|connection[_\s-]?(?:refused|reset)|aborted|cancel(?:ed|led)|interrupted|service[_\s-]?unavailable|temporarily\s+unavailable|overloaded|visible[_\s-]?reply(?:[_\s-][a-z0-9]+)*|task[_\s-]?refs|relayofmessageid|relay[_\s-]?of[_\s-]?message[_\s-]?id|message[_\s-]?send|non[_\s-]?visible[_\s-]?tool(?:[_\s-][a-z0-9]+)*|protocol[_\s-]?proof)(?=$|[_\s:;.\/(),-])/i;
const OPENCODE_SESSION_REFRESH_ANY_REASON_PATTERN = const OPENCODE_SESSION_REFRESH_ANY_REASON_PATTERN =
/\b(?:resolved_behavior_changed|opencode_app_mcp_transport_changed):[-a-z0-9._~/=]+->[-a-z0-9._~/=]+/gi; /\b(?:resolved_behavior_changed|opencode_app_mcp_transport_changed):[-a-z0-9._~/=]+->[-a-z0-9._~/=]+/gi;

View file

@ -59,9 +59,12 @@ describe('smokePackagedApp shutdown handling', () => {
const termination = terminateChild(child, exitPromise, 'linux'); const termination = terminateChild(child, exitPromise, 'linux');
const rejection = expect(termination).rejects.toThrow('Timed out after 5000ms'); const rejection = expect(termination).rejects.toThrow('Timed out after 5000ms');
await vi.advanceTimersByTimeAsync(5_000); await vi.advanceTimersByTimeAsync(5_000);
await vi.advanceTimersByTimeAsync(5_000);
await rejection; await rejection;
expect(child.kill).toHaveBeenCalledTimes(1); expect(child.kill).toHaveBeenCalledTimes(2);
expect(child.kill).toHaveBeenNthCalledWith(1);
expect(child.kill).toHaveBeenNthCalledWith(2, 'SIGKILL');
} finally { } finally {
vi.useRealTimers(); vi.useRealTimers();
} }

View file

@ -23,6 +23,14 @@ import { execCli } from '@main/utils/childProcess';
const mockExecCli = vi.mocked(execCli); const mockExecCli = vi.mocked(execCli);
function findMcpCliArgs(command: 'add' | 'remove'): string[] {
const call = mockExecCli.mock.calls.find(([, args]) => args[0] === 'mcp' && args[1] === command);
if (!call) {
throw new Error(`Expected a claude mcp ${command} CLI call`);
}
return call[1];
}
// ── Mock aggregator ────────────────────────────────────────────────────────── // ── Mock aggregator ──────────────────────────────────────────────────────────
function makeStdioServer(): McpCatalogItem { function makeStdioServer(): McpCatalogItem {
@ -60,7 +68,7 @@ function makeHttpServer(): McpCatalogItem {
} }
function createMockAggregator( function createMockAggregator(
getByIdResult: McpCatalogItem | null = makeStdioServer(), getByIdResult: McpCatalogItem | null = makeStdioServer()
): McpCatalogAggregator { ): McpCatalogAggregator {
return { return {
search: vi.fn(), search: vi.fn(),
@ -101,8 +109,20 @@ describe('McpInstallService', () => {
expect(result.state).toBe('success'); expect(result.state).toBe('success');
expect(mockExecCli).toHaveBeenCalledWith( expect(mockExecCli).toHaveBeenCalledWith(
'/usr/local/bin/claude', '/usr/local/bin/claude',
['mcp', 'add', '-s', 'user', '-e', 'UPSTASH_API_KEY=test-key-123', 'context7', '--', 'npx', '-y', '@upstash/context7-mcp@1.0.0'], [
expect.objectContaining({ timeout: 30_000 }), 'mcp',
'add',
'-s',
'user',
'-e',
'UPSTASH_API_KEY=test-key-123',
'context7',
'--',
'npx',
'-y',
'@upstash/context7-mcp@1.0.0',
],
expect.objectContaining({ timeout: 30_000 })
); );
}); });
@ -118,7 +138,7 @@ describe('McpInstallService', () => {
headers: [], headers: [],
}); });
const args = mockExecCli.mock.calls[0]?.[1]; const args = findMcpCliArgs('add');
expect(args).toContain('-s'); expect(args).toContain('-s');
expect(args).toContain('project'); expect(args).toContain('project');
}); });
@ -135,7 +155,7 @@ describe('McpInstallService', () => {
headers: [], headers: [],
}); });
const args = mockExecCli.mock.calls[0]?.[1]; const args = findMcpCliArgs('add');
expect(args).not.toContain('-s'); expect(args).not.toContain('-s');
}); });
}); });
@ -159,8 +179,19 @@ describe('McpInstallService', () => {
expect(result.state).toBe('success'); expect(result.state).toBe('success');
expect(mockExecCli).toHaveBeenCalledWith( expect(mockExecCli).toHaveBeenCalledWith(
'/usr/local/bin/claude', '/usr/local/bin/claude',
['mcp', 'add', '-s', 'user', '-t', 'sse', '-H', 'Authorization: Bearer token123', 'example-http', 'https://mcp.example.com/sse'], [
expect.objectContaining({ timeout: 30_000 }), 'mcp',
'add',
'-s',
'user',
'-t',
'sse',
'-H',
'Authorization: Bearer token123',
'example-http',
'https://mcp.example.com/sse',
],
expect.objectContaining({ timeout: 30_000 })
); );
}); });
}); });
@ -252,7 +283,7 @@ describe('McpInstallService', () => {
describe('install (secret masking)', () => { describe('install (secret masking)', () => {
it('masks env values in error messages', async () => { it('masks env values in error messages', async () => {
mockExecCli.mockRejectedValue( mockExecCli.mockRejectedValue(
new Error('Command failed: UPSTASH_API_KEY=super-secret-key-12345'), new Error('Command failed: UPSTASH_API_KEY=super-secret-key-12345')
); );
const result = await service.install({ const result = await service.install({
@ -271,9 +302,7 @@ describe('McpInstallService', () => {
it('masks header values in error messages', async () => { it('masks header values in error messages', async () => {
aggregator = createMockAggregator(makeHttpServer()); aggregator = createMockAggregator(makeHttpServer());
service = new McpInstallService(aggregator); service = new McpInstallService(aggregator);
mockExecCli.mockRejectedValue( mockExecCli.mockRejectedValue(new Error('Auth failed with Bearer my-token-value'));
new Error('Auth failed with Bearer my-token-value'),
);
const result = await service.install({ const result = await service.install({
registryId: 'test', registryId: 'test',
@ -336,7 +365,7 @@ describe('McpInstallService', () => {
expect(mockExecCli).toHaveBeenCalledWith( expect(mockExecCli).toHaveBeenCalledWith(
'/usr/local/bin/claude', '/usr/local/bin/claude',
['mcp', 'remove', 'context7'], ['mcp', 'remove', 'context7'],
expect.objectContaining({ timeout: 30_000 }), expect.objectContaining({ timeout: 30_000 })
); );
}); });
@ -345,7 +374,7 @@ describe('McpInstallService', () => {
await service.uninstall('context7', 'user'); await service.uninstall('context7', 'user');
const args = mockExecCli.mock.calls[0]?.[1]; const args = findMcpCliArgs('remove');
expect(args).toContain('-s'); expect(args).toContain('-s');
expect(args).toContain('user'); expect(args).toContain('user');
}); });

View file

@ -13554,22 +13554,29 @@ describe('Team agent launch matrix safe e2e', () => {
await (svc as any).launchMixedSecondaryLaneIfNeeded(currentRun); await (svc as any).launchMixedSecondaryLaneIfNeeded(currentRun);
await waitForCondition(() => adapter.pendingLaunchInputs.length === 1); await waitForCondition(() => adapter.pendingLaunchInputs.length === 1);
svc.stopTeam(teamName); const killTracker = trackProcessKillsForPids([64901, 64902]);
try {
svc.stopTeam(teamName);
await waitForCondition(() => adapter.stopInputs.length === 1); await waitForCondition(() => adapter.stopInputs.length === 1);
expectDirectChildKillCount(staleKillCount, 0); expectProcessKillCount(killTracker.killedPids, 64901, 0);
expectDirectChildKillCount(currentKillCount, 1); expectProcessKillCount(killTracker.killedPids, 64902, 1);
expect(staleRun.cancelRequested).toBe(false); expectDirectChildKillCount(staleKillCount, 0);
expect(currentRun.cancelRequested).toBe(true); expectDirectChildKillCount(currentKillCount, 0);
expect(adapter.stopInputs.map((input) => input.laneId).sort()).toEqual([ expect(staleRun.cancelRequested).toBe(false);
'secondary:opencode:bob', expect(currentRun.cancelRequested).toBe(true);
]); expect(adapter.stopInputs.map((input) => input.laneId).sort()).toEqual([
expect(await svc.getRuntimeState(teamName)).toMatchObject({ 'secondary:opencode:bob',
teamName, ]);
isAlive: false, expect(await svc.getRuntimeState(teamName)).toMatchObject({
runId: null, teamName,
progress: null, isAlive: false,
}); runId: null,
progress: null,
});
} finally {
killTracker.restore();
}
}); });
it('cancels a stale Anthropic and Gemini mixed run without stopping current OpenCode lanes', async () => { it('cancels a stale Anthropic and Gemini mixed run without stopping current OpenCode lanes', async () => {
@ -13611,43 +13618,50 @@ describe('Team agent launch matrix safe e2e', () => {
await (svc as any).launchMixedSecondaryLaneIfNeeded(currentRun); await (svc as any).launchMixedSecondaryLaneIfNeeded(currentRun);
await waitForCondition(() => adapter.pendingLaunchInputs.length === 1); await waitForCondition(() => adapter.pendingLaunchInputs.length === 1);
await svc.cancelProvisioning(staleRun.runId); const killTracker = trackProcessKillsForPids([65001, 65002]);
try {
await svc.cancelProvisioning(staleRun.runId);
expectDirectChildKillCount(staleKillCount, 1); expectProcessKillCount(killTracker.killedPids, 65001, 1);
expectDirectChildKillCount(currentKillCount, 0); expectProcessKillCount(killTracker.killedPids, 65002, 0);
expect(staleRun.cancelRequested).toBe(true); expectDirectChildKillCount(staleKillCount, 0);
expect(currentRun.cancelRequested).toBe(false); expectDirectChildKillCount(currentKillCount, 0);
expect(adapter.stopInputs).toEqual([]); expect(staleRun.cancelRequested).toBe(true);
expect(svc.isTeamAlive(teamName)).toBe(true); expect(currentRun.cancelRequested).toBe(false);
expect(await svc.getRuntimeState(teamName)).toMatchObject({ expect(adapter.stopInputs).toEqual([]);
teamName, expect(svc.isTeamAlive(teamName)).toBe(true);
isAlive: true, expect(await svc.getRuntimeState(teamName)).toMatchObject({
runId: currentRun.runId, teamName,
}); isAlive: true,
runId: currentRun.runId,
});
adapter.releaseLaunches(); adapter.releaseLaunches();
await waitForCondition(() => adapter.launchInputs.length === 2); await waitForCondition(() => adapter.launchInputs.length === 2);
await waitForCondition(() => await waitForCondition(() =>
currentRun.mixedSecondaryLanes.every((lane: { state: string }) => lane.state === 'finished') currentRun.mixedSecondaryLanes.every((lane: { state: string }) => lane.state === 'finished')
); );
const statuses = await svc.getMemberSpawnStatuses(teamName); const statuses = await svc.getMemberSpawnStatuses(teamName);
expect(statuses.teamLaunchState).toBe('clean_success'); expect(statuses.teamLaunchState).toBe('clean_success');
expect(statuses.statuses.reviewer).toMatchObject({ expect(statuses.statuses.reviewer).toMatchObject({
status: 'online', status: 'online',
launchState: 'confirmed_alive', launchState: 'confirmed_alive',
hardFailure: false, hardFailure: false,
}); });
expect(statuses.statuses.bob).toMatchObject({ expect(statuses.statuses.bob).toMatchObject({
status: 'online', status: 'online',
launchState: 'confirmed_alive', launchState: 'confirmed_alive',
hardFailure: false, hardFailure: false,
}); });
expect(statuses.statuses.tom).toMatchObject({ expect(statuses.statuses.tom).toMatchObject({
status: 'online', status: 'online',
launchState: 'confirmed_alive', launchState: 'confirmed_alive',
hardFailure: false, hardFailure: false,
}); });
} finally {
killTracker.restore();
}
}); });
it('stops the current pure Anthropic run instead of a stale same-team run', async () => { it('stops the current pure Anthropic run instead of a stale same-team run', async () => {
@ -13677,18 +13691,25 @@ describe('Team agent launch matrix safe e2e', () => {
runId: currentRun.runId, runId: currentRun.runId,
}); });
svc.stopTeam(teamName); const killTracker = trackProcessKillsForPids([63101, 63102]);
try {
svc.stopTeam(teamName);
expectDirectChildKillCount(staleKillCount, 0); expectProcessKillCount(killTracker.killedPids, 63101, 0);
expectDirectChildKillCount(currentKillCount, 1); expectProcessKillCount(killTracker.killedPids, 63102, 1);
expect(staleRun.cancelRequested).toBe(false); expectDirectChildKillCount(staleKillCount, 0);
expect(currentRun.cancelRequested).toBe(true); expectDirectChildKillCount(currentKillCount, 0);
expect(await svc.getRuntimeState(teamName)).toMatchObject({ expect(staleRun.cancelRequested).toBe(false);
teamName, expect(currentRun.cancelRequested).toBe(true);
isAlive: false, expect(await svc.getRuntimeState(teamName)).toMatchObject({
runId: null, teamName,
progress: null, isAlive: false,
}); runId: null,
progress: null,
});
} finally {
killTracker.restore();
}
}); });
it('cancels a stale pure Anthropic run without stopping the current same-team run', async () => { it('cancels a stale pure Anthropic run without stopping the current same-team run', async () => {
@ -13712,20 +13733,27 @@ describe('Team agent launch matrix safe e2e', () => {
trackLiveRun(svc, staleRun); trackLiveRun(svc, staleRun);
trackLiveRun(svc, currentRun); trackLiveRun(svc, currentRun);
await svc.cancelProvisioning(staleRun.runId); const killTracker = trackProcessKillsForPids([63301, 63302]);
try {
await svc.cancelProvisioning(staleRun.runId);
expectDirectChildKillCount(staleKillCount, 1); expectProcessKillCount(killTracker.killedPids, 63301, 1);
expectDirectChildKillCount(currentKillCount, 0); expectProcessKillCount(killTracker.killedPids, 63302, 0);
expect(staleRun.cancelRequested).toBe(true); expectDirectChildKillCount(staleKillCount, 0);
expect(currentRun.cancelRequested).toBe(false); expectDirectChildKillCount(currentKillCount, 0);
expect(svc.isTeamAlive(teamName)).toBe(true); expect(staleRun.cancelRequested).toBe(true);
expect(await svc.getRuntimeState(teamName)).toMatchObject({ expect(currentRun.cancelRequested).toBe(false);
teamName, expect(svc.isTeamAlive(teamName)).toBe(true);
isAlive: true, expect(await svc.getRuntimeState(teamName)).toMatchObject({
runId: currentRun.runId, teamName,
}); isAlive: true,
runId: currentRun.runId,
});
await svc.sendMessageToTeam(teamName, 'current run still receives messages'); await svc.sendMessageToTeam(teamName, 'current run still receives messages');
} finally {
killTracker.restore();
}
}); });
it('cancels the current pure Anthropic run without resurrecting a stale same-team run', async () => { it('cancels the current pure Anthropic run without resurrecting a stale same-team run', async () => {
@ -13749,22 +13777,29 @@ describe('Team agent launch matrix safe e2e', () => {
trackLiveRun(svc, staleRun); trackLiveRun(svc, staleRun);
trackLiveRun(svc, currentRun); trackLiveRun(svc, currentRun);
await svc.cancelProvisioning(currentRun.runId); const killTracker = trackProcessKillsForPids([63501, 63502]);
try {
await svc.cancelProvisioning(currentRun.runId);
expectDirectChildKillCount(staleKillCount, 0); expectProcessKillCount(killTracker.killedPids, 63501, 0);
expectDirectChildKillCount(currentKillCount, 1); expectProcessKillCount(killTracker.killedPids, 63502, 1);
expect(staleRun.cancelRequested).toBe(false); expectDirectChildKillCount(staleKillCount, 0);
expect(currentRun.cancelRequested).toBe(true); expectDirectChildKillCount(currentKillCount, 0);
expect(svc.isTeamAlive(teamName)).toBe(false); expect(staleRun.cancelRequested).toBe(false);
expect(await svc.getRuntimeState(teamName)).toMatchObject({ expect(currentRun.cancelRequested).toBe(true);
teamName, expect(svc.isTeamAlive(teamName)).toBe(false);
isAlive: false, expect(await svc.getRuntimeState(teamName)).toMatchObject({
runId: null, teamName,
progress: null, isAlive: false,
}); runId: null,
await expect(svc.sendMessageToTeam(teamName, 'must not hit stale run')).rejects.toThrow( progress: null,
`No active process for team "${teamName}"` });
); await expect(svc.sendMessageToTeam(teamName, 'must not hit stale run')).rejects.toThrow(
`No active process for team "${teamName}"`
);
} finally {
killTracker.restore();
}
}); });
it('refreshes runtime snapshot cache after same-team pure Anthropic relaunch', async () => { it('refreshes runtime snapshot cache after same-team pure Anthropic relaunch', async () => {
@ -19067,7 +19102,38 @@ async function writeStoppedProcessRegistry(teamName: string): Promise<void> {
} }
function expectDirectChildKillCount(actual: number, expected: number): void { function expectDirectChildKillCount(actual: number, expected: number): void {
// Windows uses taskkill.exe for process-tree termination, so fake child.kill is not called. expect(actual).toBe(expected);
}
function trackProcessKillsForPids(pids: readonly number[]): {
killedPids: number[];
restore: () => void;
} {
const targetPids = new Set(pids);
const killedPids: number[] = [];
const spy = vi.spyOn(process, 'kill').mockImplementation(((
pid: number | string,
signal?: number | string
) => {
const numericPid = Number(pid);
if (targetPids.has(numericPid) && signal !== 0) {
killedPids.push(numericPid);
}
return true;
}) as typeof process.kill);
return {
killedPids,
restore: () => spy.mockRestore(),
};
}
function expectProcessKillCount(
killedPids: readonly number[],
pid: number,
expected: number
): void {
const actual = killedPids.filter((killedPid) => killedPid === pid).length;
// Windows uses taskkill.exe for process-tree termination, so process.kill is not called.
expect(actual).toBe(process.platform === 'win32' ? 0 : expected); expect(actual).toBe(process.platform === 'win32' ? 0 : expected);
} }

View file

@ -25,6 +25,9 @@ vi.mock('@main/services/team/ClaudeBinaryResolver', () => ({
vi.mock('@main/utils/childProcess', () => ({ vi.mock('@main/utils/childProcess', () => ({
execCli: vi.fn(async (_binaryPath: string | null, args: string[]) => { execCli: vi.fn(async (_binaryPath: string | null, args: string[]) => {
if (args[0] === '-e' && args[1]?.includes('process.execPath')) {
return { stdout: process.execPath, stderr: '' };
}
if (args.includes('model') && args.includes('list')) { if (args.includes('model') && args.includes('list')) {
return { return {
stdout: JSON.stringify({ stdout: JSON.stringify({
@ -79,6 +82,19 @@ vi.mock('@main/utils/childProcess', () => ({
killProcessTree: vi.fn(), killProcessTree: vi.fn(),
})); }));
vi.mock('@main/utils/shellEnv', async (importOriginal) => {
const actual = await importOriginal<typeof import('@main/utils/shellEnv')>();
return {
...actual,
getCachedShellEnv: () => ({ PATH: process.env.PATH ?? '', HOME: hoisted.paths.claudeRoot }),
getShellPreferredHome: () => hoisted.paths.claudeRoot || actual.getShellPreferredHome(),
resolveInteractiveShellEnv: vi.fn(async () => ({
PATH: process.env.PATH ?? '',
HOME: hoisted.paths.claudeRoot,
})),
};
});
vi.mock('@main/utils/pathDecoder', async (importOriginal) => { vi.mock('@main/utils/pathDecoder', async (importOriginal) => {
const actual = await importOriginal<typeof import('@main/utils/pathDecoder')>(); const actual = await importOriginal<typeof import('@main/utils/pathDecoder')>();
return { return {
@ -265,18 +281,20 @@ describe('TeamProvisioningService prompt content (solo mode discipline)', () =>
expect(writeSpy).not.toHaveBeenCalled(); expect(writeSpy).not.toHaveBeenCalled();
const prompt = extractPromptFromBootstrapFile(); const prompt = extractPromptFromBootstrapFile();
expect(prompt).toContain('SOLO MODE: This team CURRENTLY has ZERO teammates.'); expect(prompt).toContain('SOLO MODE: This team CURRENTLY has ZERO teammates.');
expect(prompt).toContain('This launch/bootstrap step has already been completed deterministically by the runtime.'); expect(prompt).toContain(
'This launch/bootstrap step has already been completed deterministically by the runtime.'
);
expect(prompt).toContain('Do NOT start implementation in this turn.'); expect(prompt).toContain('Do NOT start implementation in this turn.');
expect(prompt).toContain('Use this turn only to refresh context, review the current board snapshot, and confirm you are ready.'); expect(prompt).toContain(
'Use this turn only to refresh context, review the current board snapshot, and confirm you are ready.'
);
expect(prompt).toContain( expect(prompt).toContain(
'Do NOT create, assign, or delegate any new task in this turn. If the board is empty, stay silent and wait for a fresh user instruction.' 'Do NOT create, assign, or delegate any new task in this turn. If the board is empty, stay silent and wait for a fresh user instruction.'
); );
expect(prompt).toContain( expect(prompt).toContain(
'review_request already notifies the reviewer, so do NOT send a second manual SendMessage for the same review request' 'review_request already notifies the reviewer, so do NOT send a second manual SendMessage for the same review request'
); );
expect(prompt).toContain( expect(prompt).toContain('Review is a state transition on the EXISTING work task.');
'Review is a state transition on the EXISTING work task.'
);
expect(prompt).toContain( expect(prompt).toContain(
'The REVIEW column is for the same task #X moving through review. It is NOT a signal to create another task for review.' 'The REVIEW column is for the same task #X moving through review. It is NOT a signal to create another task for review.'
); );
@ -520,7 +538,9 @@ describe('TeamProvisioningService prompt content (solo mode discipline)', () =>
expect(message).toContain('Teammate "alice" with role "Reviewer" was restarted from the UI.'); expect(message).toContain('Teammate "alice" with role "Reviewer" was restarted from the UI.');
expect(message).toContain('team_name="forge-labs", name="alice"'); expect(message).toContain('team_name="forge-labs", name="alice"');
expect(message).toContain('provider="codex", model="gpt-5.4-mini", effort="medium"'); expect(message).toContain('provider="codex", model="gpt-5.4-mini", effort="medium"');
expect(message).toContain('This is a restart of an existing persistent teammate, not a new teammate.'); expect(message).toContain(
'This is a restart of an existing persistent teammate, not a new teammate.'
);
expect(message).toContain( expect(message).toContain(
'If the Agent tool returns duplicate_skipped with reason bootstrap_pending, treat that as a pending restart and wait for teammate check-in.' 'If the Agent tool returns duplicate_skipped with reason bootstrap_pending, treat that as a pending restart and wait for teammate check-in.'
); );
@ -623,11 +643,11 @@ describe('TeamProvisioningService prompt content (solo mode discipline)', () =>
role: 'developer', role: 'developer',
}); });
expect(prompt).toContain('Review flow rule: review is a state transition on the SAME work task');
expect(prompt).toContain('Do NOT create a separate "review task"');
expect(prompt).toContain( expect(prompt).toContain(
'If no reviewer exists, leave #X completed.' 'Review flow rule: review is a state transition on the SAME work task'
); );
expect(prompt).toContain('Do NOT create a separate "review task"');
expect(prompt).toContain('If no reviewer exists, leave #X completed.');
expect(prompt).toContain( expect(prompt).toContain(
'If you are the reviewer for task #X, call review_start on #X first, then review_approve or review_request_changes on #X itself.' 'If you are the reviewer for task #X, call review_start on #X first, then review_approve or review_request_changes on #X itself.'
); );
@ -731,9 +751,7 @@ describe('TeamProvisioningService prompt content (solo mode discipline)', () =>
expect(writeSpy).not.toHaveBeenCalled(); expect(writeSpy).not.toHaveBeenCalled();
const prompt = extractPromptFromBootstrapFile(); const prompt = extractPromptFromBootstrapFile();
expect(prompt).toContain( expect(prompt).toContain('Teammate task comments are auto-forwarded to you.');
'Teammate task comments are auto-forwarded to you.'
);
await svc.cancelProvisioning(runId); await svc.cancelProvisioning(runId);
}); });
@ -790,25 +808,29 @@ describe('TeamProvisioningService prompt content (solo mode discipline)', () =>
expect(writeSpy).not.toHaveBeenCalled(); expect(writeSpy).not.toHaveBeenCalled();
const prompt = extractPromptFromBootstrapFile(); const prompt = extractPromptFromBootstrapFile();
expect(prompt).toContain('This launch/bootstrap step has already been completed deterministically by the runtime.'); expect(prompt).toContain(
'This launch/bootstrap step has already been completed deterministically by the runtime.'
);
expect(prompt).toContain('Do NOT use Agent to spawn or restore teammates.'); expect(prompt).toContain('Do NOT use Agent to spawn or restore teammates.');
expect(prompt).toContain('Use this turn only to refresh context and review the current board snapshot.'); expect(prompt).toContain(
'Use this turn only to refresh context and review the current board snapshot.'
);
expect(prompt).toContain( expect(prompt).toContain(
'Do NOT create, assign, or delegate any new task in this turn. If the board is empty, stay silent and wait for a fresh user instruction.' 'Do NOT create, assign, or delegate any new task in this turn. If the board is empty, stay silent and wait for a fresh user instruction.'
); );
expect(prompt).toContain('DELEGATION-FIRST (behavior rule for ALL future turns):'); expect(prompt).toContain('DELEGATION-FIRST (behavior rule for ALL future turns):');
expect(prompt).toContain(`AGENT_BLOCK_OPEN is exactly: ${AGENT_BLOCK_OPEN}`); expect(prompt).toContain(`AGENT_BLOCK_OPEN is exactly: ${AGENT_BLOCK_OPEN}`);
expect(prompt).toContain(`AGENT_BLOCK_CLOSE is exactly: ${AGENT_BLOCK_CLOSE}`); expect(prompt).toContain(`AGENT_BLOCK_CLOSE is exactly: ${AGENT_BLOCK_CLOSE}`);
expect(prompt).toContain('Messages to "user" (the human) must NEVER contain agent-only blocks.'); expect(prompt).toContain(
'Messages to "user" (the human) must NEVER contain agent-only blocks.'
);
expect(prompt).toContain('task_create_from_message'); expect(prompt).toContain('task_create_from_message');
expect(prompt).toContain('task_set_owner'); expect(prompt).toContain('task_set_owner');
expect(prompt).toContain('cross_team_send'); expect(prompt).toContain('cross_team_send');
expect(prompt).toContain( expect(prompt).toContain(
'lead_briefing is the primary lead queue. Decisions about what to act on now come from lead_briefing, not from raw task_list rows.' 'lead_briefing is the primary lead queue. Decisions about what to act on now come from lead_briefing, not from raw task_list rows.'
); );
expect(prompt).toContain( expect(prompt).toContain('Browse/search compact inventory rows only: task_list');
'Browse/search compact inventory rows only: task_list'
);
expect(prompt).toContain( expect(prompt).toContain(
`Browse/search compact inventory rows only: task_list { teamName: "${teamName}", owner?: "<member>", status?: "pending|in_progress|completed"` `Browse/search compact inventory rows only: task_list { teamName: "${teamName}", owner?: "<member>", status?: "pending|in_progress|completed"`
); );
@ -816,20 +838,12 @@ describe('TeamProvisioningService prompt content (solo mode discipline)', () =>
`Browse/search compact inventory rows only: task_list { teamName: "${teamName}", owner?: "<member>", status?: "pending|in_progress|completed|deleted"` `Browse/search compact inventory rows only: task_list { teamName: "${teamName}", owner?: "<member>", status?: "pending|in_progress|completed|deleted"`
); );
expect(prompt).toContain( expect(prompt).toContain(
'task_list is inventory/search/drill-down only. Do NOT treat task_list as the lead\'s working queue.' "task_list is inventory/search/drill-down only. Do NOT treat task_list as the lead's working queue."
);
expect(prompt).toContain(
'review_request already notifies the reviewer'
);
expect(prompt).toContain(
'By default, NEVER create a separate "review task".'
);
expect(prompt).toContain(
'Only move #X into REVIEW when a real reviewer exists for #X.'
);
expect(prompt).not.toContain(
'Only create a separate review reminder/assignment task'
); );
expect(prompt).toContain('review_request already notifies the reviewer');
expect(prompt).toContain('By default, NEVER create a separate "review task".');
expect(prompt).toContain('Only move #X into REVIEW when a real reviewer exists for #X.');
expect(prompt).not.toContain('Only create a separate review reminder/assignment task');
expect(prompt).toContain( expect(prompt).toContain(
'Correct flow: finish implementation on #X -> task_complete #X -> review_request #X -> reviewer runs review_start #X -> reviewer runs review_approve or review_request_changes on #X.' 'Correct flow: finish implementation on #X -> task_complete #X -> review_request #X -> reviewer runs review_start #X -> reviewer runs review_approve or review_request_changes on #X.'
); );

View file

@ -166,10 +166,11 @@ describe('GraphMemberLogPreviewHud', () => {
button.textContent?.includes('pnpm test') button.textContent?.includes('pnpm test')
); );
expect(row).not.toBeUndefined(); expect(row).not.toBeUndefined();
expect(row?.querySelector('.float-left')).not.toBeNull(); expect(row?.querySelector('svg.text-amber-300')).not.toBeNull();
expect(row?.querySelector('.line-clamp-3')).toBeNull(); expect(row?.querySelector('.line-clamp-3')).toBeNull();
expect(row?.querySelector('.line-clamp-2')).not.toBeNull();
expect(row?.className).toContain('h-[72px]'); expect(row?.className).toContain('h-[72px]');
expect(row?.querySelector('span.text-slate-200')?.className).toContain('leading-5'); expect(row?.querySelector('span.text-slate-200')?.className).toContain('leading-4');
expect(row?.textContent).toContain('pnpm test'); expect(row?.textContent).toContain('pnpm test');
const errorRow = Array.from(host.querySelectorAll('button')).find((button) => const errorRow = Array.from(host.querySelectorAll('button')).find((button) =>