fix: harden team runtime liveness

This commit is contained in:
777genius 2026-04-24 20:52:26 +03:00
parent a39ae2fb84
commit ebb7b5289d
109 changed files with 6244 additions and 1545 deletions

View file

@ -48,25 +48,19 @@ function normalizeMetaMembers(rawMembers) {
}
function resolveTargetLead(paths, config) {
// 1. config.members — agentType check
// 1. config.members - canonical lead detection shared with queue routing
if (config && config.members && config.members.length) {
const lead = config.members.find((m) => m && m.agentType === 'team-lead');
const lead = config.members.find((m) => runtimeHelpers.isCanonicalLeadMember(m));
if (lead && lead.name) return String(lead.name).trim();
// 2. config.members — name check
const namedLead = config.members.find((m) => m && m.name === 'team-lead');
if (namedLead && namedLead.name) return String(namedLead.name).trim();
}
// 3. members.meta.json — WITH normalization (trim + dedup)
// 2. members.meta.json - WITH normalization (trim + dedup)
const metaPath = path.join(paths.teamDir, 'members.meta.json');
try {
const raw = JSON.parse(fs.readFileSync(metaPath, 'utf8'));
const members = normalizeMetaMembers(raw && raw.members);
if (members.length > 0) {
const metaLead = members.find(
(m) => m.agentType === 'team-lead' || m.name === 'team-lead'
);
const metaLead = members.find((m) => runtimeHelpers.isCanonicalLeadMember(m));
if (metaLead && metaLead.name) return metaLead.name;
return members[0].name;
}
@ -74,13 +68,8 @@ function resolveTargetLead(paths, config) {
/* ENOENT or parse error */
}
// 4. role-based (legacy compat)
// 3. First configured member
if (config && config.members && config.members.length) {
const roleLead = config.members.find(
(m) => m && m.role && String(m.role).toLowerCase().includes('lead')
);
if (roleLead && roleLead.name) return String(roleLead.name).trim();
// 5. First member
if (config.members[0] && config.members[0].name) return String(config.members[0].name).trim();
}
@ -141,7 +130,7 @@ function findRecentDuplicate(outboxList, dedupeKey) {
function sendCrossTeamMessage(context, flags) {
const fromTeam = context.teamName;
const toTeam = typeof flags.toTeam === 'string' ? flags.toTeam.trim() : '';
const fromMember = typeof flags.fromMember === 'string' ? flags.fromMember.trim() : 'team-lead';
const rawFromMember = typeof flags.fromMember === 'string' ? flags.fromMember.trim() : '';
const replyToConversationId =
typeof flags.replyToConversationId === 'string' ? flags.replyToConversationId.trim() : '';
const conversationId =
@ -156,6 +145,10 @@ function sendCrossTeamMessage(context, flags) {
if (!TEAM_NAME_PATTERN.test(fromTeam)) {
throw new Error(`Invalid fromTeam: ${fromTeam}`);
}
const sourceConfig = runtimeHelpers.readTeamConfig(context.paths);
if (!sourceConfig || sourceConfig.deletedAt) {
throw new Error(`Source team not found: ${fromTeam}`);
}
if (!TEAM_NAME_PATTERN.test(toTeam)) {
throw new Error(`Invalid toTeam: ${toTeam}`);
}
@ -165,6 +158,11 @@ function sendCrossTeamMessage(context, flags) {
if (!text || text.trim().length === 0) {
throw new Error('Message text is required');
}
const fromMember = rawFromMember
? runtimeHelpers.assertExplicitTeamMemberName(context.paths, rawFromMember, 'cross-team sender', {
allowLeadAliases: true,
})
: runtimeHelpers.inferLeadName(context.paths);
// Target context + config
const targetContext = createTargetContext(context, toTeam);

View file

@ -101,12 +101,12 @@ function listReviewers(context) {
function addReviewer(context, reviewer) {
return withTeamBoardLock(context.paths, () => {
runtimeHelpers.assertExplicitTeamMemberName(context.paths, reviewer, 'reviewer', {
const resolvedReviewer = runtimeHelpers.assertExplicitTeamMemberName(context.paths, reviewer, 'reviewer', {
allowLeadAliases: true,
});
const state = getKanbanState(context);
const next = new Set(state.reviewers);
next.add(String(reviewer));
next.add(String(resolvedReviewer));
kanbanStore.writeKanbanState(context.paths, context.teamName, {
...state,
reviewers: [...next],
@ -118,7 +118,13 @@ function addReviewer(context, reviewer) {
function removeReviewer(context, reviewer) {
return withTeamBoardLock(context.paths, () => {
const state = getKanbanState(context);
const next = state.reviewers.filter((entry) => entry !== reviewer);
const resolvedReviewer = runtimeHelpers.resolveExplicitTeamMemberName(context.paths, reviewer, {
allowLeadAliases: true,
});
const reviewerNames = new Set(
[reviewer, resolvedReviewer].filter((entry) => typeof entry === 'string' && entry.trim())
);
const next = state.reviewers.filter((entry) => !reviewerNames.has(entry));
kanbanStore.writeKanbanState(context.paths, context.teamName, {
...state,
reviewers: next,

View file

@ -69,10 +69,9 @@ function normalizeActorKey(value) {
function resolveKnownActorName(context, value, label) {
const actor = typeof value === 'string' && value.trim() ? value.trim() : '';
if (!actor) return null;
runtimeHelpers.assertExplicitTeamMemberName(context.paths, actor, label, {
return runtimeHelpers.assertExplicitTeamMemberName(context.paths, actor, label, {
allowLeadAliases: true,
});
return actor;
}
function tryResolveKnownActorName(context, value, label) {
@ -301,9 +300,10 @@ function requestReview(context, taskId, flags = {}) {
}
const nextFrom =
resolveKnownActorName(context, flags.from, 'review requester') || 'team-lead';
resolveKnownActorName(context, flags.from, 'review requester') ||
resolveKnownActorName(context, 'team-lead', 'review requester');
const rawReviewer = getReviewer(context, flags);
const nextReviewer = rawReviewer ? (resolveKnownActorName(context, rawReviewer, 'reviewer'), rawReviewer) : null;
const nextReviewer = rawReviewer ? resolveKnownActorName(context, rawReviewer, 'reviewer') : null;
const prevReviewState = getEffectiveReviewState(context, currentTask);
if (prevReviewState === 'approved') {
throw new Error(`Task #${currentTask.displayId || currentTask.id} is already approved; reopen work before requesting another review`);

View file

@ -80,19 +80,46 @@ function getEffectiveReviewState(task, kanbanEntry) {
return historyState;
}
const status = typeof task?.status === 'string' ? task.status.trim() : '';
const normalizeFallback = (state, source) => {
const normalized = normalizeReviewState(state);
if (normalized === 'none') {
return null;
}
if (status === 'in_progress' || status === 'deleted') {
return {
state: 'none',
source: `${source}_status_reset`,
};
}
if (status === 'pending') {
return normalized === 'needsFix'
? { state: 'needsFix', source: `${source}_pending_needs_fix` }
: { state: 'none', source: `${source}_pending_reset` };
}
if (status === 'completed') {
return normalized === 'review' || normalized === 'approved'
? { state: normalized, source }
: { state: 'none', source: `${source}_completed_reset` };
}
return { state: normalized, source };
};
const persisted = normalizeReviewState(task && task.reviewState);
if (persisted !== 'none') {
return {
state: persisted,
source: 'task_review_state',
};
const persistedFallback = normalizeFallback(persisted, 'task_review_state');
if (persistedFallback) {
return persistedFallback;
}
if (kanbanEntry && REVIEW_COLUMNS.has(kanbanEntry.column)) {
return {
state: normalizeReviewState(kanbanEntry.column),
source: 'kanban_column',
};
const kanbanFallback = normalizeFallback(kanbanEntry.column, 'kanban_column');
if (kanbanFallback) {
return kanbanFallback;
}
}
return {

View file

@ -5,6 +5,7 @@ const crypto = require('crypto');
const TASK_ATTACHMENTS_DIR = 'task-attachments';
const MAX_TASK_ATTACHMENT_BYTES = 20 * 1024 * 1024;
const TEAM_NAME_PATTERN = /^[a-z0-9][a-z0-9-]{0,127}$/;
const LEAD_AGENT_TYPES = new Set(['team-lead', 'lead', 'orchestrator']);
const CROSS_TEAM_TOOL_RECIPIENT_NAMES = new Set([
'cross_team_send',
'cross_team_list_targets',
@ -130,7 +131,7 @@ function isCanonicalLeadMember(member) {
const role = typeof member.role === 'string' ? member.role.trim().toLowerCase() : '';
const name = typeof member.name === 'string' ? member.name.trim().toLowerCase() : '';
return (
agentType === 'team-lead' ||
LEAD_AGENT_TYPES.has(agentType) ||
name === 'team-lead' ||
role === 'team-lead' ||
role === 'team lead' ||
@ -175,12 +176,10 @@ function inferLeadName(paths) {
const resolved = resolveTeamMembers(paths);
const members = resolved.members || [];
const lead =
members.find(
(member) =>
member &&
typeof member.agentType === 'string' &&
member.agentType.trim().toLowerCase() === 'team-lead'
) ||
members.find((member) => {
const agentType = typeof member?.agentType === 'string' ? member.agentType.trim().toLowerCase() : '';
return LEAD_AGENT_TYPES.has(agentType);
}) ||
members.find((member) => String((member && member.name) || '').trim().toLowerCase() === 'team-lead') ||
members.find(
(member) => {

View file

@ -187,19 +187,23 @@ function maybeNotifyTaskOwnerOnComment(context, task, comment, options = {}) {
}
function createTask(context, input) {
let taskInput = input;
if (input && typeof input.owner === 'string' && input.owner.trim()) {
assertKnownTaskActor(context, input.owner, 'task owner');
taskInput = {
...input,
owner: assertKnownTaskActor(context, input.owner, 'task owner'),
};
}
const task = withTeamBoardLock(context.paths, () => taskStore.createTask(context.paths, input));
if (input && input.notifyOwner !== false) {
const task = withTeamBoardLock(context.paths, () => taskStore.createTask(context.paths, taskInput));
if (taskInput && taskInput.notifyOwner !== false) {
maybeNotifyAssignedOwner(context, task, {
description: input.description,
prompt: input.prompt,
description: taskInput.description,
prompt: taskInput.prompt,
taskRefs: [
...(Array.isArray(input.descriptionTaskRefs) ? input.descriptionTaskRefs : []),
...(Array.isArray(input.promptTaskRefs) ? input.promptTaskRefs : []),
...(Array.isArray(taskInput.descriptionTaskRefs) ? taskInput.descriptionTaskRefs : []),
...(Array.isArray(taskInput.promptTaskRefs) ? taskInput.promptTaskRefs : []),
],
from: input.from,
from: taskInput.from,
});
}
return task;
@ -382,6 +386,10 @@ function softDeleteTask(context, taskId, actor) {
function restoreTask(context, taskId, actor) {
return withTeamBoardLock(context.paths, () => {
const before = taskStore.readTask(context.paths, taskId, { includeDeleted: true });
if (before.status !== 'deleted') {
throw new Error(`Task #${before.displayId || before.id} is not deleted; task_restore only restores deleted tasks`);
}
let task = taskStore.setTaskStatus(context.paths, taskId, 'pending', actor || 'user');
const state = kanbanStore.readKanbanState(context.paths, context.teamName);
if (hasKanbanReference(state, task.id)) {
@ -403,7 +411,7 @@ function setTaskOwner(context, taskId, owner) {
const before = taskStore.readTask(context.paths, taskId, { includeDeleted: true });
const nextOwner = isClearOwnerValue(owner)
? owner
: (assertKnownTaskActor(context, owner, 'task owner'), owner);
: assertKnownTaskActor(context, owner, 'task owner');
const after = taskStore.setTaskOwner(context.paths, taskId, nextOwner);
return {
previousTask: before,

View file

@ -11,6 +11,7 @@ const AGENT_TEAMS_TASK_TOOL_NAMES = [
'task_get_comment',
'task_link',
'task_list',
'task_restore',
'task_set_clarification',
'task_set_owner',
'task_set_status',

View file

@ -385,6 +385,31 @@ describe('agent-teams-controller API', () => {
expect(briefing).toContain('Counters: actionable=4, awareness=3');
});
it('treats stale legacy terminal reviewState on pending tasks as owner-ready work', async () => {
const claudeDir = makeClaudeDir();
const controller = createController({ teamName: 'my-team', claudeDir });
const staleTask = controller.tasks.createTask({
subject: 'Legacy stale approved task',
owner: 'bob',
status: 'pending',
reviewState: 'approved',
notifyOwner: false,
});
const briefing = await controller.tasks.taskBriefing('bob');
const staleLine = briefing.split('\n').find((line) => line.includes(`#${staleTask.displayId}`));
expect(staleLine).toContain('[status=pending]');
expect(staleLine).not.toContain('review=');
expect(staleLine).toContain('reason=owner_ready');
const rows = controller.tasks.listTaskInventory({ owner: 'bob' });
expect(rows.find((row) => row.id === staleTask.id)).toMatchObject({
status: 'pending',
reviewState: 'none',
});
});
it('reconciles stale kanban rows and linked inbox comments idempotently', () => {
const claudeDir = makeClaudeDir();
const controller = createController({ teamName: 'my-team', claudeDir });
@ -887,7 +912,7 @@ describe('agent-teams-controller API', () => {
text: 'Need your decision here.',
});
const inboxPath = path.join(claudeDir, 'teams', 'my-team', 'inboxes', 'team-lead.json');
const inboxPath = path.join(claudeDir, 'teams', 'my-team', 'inboxes', 'alice.json');
const rows = JSON.parse(fs.readFileSync(inboxPath, 'utf8'));
expect(rows).toHaveLength(1);
expect(rows[0].from).toBe('bob');
@ -1114,6 +1139,82 @@ describe('agent-teams-controller API', () => {
expect(leadBriefing).not.toContain(`#${task.displayId}`);
});
it('recognizes lead and orchestrator agent types as canonical team leads', async () => {
const claudeDir = makeClaudeDir();
const configPath = path.join(claudeDir, 'teams', 'my-team', 'config.json');
fs.writeFileSync(
configPath,
JSON.stringify(
{
name: 'my-team',
leadSessionId: 'lead-session-1',
members: [
{ name: 'alice', role: 'developer' },
{ name: 'leadbot', agentType: 'lead' },
{ name: 'opsbot', agentType: 'orchestrator' },
],
},
null,
2
)
);
const controller = createController({ teamName: 'my-team', claudeDir });
const aliceTask = controller.tasks.createTask({ subject: 'Alice owns this', owner: 'alice' });
const leadTask = controller.tasks.createTask({ subject: 'Lead owns this', owner: 'leadbot' });
const aliceBriefing = await controller.tasks.taskBriefing('alice');
const leadBriefing = await controller.tasks.leadBriefing();
expect(aliceBriefing).toContain(`#${aliceTask.displayId}`);
expect(aliceBriefing).toContain('actionOwner=@alice');
expect(aliceBriefing).not.toContain(`#${leadTask.displayId}`);
expect(leadBriefing).toContain(`#${leadTask.displayId}`);
expect(leadBriefing).not.toContain(`#${aliceTask.displayId}`);
});
it('stores canonical member names for lead aliases in owners, reviewers, and reviewer config', () => {
const claudeDir = makeClaudeDir();
const configPath = path.join(claudeDir, 'teams', 'my-team', 'config.json');
fs.writeFileSync(
configPath,
JSON.stringify(
{
name: 'my-team',
members: [
{ name: 'leadbot', agentType: 'lead' },
{ name: 'alice', role: 'reviewer' },
{ name: 'bob', role: 'developer' },
],
},
null,
2
)
);
const controller = createController({ teamName: 'my-team', claudeDir });
const leadOwnedTask = controller.tasks.createTask({ subject: 'Lead alias owner', owner: 'lead' });
expect(leadOwnedTask.owner).toBe('leadbot');
expect(fs.existsSync(path.join(claudeDir, 'teams', 'my-team', 'inboxes', 'lead.json'))).toBe(false);
const reassignedTask = controller.tasks.createTask({ subject: 'Reassign alias owner', owner: 'bob' });
expect(controller.tasks.setTaskOwner(reassignedTask.id, 'team-lead').owner).toBe('leadbot');
controller.kanban.addReviewer('lead');
expect(controller.kanban.listReviewers()).toEqual(['leadbot']);
const reviewTask = controller.tasks.createTask({ subject: 'Review alias', owner: 'bob' });
controller.tasks.completeTask(reviewTask.id, 'bob');
controller.review.requestReview(reviewTask.id, { from: 'alice', reviewer: 'lead' });
const requested = controller.tasks
.getTask(reviewTask.id)
.historyEvents.filter((event) => event.type === 'review_requested')
.at(-1);
expect(requested.reviewer).toBe('leadbot');
expect(fs.existsSync(path.join(claudeDir, 'teams', 'my-team', 'inboxes', 'leadbot.json'))).toBe(true);
expect(fs.existsSync(path.join(claudeDir, 'teams', 'my-team', 'inboxes', 'lead.json'))).toBe(false);
});
it('rejects task_briefing for unknown members', async () => {
const claudeDir = makeClaudeDir();
const controller = createController({ teamName: 'my-team', claudeDir });
@ -1305,6 +1406,22 @@ describe('agent-teams-controller API', () => {
expect(restored.reviewState).toBe('none');
});
it('rejects task_restore for non-deleted tasks', () => {
const claudeDir = makeClaudeDir();
const controller = createController({ teamName: 'my-team', claudeDir });
const task = controller.tasks.createTask({ subject: 'Approved task must stay approved', owner: 'bob' });
controller.tasks.completeTask(task.id, 'bob');
controller.review.requestReview(task.id, { from: 'alice', reviewer: 'alice' });
controller.review.approveReview(task.id, { from: 'alice' });
expect(() => controller.tasks.restoreTask(task.id, 'alice')).toThrow(
'task_restore only restores deleted tasks'
);
expect(controller.tasks.getTask(task.id).status).toBe('completed');
expect(controller.tasks.getTask(task.id).reviewState).toBe('approved');
});
it('uses actual kanban overlay for kanbanColumn inventory filters', () => {
const claudeDir = makeClaudeDir();
const controller = createController({ teamName: 'my-team', claudeDir });

View file

@ -59,8 +59,8 @@ describe('crossTeam module', () => {
const inbox = JSON.parse(fs.readFileSync(inboxPath, 'utf8'));
expect(inbox).toHaveLength(1);
expect(inbox[0].source).toBe(CROSS_TEAM_SOURCE);
expect(inbox[0].from).toBe('team-a.lead');
expect(inbox[0].text).toContain(`<${CROSS_TEAM_TAG_NAME} from="team-a.lead" depth="0"`);
expect(inbox[0].from).toBe('team-a.team-lead');
expect(inbox[0].text).toContain(`<${CROSS_TEAM_TAG_NAME} from="team-a.team-lead" depth="0"`);
expect(inbox[0].conversationId).toBeTruthy();
expect(inbox[0].text).toContain(`conversationId="${inbox[0].conversationId}"`);
});
@ -314,6 +314,108 @@ describe('crossTeam module', () => {
expect(fs.existsSync(inboxPath)).toBe(true);
});
it('resolves supported lead agent types before tech-lead role text', () => {
const claudeDir = makeClaudeDir({
'team-a': {
name: 'team-a',
members: [{ name: 'team-lead', agentType: 'team-lead' }],
},
'team-b': {
name: 'team-b',
members: [
{ name: 'alice', role: 'tech lead' },
{ name: 'olivia', agentType: 'lead' },
],
},
});
const controller = createController({ teamName: 'team-a', claudeDir });
controller.crossTeam.sendCrossTeamMessage({
toTeam: 'team-b',
text: 'Hello',
});
expect(fs.existsSync(path.join(claudeDir, 'teams', 'team-b', 'inboxes', 'olivia.json'))).toBe(true);
expect(fs.existsSync(path.join(claudeDir, 'teams', 'team-b', 'inboxes', 'alice.json'))).toBe(false);
});
it('resolves orchestrator lead from members.meta.json', () => {
const claudeDir = makeClaudeDir({
'team-a': {
name: 'team-a',
members: [{ name: 'team-lead', agentType: 'team-lead' }],
},
'team-b': {
name: 'team-b',
members: [],
},
});
const metaPath = path.join(claudeDir, 'teams', 'team-b', 'members.meta.json');
fs.writeFileSync(
metaPath,
JSON.stringify({
members: [
{ name: 'alice', role: 'tech lead' },
{ name: 'orla', agentType: 'orchestrator' },
],
})
);
const controller = createController({ teamName: 'team-a', claudeDir });
controller.crossTeam.sendCrossTeamMessage({
toTeam: 'team-b',
text: 'Hello',
});
expect(fs.existsSync(path.join(claudeDir, 'teams', 'team-b', 'inboxes', 'orla.json'))).toBe(true);
expect(fs.existsSync(path.join(claudeDir, 'teams', 'team-b', 'inboxes', 'alice.json'))).toBe(false);
});
it('rejects phantom source teams before delivery or outbox writes', () => {
const claudeDir = makeClaudeDir({
'team-b': {
name: 'team-b',
members: [{ name: 'team-lead', agentType: 'team-lead' }],
},
});
const controller = createController({ teamName: 'team-a', claudeDir });
expect(() =>
controller.crossTeam.sendCrossTeamMessage({
toTeam: 'team-b',
text: 'Hello from nowhere',
})
).toThrow('Source team not found: team-a');
expect(fs.existsSync(path.join(claudeDir, 'teams', 'team-a'))).toBe(false);
expect(fs.existsSync(path.join(claudeDir, 'teams', 'team-b', 'inboxes', 'team-lead.json'))).toBe(false);
});
it('rejects unknown cross-team senders', () => {
const claudeDir = makeClaudeDir({
'team-a': {
name: 'team-a',
members: [{ name: 'team-lead', agentType: 'team-lead' }],
},
'team-b': {
name: 'team-b',
members: [{ name: 'team-lead', agentType: 'team-lead' }],
},
});
const controller = createController({ teamName: 'team-a', claudeDir });
expect(() =>
controller.crossTeam.sendCrossTeamMessage({
toTeam: 'team-b',
fromMember: 'alicce',
text: 'Hello',
})
).toThrow('Unknown cross-team sender: alicce');
expect(fs.existsSync(path.join(claudeDir, 'teams', 'team-b', 'inboxes', 'team-lead.json'))).toBe(false);
});
it('resolves lead by name fallback', () => {
const claudeDir = makeClaudeDir({
'team-a': {

View file

@ -32,23 +32,23 @@
- `mcp-server/src/tools/runtimeTools.ts` уже содержит `runtime_bootstrap_checkin` и `runtime_heartbeat`. Это сильный сигнал, его надо сделать главным источником подтверждения.
- `agent-teams-controller/src/internal/runtime.js` уже прокидывает `runtimeBootstrapCheckin()` в desktop runtime.
- `src/main/services/team/TeamBootstrapStateReader.ts` уже читает `bootstrap-state.json`, `bootstrap-journal.jsonl` и классифицирует stuck bootstrap. Там уже есть важные тайминги: `ACTIVE_BOOTSTRAP_STUCK_CLASSIFICATION_MS = 3 min` и `TERMINAL_BOOTSTRAP_ONLY_PENDING_GRACE_MS = 5 min`.
- `TeamProvisioningService.getLiveTeamAgentRuntimeMetadata()` сейчас собирает evidence из config/meta/persisted runtime/tmux/process table.
- Для tmux сейчас читается только `#{pane_id}\t#{pane_pid}` через `listTmuxPanePidsForCurrentPlatform()`. `pane_pid` часто является shell (`zsh`, `bash`, `sh`), поэтому `2 MB` выглядит логично.
- `attachLiveRuntimeMetadataToStatuses()` превращает `metadata.alive` в `runtimeAlive: true` и `livenessSource: "process"`.
- `reevaluateMemberLaunchStatus()` не fail-ит member после grace timeout, если `runtimeAlive === true`.
- `OpenCodeTeamRuntimeAdapter.mapBridgeMemberToRuntimeEvidence()` сейчас может выставить `runtimeAlive: true`, если bridge просто вернул member в состоянии `created` или `permission_blocked`. Это полезный материализационный сигнал, но он слабее реального `runtimePid` и слабее bootstrap.
- `TeamProvisioningService.getLiveTeamAgentRuntimeMetadata()` собирает evidence из config/meta/persisted runtime/tmux/process table и прогоняет его через strict resolver.
- Для tmux раньше читался только `#{pane_id}\t#{pane_pid}` через `listTmuxPanePidsForCurrentPlatform()`. `pane_pid` часто является shell (`zsh`, `bash`, `sh`), поэтому `2 MB` выглядело логично.
- `attachLiveRuntimeMetadataToStatuses()` теперь повышает member до `runtimeAlive: true` только через strong evidence: `confirmed_bootstrap` или `runtime_process`.
- `reevaluateMemberLaunchStatus()` больше не доверяет старому `runtimeAlive === true` без live metadata.
- `OpenCodeTeamRuntimeAdapter.mapBridgeMemberToRuntimeEvidence()` теперь не выставляет `runtimeAlive: true` для bridge-only `created` или `permission_blocked`. Такие сигналы остаются candidate/pending до bootstrap или OS verification.
- `recordOpenCodeRuntimeBootstrapCheckin()` и `recordOpenCodeRuntimeHeartbeat()` уже пишут `confirmed_alive`, `runtimeAlive: true`, `bootstrapConfirmed: true`, `nativeHeartbeat: true` через `updateOpenCodeRuntimeMemberLiveness()`. Значит confirmed state уже есть, надо не дать слабым сигналам выглядеть как он.
- `OpenCodeLaunchTransactionStore.canMarkOpenCodeRunReady()` уже требует `member_session_recorded`, `required_tools_proven` и `bootstrap_confirmed`. Это strict readiness precedent, который надо сохранить.
- Renderer уже получает оба источника: `memberSpawnStatuses` и `teamAgentRuntimeByTeam`. Но `MemberCard` сейчас получает только `runtimeSummary` строкой, а не сам `TeamAgentRuntimeEntry`.
- `teamSlice.areTeamAgentRuntimeEntriesEqual()` сейчас сравнивает только `memberName`, `alive`, `restartable`, `backendType`, `pid`, `runtimeModel`, `rssBytes`. Если добавить `livenessKind`, `pidSource`, `diagnostics`, но не обновить comparator, UI может не перерендериться.
- `teamSlice.areMemberSpawnStatusEntriesEqual()` сейчас намеренно игнорирует timing fields и сравнивает только visible spawn fields. Если добавить `livenessKind/runtimeDiagnostic`, comparator тоже надо обновить.
- `areLaunchSummaryCountsEqual()` сейчас знает только `confirmedCount`, `pendingCount`, `failedCount`, `runtimeAlivePendingCount`. Новые aggregate diagnostic counts не будут обновлять UI без расширения comparator.
- `teamSlice.areTeamAgentRuntimeEntriesEqual()` должен сравнивать `livenessKind`, `pidSource` и diagnostic fields, иначе UI может не перерендериться при смене strict evidence.
- `teamSlice.areMemberSpawnStatusEntriesEqual()` должен сравнивать visible liveness fields (`livenessKind/runtimeDiagnostic`) и продолжать игнорировать timing-only fields.
- `areLaunchSummaryCountsEqual()` должен сравнивать aggregate diagnostic counts (`shellOnlyPendingCount`, `runtimeProcessPendingCount`, `runtimeCandidatePendingCount`, `noRuntimePendingCount`, `permissionPendingCount`). UI не должен использовать legacy `runtimeAlivePendingCount` как process evidence.
- `TeamAgentRuntimeWatcher` обновляет runtime snapshot раз в 5 секунд, а spawn statuses раз в 2.5 секунды. Диагностические поля должны попадать либо в оба snapshot слоя, либо UX должен быть устойчив к задержке runtime snapshot.
- Renderer `member-spawn` event сейчас вызывает refresh spawn statuses, но не runtime snapshot. Если tooltip/detail зависят от `TeamAgentRuntimeSnapshot`, event handler тоже должен запланировать runtime refresh.
- Runtime tools принимают `metadata`, но `recordOpenCodeRuntimeBootstrapCheckin()` и `recordOpenCodeRuntimeHeartbeat()` сейчас используют только `diagnostics`. Если runtime присылает PID/version/command в `metadata`, эта информация теряется.
- `handleMemberSpawnToolResult()` при reason `already_running` сейчас делает `setMemberSpawnStatus(..., "online", ..., "process")`. В strict model это нельзя оставлять как strong liveness без проверки актуального runtime identity.
- `handleMemberSpawnToolResult()` раньше при reason `already_running` делал `setMemberSpawnStatus(..., "online", ..., "process")`. В strict model это заменено на `waiting` + runtime re-evaluation.
- `waitForTmuxPanesToExit()` использует `listTmuxPanePidsForCurrentPlatform()` только как "pane exists" check. Поэтому старый `listPanePids()` wrapper должен остаться ровно pane-existence helper, а не получить новую liveness-семантику.
- В проекте уже есть env-mode precedent: `CLAUDE_TEAM_OPENCODE_LAUNCH_MODE` с `dogfood`/`production`/`disabled`. Для liveness rollout лучше использовать такой же явный режим, а не скрытый boolean.
- В проекте есть env-mode precedent: `CLAUDE_TEAM_OPENCODE_LAUNCH_MODE` с `dogfood`/`production`/`disabled`. Для member liveness финальное решение другое: strict model включена по умолчанию без отдельного env-флага.
- `src/shared/types/api.ts`, `src/preload/index.ts` и `src/renderer/api/httpClient.ts` уже прокидывают `getMemberSpawnStatuses()` и `getTeamAgentRuntime()` через shared snapshot types. Новый контракт можно добавить optional fields без нового IPC channel, но browser HTTP fallback должен возвращать валидный старый shape.
- `TeamProvisioningService.readUnixProcessTableRows()` сейчас приватный, sync и читает только `pid,command`. Для надежного liveness нужен `ppid`, WSL-aware execution и unit-test seam. Это не должно оставаться приватным ad hoc helper внутри огромного service.
- `getLiveTeamAgentRuntimeMetadata()` сейчас читает tmux panes и process table внутри одного метода. После strict model там станет слишком много правил, поэтому план должен вынести pure resolution в отдельный helper/module.
@ -142,58 +142,21 @@ const MEMBER_BOOTSTRAP_STALL_MS = 5 * 60_000;
## Rollout mode
Строгая модель меняет поведение launch timeout, поэтому ее надо включать контролируемо.
Строгая модель меняет поведение launch timeout, поэтому изначальный план рассматривал rollout через отдельный флаг.
Текущая реализация после hardening включает strict liveness по умолчанию и не содержит старый переключатель режима.
Топ 3 rollout вариантов:
Актуальное поведение:
1. Diagnostics-only default, strict behind env flag
🎯 9 🛡️ 9 🧠 5 Примерно 80-140 строк.
По умолчанию UI получает новые diagnostics, но `runtimeAlive` behavior остается старым. Strict включается через env для dogfood. Это самый безопасный путь для первого PR.
| Area | Strict-only behavior |
| ------------------------------ | ---------------------------------------- |
| `livenessKind` | always filled when evidence exists |
| UI labels | enabled |
| `runtimeAlive` from shell-only | always false |
| `already_running` shortcut | waits for strong runtime verification |
| timeout self-heal | strong evidence only |
| launchDiagnostics | enabled for warning/error states |
2. Strict default сразу
🎯 6 🛡️ 6 🧠 4 Примерно 40-80 строк.
Быстрее исправляет проблему, но риск false negative выше, если реальные teammate processes не содержат ожидаемые identity args.
3. Полный app setting + env override
🎯 8 🛡️ 8 🧠 7 Примерно 180-260 строк.
Удобно для пользователей, но это больше surface area: settings UI, persistence, migration, tests. Лучше после dogfood данных.
Рекомендация: вариант 1.
Добавить mode resolver рядом с team runtime кодом:
```ts
export type TeamMemberLivenessMode = 'diagnostics' | 'strict';
export const CLAUDE_TEAM_MEMBER_LIVENESS_MODE_ENV = 'CLAUDE_TEAM_MEMBER_LIVENESS_MODE';
export function resolveTeamMemberLivenessModeFromEnv(
env: NodeJS.ProcessEnv = process.env
): TeamMemberLivenessMode {
const raw = env[CLAUDE_TEAM_MEMBER_LIVENESS_MODE_ENV]?.trim().toLowerCase();
if (raw === 'strict') return 'strict';
return 'diagnostics';
}
```
Behavior by mode:
| Area | `diagnostics` | `strict` |
| ------------------------------ | ---------------------------------------- | --------------------------- |
| `livenessKind` | filled | filled |
| UI labels | enabled | enabled |
| `runtimeAlive` from shell-only | old behavior may remain temporarily | always false |
| `already_running` shortcut | warning diagnostic, old fallback allowed | must verify strong evidence |
| timeout self-heal | old behavior | strong evidence only |
| launchDiagnostics | enabled | enabled |
Important default:
- In local dogfood, run with `CLAUDE_TEAM_MEMBER_LIVENESS_MODE=strict`.
- Production default can stay `diagnostics` for one release if Phase 0 data is unknown.
- After manual scenarios pass, flip default to `strict` and keep env as rollback: `CLAUDE_TEAM_MEMBER_LIVENESS_MODE=diagnostics`.
This gives an emergency fallback without reverting the UI diagnostics work.
Operational rollback должен быть отдельным code revert или follow-up setting, а не скрытым env-флагом.
## Structured launch diagnostics
@ -1251,7 +1214,7 @@ if (
Файл: `src/main/services/team/TeamProvisioningService.ts`
`handleMemberSpawnToolResult()` сейчас содержит shortcut:
`handleMemberSpawnToolResult()` раньше содержал shortcut:
```ts
if (parsedStatus.reason === 'already_running') {
@ -1261,23 +1224,19 @@ if (parsedStatus.reason === 'already_running') {
В strict liveness модели это опасно: `already_running` доказывает, что runtime/CLI отказался дублировать spawn, но не доказывает, что нужный teammate сейчас прошел bootstrap или что текущий pane PID является runtime процессом.
Новая логика:
Итоговая логика:
```ts
if (parsedStatus.reason === 'already_running') {
this.agentRuntimeSnapshotCache.delete(run.teamName);
this.liveTeamAgentRuntimeMetadataCache.delete(run.teamName);
const runtime = await this.findStrongRuntimeEvidenceForMember(run.teamName, spawnedMemberName);
if (isStrongRuntimeEvidence(runtime)) {
this.setMemberSpawnStatus(run, spawnedMemberName, 'online', undefined, 'process');
} else {
this.setMemberSpawnStatus(run, spawnedMemberName, 'waiting');
this.setMemberRuntimeDiagnostic(run, spawnedMemberName, {
livenessKind: runtime?.livenessKind ?? 'registered_only',
message: 'Runtime reported already running, but no verified member process was found yet.',
severity: 'warning',
});
}
this.setMemberSpawnStatus(run, spawnedMemberName, 'waiting');
this.appendMemberBootstrapDiagnostic(
run,
spawnedMemberName,
'already_running requires strong runtime verification'
);
void this.reevaluateMemberLaunchStatus(run, spawnedMemberName);
}
```
@ -1825,6 +1784,7 @@ export interface PersistedTeamLaunchSummary {
confirmedCount: number;
pendingCount: number;
failedCount: number;
// Compatibility aggregate only. Do not use as process evidence in UI.
runtimeAlivePendingCount: number;
shellOnlyPendingCount?: number;
runtimeProcessPendingCount?: number;
@ -1905,11 +1865,6 @@ Backend/shared:
- добавить компактные diagnostic fields в `MemberSpawnStatusEntry`.
- добавить bounded `TeamLaunchDiagnosticItem` и `TeamProvisioningProgress.launchDiagnostics`.
- `src/main/services/team/TeamMemberLivenessMode.ts`
- добавить `CLAUDE_TEAM_MEMBER_LIVENESS_MODE`;
- добавить resolver `diagnostics`/`strict`;
- использовать как dogfood/rollback lever.
- `src/main/services/team/TeamRuntimeLivenessResolver.ts`
- вынести pure liveness classification;
- принимать tmux/process/OpenCode/persisted facts;
@ -1928,7 +1883,6 @@ Backend/shared:
- расширить `LiveTeamAgentRuntimeMetadata`;
- parse sanitized runtime tool `metadata`;
- добавить strict evidence helpers;
- подключить `TeamMemberLivenessMode`;
- использовать `TeamRuntimeLivenessResolver`;
- обновить `updateProgress()` extras для `launchDiagnostics`;
- переписать tmux/process resolution;
@ -2005,11 +1959,6 @@ Renderer:
Backend:
- `TeamMemberLivenessMode.test.ts`
- default mode is `diagnostics`;
- `CLAUDE_TEAM_MEMBER_LIVENESS_MODE=strict` enables strict;
- unknown values fall back to `diagnostics`.
- `TeamRuntimeLivenessResolver.test.ts`
- tmux foreground shell + no child -> `shell_only`;
- verified process row by `--team-name` + `--agent-id` -> `runtime_process`;
@ -2088,9 +2037,9 @@ Renderer:
Add:
- `TeamMemberLivenessMode` with default `diagnostics`;
- `TeamRuntimeLivenessResolver` pure tests;
- process table/tmux providers, but strict behavior disabled by default.
- process table/tmux providers;
- strict-only runtime evidence flow without a runtime-mode switch.
Verification:
@ -2103,14 +2052,11 @@ pnpm exec vitest run test/main/features/tmux-installer test/main/services/team/T
🎯 9 🛡️ 9 🧠 7 Примерно 220-320 строк.
Переключить `attachLiveRuntimeMetadataToStatuses()` на strong evidence only when `CLAUDE_TEAM_MEMBER_LIVENESS_MODE=strict`. Shell/pane/candidate больше не выставляют `runtimeAlive` в strict mode.
Keep diagnostics mode as rollback until manual launch scenarios pass.
Переключить `attachLiveRuntimeMetadataToStatuses()` на strong evidence only. Shell/pane/candidate больше не выставляют `runtimeAlive`.
Verification:
```bash
CLAUDE_TEAM_MEMBER_LIVENESS_MODE=strict pnpm exec vitest run test/main/services/team/TeamProvisioningService.test.ts
pnpm exec vitest run test/main/services/team/TeamProvisioningService.test.ts
```
@ -2204,9 +2150,9 @@ Scenarios:
- `member-spawn` event refreshes runtime snapshot.
9. Rollout безопасен:
- default `diagnostics` mode не меняет hard timeout behavior до включения strict;
- `CLAUDE_TEAM_MEMBER_LIVENESS_MODE=strict` включает strong-only behavior;
- `CLAUDE_TEAM_MEMBER_LIVENESS_MODE=diagnostics` работает как rollback без удаления UI diagnostics.
- strict behavior включен по умолчанию;
- diagnostics UI остается доступным без отдельного mode flag;
- rollback требует явного code revert или отдельного follow-up setting.
10. Provider failures не создают ложный ready:
@ -2282,22 +2228,21 @@ Mitigation:
## Minimal safe patch order
1. Добавить типы и optional fields.
2. Добавить `TeamMemberLivenessMode` default `diagnostics`.
3. Добавить sanitized runtime tool metadata parser.
4. Добавить tmux `listPaneRuntimeInfo()` и сохранить wrapper `listPanePids()`.
5. Добавить process table provider/parser с `ppid`.
6. Вынести `TeamRuntimeLivenessResolver`.
7. Заполнить `livenessKind` без behavior change.
8. Написать backend tests на shell-only, verified runtime, stale event, metadata PID.
9. Переключить `attachLiveRuntimeMetadataToStatuses()` на strong evidence behind strict mode.
10. Исправить `already_running` shortcut behind strict mode.
11. Переключить timeout/self-heal logic behind strict mode.
12. Исправить OpenCode bridge mapping.
13. Обновить persisted summary diagnostics и store equality.
14. Добавить `launchDiagnostics` в progress payload и UI disclosure.
15. Добавить renderer labels/tooltips/banner.
16. Добавить copy diagnostics.
17. После manual validation включить strict default или оставить env rollback на один release.
2. Добавить sanitized runtime tool metadata parser.
3. Добавить tmux `listPaneRuntimeInfo()` и сохранить wrapper `listPanePids()`.
4. Добавить process table provider/parser с `ppid`.
5. Вынести `TeamRuntimeLivenessResolver`.
6. Заполнить `livenessKind`.
7. Написать backend tests на shell-only, verified runtime, stale event, metadata PID.
8. Переключить `attachLiveRuntimeMetadataToStatuses()` на strong evidence.
9. Исправить `already_running` shortcut.
10. Переключить timeout/self-heal logic на strong evidence.
11. Исправить OpenCode bridge mapping.
12. Обновить persisted summary diagnostics и store equality.
13. Добавить `launchDiagnostics` в progress payload и UI disclosure.
14. Добавить renderer labels/tooltips/banner.
15. Добавить copy diagnostics.
16. Manual validation: создать команду, проверить pending names, runtime diagnostics и отсутствие false-ready shell-only процесса.
## Expected UX

View file

@ -3,6 +3,7 @@ import { z } from 'zod';
import { getController } from '../controller';
import { jsonTextContent } from '../utils/format';
import { assertConfiguredTeam } from '../utils/teamConfig';
const toolContextSchema = {
teamName: z.string().min(1),
@ -34,8 +35,9 @@ export function registerCrossTeamTools(server: Pick<FastMCP, 'addTool'>) {
conversationId,
replyToConversationId,
chainDepth,
}) =>
await Promise.resolve(
}) => {
assertConfiguredTeam(teamName, claudeDir);
return await Promise.resolve(
jsonTextContent(
getController(teamName, claudeDir).crossTeam.sendCrossTeamMessage({
toTeam,
@ -47,7 +49,8 @@ export function registerCrossTeamTools(server: Pick<FastMCP, 'addTool'>) {
...(chainDepth !== undefined ? { chainDepth } : {}),
})
)
),
);
},
});
server.addTool({
@ -57,14 +60,16 @@ export function registerCrossTeamTools(server: Pick<FastMCP, 'addTool'>) {
...toolContextSchema,
excludeTeam: z.string().optional(),
}),
execute: async ({ teamName, claudeDir, excludeTeam }) =>
await Promise.resolve(
execute: async ({ teamName, claudeDir, excludeTeam }) => {
assertConfiguredTeam(teamName, claudeDir);
return await Promise.resolve(
jsonTextContent(
getController(teamName, claudeDir).crossTeam.listCrossTeamTargets({
...(excludeTeam ? { excludeTeam } : {}),
})
)
),
);
},
});
server.addTool({
@ -73,9 +78,11 @@ export function registerCrossTeamTools(server: Pick<FastMCP, 'addTool'>) {
parameters: z.object({
...toolContextSchema,
}),
execute: async ({ teamName, claudeDir }) =>
await Promise.resolve(
execute: async ({ teamName, claudeDir }) => {
assertConfiguredTeam(teamName, claudeDir);
return await Promise.resolve(
jsonTextContent(getController(teamName, claudeDir).crossTeam.getCrossTeamOutbox())
),
);
},
});
}

View file

@ -3,6 +3,7 @@ import { z } from 'zod';
import { getController } from '../controller';
import { jsonTextContent } from '../utils/format';
import { assertConfiguredTeam } from '../utils/teamConfig';
const toolContextSchema = {
teamName: z.string().min(1),
@ -16,8 +17,10 @@ export function registerKanbanTools(server: Pick<FastMCP, 'addTool'>) {
parameters: z.object({
...toolContextSchema,
}),
execute: async ({ teamName, claudeDir }) =>
await Promise.resolve(jsonTextContent(getController(teamName, claudeDir).kanban.getKanbanState())),
execute: async ({ teamName, claudeDir }) => {
assertConfiguredTeam(teamName, claudeDir);
return await Promise.resolve(jsonTextContent(getController(teamName, claudeDir).kanban.getKanbanState()));
},
});
server.addTool({
@ -29,10 +32,12 @@ export function registerKanbanTools(server: Pick<FastMCP, 'addTool'>) {
taskId: z.string().min(1),
column: z.enum(['review', 'approved']),
}),
execute: async ({ teamName, claudeDir, taskId, column }) =>
await Promise.resolve(
execute: async ({ teamName, claudeDir, taskId, column }) => {
assertConfiguredTeam(teamName, claudeDir);
return await Promise.resolve(
jsonTextContent(getController(teamName, claudeDir).kanban.setKanbanColumn(taskId, column))
),
);
},
});
server.addTool({
@ -43,8 +48,10 @@ export function registerKanbanTools(server: Pick<FastMCP, 'addTool'>) {
...toolContextSchema,
taskId: z.string().min(1),
}),
execute: async ({ teamName, claudeDir, taskId }) =>
await Promise.resolve(jsonTextContent(getController(teamName, claudeDir).kanban.clearKanban(taskId))),
execute: async ({ teamName, claudeDir, taskId }) => {
assertConfiguredTeam(teamName, claudeDir);
return await Promise.resolve(jsonTextContent(getController(teamName, claudeDir).kanban.clearKanban(taskId)));
},
});
server.addTool({
@ -53,8 +60,10 @@ export function registerKanbanTools(server: Pick<FastMCP, 'addTool'>) {
parameters: z.object({
...toolContextSchema,
}),
execute: async ({ teamName, claudeDir }) =>
await Promise.resolve(jsonTextContent(getController(teamName, claudeDir).kanban.listReviewers())),
execute: async ({ teamName, claudeDir }) => {
assertConfiguredTeam(teamName, claudeDir);
return await Promise.resolve(jsonTextContent(getController(teamName, claudeDir).kanban.listReviewers()));
},
});
server.addTool({
@ -64,8 +73,10 @@ export function registerKanbanTools(server: Pick<FastMCP, 'addTool'>) {
...toolContextSchema,
reviewer: z.string().min(1),
}),
execute: async ({ teamName, claudeDir, reviewer }) =>
await Promise.resolve(jsonTextContent(getController(teamName, claudeDir).kanban.addReviewer(reviewer))),
execute: async ({ teamName, claudeDir, reviewer }) => {
assertConfiguredTeam(teamName, claudeDir);
return await Promise.resolve(jsonTextContent(getController(teamName, claudeDir).kanban.addReviewer(reviewer)));
},
});
server.addTool({
@ -75,9 +86,11 @@ export function registerKanbanTools(server: Pick<FastMCP, 'addTool'>) {
...toolContextSchema,
reviewer: z.string().min(1),
}),
execute: async ({ teamName, claudeDir, reviewer }) =>
await Promise.resolve(
execute: async ({ teamName, claudeDir, reviewer }) => {
assertConfiguredTeam(teamName, claudeDir);
return await Promise.resolve(
jsonTextContent(getController(teamName, claudeDir).kanban.removeReviewer(reviewer))
),
);
},
});
}

View file

@ -2,6 +2,7 @@ import type { FastMCP } from 'fastmcp';
import { z } from 'zod';
import { getController } from '../controller';
import { assertConfiguredTeam } from '../utils/teamConfig';
const toolContextSchema = {
teamName: z.string().min(1),
@ -20,13 +21,16 @@ export function registerLeadTools(server: Pick<FastMCP, 'addTool'>) {
parameters: z.object({
...toolContextSchema,
}),
execute: async ({ teamName, claudeDir }) => ({
content: [
{
type: 'text' as const,
text: await getController(teamName, claudeDir).tasks.leadBriefing(),
},
],
}),
execute: async ({ teamName, claudeDir }) => {
assertConfiguredTeam(teamName, claudeDir);
return {
content: [
{
type: 'text' as const,
text: await getController(teamName, claudeDir).tasks.leadBriefing(),
},
],
};
},
});
}

View file

@ -2,6 +2,7 @@ import type { FastMCP } from 'fastmcp';
import { z } from 'zod';
import { getController } from '../controller';
import { assertConfiguredTeam } from '../utils/teamConfig';
import { jsonTextContent } from '../utils/format';
const toolContextSchema = {
@ -42,8 +43,9 @@ export function registerMessageTools(server: Pick<FastMCP, 'addTool'>) {
source,
leadSessionId,
attachments,
}) =>
await Promise.resolve(
}) => {
assertConfiguredTeam(teamName, claudeDir);
return await Promise.resolve(
jsonTextContent(
getController(teamName, claudeDir).messages.sendMessage({
to,
@ -55,6 +57,7 @@ export function registerMessageTools(server: Pick<FastMCP, 'addTool'>) {
...(attachments?.length ? { attachments } : {}),
})
)
),
);
},
});
}

View file

@ -3,6 +3,7 @@ import { z } from 'zod';
import { getController } from '../controller';
import { jsonTextContent } from '../utils/format';
import { assertConfiguredTeam } from '../utils/teamConfig';
const toolContextSchema = {
teamName: z.string().min(1),
@ -34,8 +35,9 @@ export function registerProcessTools(server: Pick<FastMCP, 'addTool'>) {
port,
url,
claudeProcessId,
}) =>
await Promise.resolve(
}) => {
assertConfiguredTeam(teamName, claudeDir);
return await Promise.resolve(
jsonTextContent(
getController(teamName, claudeDir).processes.registerProcess({
pid,
@ -47,7 +49,8 @@ export function registerProcessTools(server: Pick<FastMCP, 'addTool'>) {
...(claudeProcessId ? { 'claude-process-id': claudeProcessId } : {}),
})
)
),
);
},
});
server.addTool({
@ -57,10 +60,12 @@ export function registerProcessTools(server: Pick<FastMCP, 'addTool'>) {
parameters: z.object({
...toolContextSchema,
}),
execute: async ({ teamName, claudeDir }) =>
await Promise.resolve(
execute: async ({ teamName, claudeDir }) => {
assertConfiguredTeam(teamName, claudeDir);
return await Promise.resolve(
jsonTextContent(getController(teamName, claudeDir).processes.listProcesses())
),
);
},
});
server.addTool({
@ -71,10 +76,12 @@ export function registerProcessTools(server: Pick<FastMCP, 'addTool'>) {
...toolContextSchema,
pid: z.number().int().positive(),
}),
execute: async ({ teamName, claudeDir, pid }) =>
await Promise.resolve(
execute: async ({ teamName, claudeDir, pid }) => {
assertConfiguredTeam(teamName, claudeDir);
return await Promise.resolve(
jsonTextContent(getController(teamName, claudeDir).processes.unregisterProcess({ pid }))
),
);
},
});
server.addTool({
@ -85,9 +92,11 @@ export function registerProcessTools(server: Pick<FastMCP, 'addTool'>) {
...toolContextSchema,
pid: z.number().int().positive(),
}),
execute: async ({ teamName, claudeDir, pid }) =>
await Promise.resolve(
execute: async ({ teamName, claudeDir, pid }) => {
assertConfiguredTeam(teamName, claudeDir);
return await Promise.resolve(
jsonTextContent(getController(teamName, claudeDir).processes.stopProcess({ pid }))
),
);
},
});
}

View file

@ -3,6 +3,7 @@ import { z } from 'zod';
import { getController } from '../controller';
import { jsonTextContent, slimTask } from '../utils/format';
import { assertConfiguredTeam } from '../utils/teamConfig';
const toolContextSchema = {
teamName: z.string().min(1),
@ -20,8 +21,9 @@ export function registerReviewTools(server: Pick<FastMCP, 'addTool'>) {
reviewer: z.string().optional(),
leadSessionId: z.string().optional(),
}),
execute: async ({ teamName, claudeDir, taskId, from, reviewer, leadSessionId }) =>
await Promise.resolve(
execute: async ({ teamName, claudeDir, taskId, from, reviewer, leadSessionId }) => {
assertConfiguredTeam(teamName, claudeDir);
return await Promise.resolve(
jsonTextContent(
slimTask(
getController(teamName, claudeDir).review.requestReview(taskId, {
@ -31,7 +33,8 @@ export function registerReviewTools(server: Pick<FastMCP, 'addTool'>) {
}) as Record<string, unknown>
)
)
),
);
},
});
server.addTool({
@ -42,14 +45,16 @@ export function registerReviewTools(server: Pick<FastMCP, 'addTool'>) {
taskId: z.string().min(1),
from: z.string().optional(),
}),
execute: async ({ teamName, claudeDir, taskId, from }) =>
await Promise.resolve(
execute: async ({ teamName, claudeDir, taskId, from }) => {
assertConfiguredTeam(teamName, claudeDir);
return await Promise.resolve(
jsonTextContent(
getController(teamName, claudeDir).review.startReview(taskId, {
...(from ? { from } : {}),
}) as Record<string, unknown>
)
),
);
},
});
server.addTool({
@ -63,8 +68,9 @@ export function registerReviewTools(server: Pick<FastMCP, 'addTool'>) {
notifyOwner: z.boolean().optional(),
leadSessionId: z.string().optional(),
}),
execute: async ({ teamName, claudeDir, taskId, from, note, notifyOwner, leadSessionId }) =>
await Promise.resolve(
execute: async ({ teamName, claudeDir, taskId, from, note, notifyOwner, leadSessionId }) => {
assertConfiguredTeam(teamName, claudeDir);
return await Promise.resolve(
jsonTextContent(
slimTask(
getController(teamName, claudeDir).review.approveReview(taskId, {
@ -75,7 +81,8 @@ export function registerReviewTools(server: Pick<FastMCP, 'addTool'>) {
}) as Record<string, unknown>
)
)
),
);
},
});
server.addTool({
@ -88,8 +95,9 @@ export function registerReviewTools(server: Pick<FastMCP, 'addTool'>) {
comment: z.string().optional(),
leadSessionId: z.string().optional(),
}),
execute: async ({ teamName, claudeDir, taskId, from, comment, leadSessionId }) =>
await Promise.resolve(
execute: async ({ teamName, claudeDir, taskId, from, comment, leadSessionId }) => {
assertConfiguredTeam(teamName, claudeDir);
return await Promise.resolve(
jsonTextContent(
slimTask(
getController(teamName, claudeDir).review.requestChanges(taskId, {
@ -99,6 +107,7 @@ export function registerReviewTools(server: Pick<FastMCP, 'addTool'>) {
}) as Record<string, unknown>
)
)
),
);
},
});
}

View file

@ -3,6 +3,7 @@ import { z } from 'zod';
import { getController } from '../controller';
import { jsonTextContent } from '../utils/format';
import { assertConfiguredTeam } from '../utils/teamConfig';
const toolContextSchema = {
teamName: z.string().min(1),
@ -57,8 +58,9 @@ export function registerRuntimeTools(server: Pick<FastMCP, 'addTool'>) {
worktree,
extraCliArgs,
waitForReady,
}) =>
jsonTextContent(
}) => {
assertConfiguredTeam(teamName, claudeDir);
return jsonTextContent(
await getController(teamName, claudeDir).runtime.launchTeam({
cwd,
...(prompt ? { prompt } : {}),
@ -72,7 +74,8 @@ export function registerRuntimeTools(server: Pick<FastMCP, 'addTool'>) {
...(waitTimeoutMs ? { waitTimeoutMs } : {}),
...(waitForReady !== undefined ? { waitForReady } : {}),
})
),
);
},
});
server.addTool({
@ -82,14 +85,16 @@ export function registerRuntimeTools(server: Pick<FastMCP, 'addTool'>) {
...toolContextSchema,
waitForStop: z.boolean().optional(),
}),
execute: async ({ teamName, claudeDir, controlUrl, waitTimeoutMs, waitForStop }) =>
jsonTextContent(
execute: async ({ teamName, claudeDir, controlUrl, waitTimeoutMs, waitForStop }) => {
assertConfiguredTeam(teamName, claudeDir);
return jsonTextContent(
await getController(teamName, claudeDir).runtime.stopTeam({
...(controlUrl ? { controlUrl } : {}),
...(waitTimeoutMs ? { waitTimeoutMs } : {}),
...(waitForStop !== undefined ? { waitForStop } : {}),
})
),
);
},
});
server.addTool({
@ -112,8 +117,9 @@ export function registerRuntimeTools(server: Pick<FastMCP, 'addTool'>) {
observedAt,
diagnostics,
metadata,
}) =>
jsonTextContent(
}) => {
assertConfiguredTeam(teamName, claudeDir);
return jsonTextContent(
await getController(teamName, claudeDir).runtime.runtimeBootstrapCheckin({
runId,
memberName,
@ -124,7 +130,8 @@ export function registerRuntimeTools(server: Pick<FastMCP, 'addTool'>) {
...(controlUrl ? { controlUrl } : {}),
...(waitTimeoutMs ? { waitTimeoutMs } : {}),
})
),
);
},
});
server.addTool({
@ -156,8 +163,9 @@ export function registerRuntimeTools(server: Pick<FastMCP, 'addTool'>) {
createdAt,
summary,
taskRefs,
}) =>
jsonTextContent(
}) => {
assertConfiguredTeam(teamName, claudeDir);
return jsonTextContent(
await getController(teamName, claudeDir).runtime.runtimeDeliverMessage({
idempotencyKey,
runId,
@ -171,7 +179,8 @@ export function registerRuntimeTools(server: Pick<FastMCP, 'addTool'>) {
...(controlUrl ? { controlUrl } : {}),
...(waitTimeoutMs ? { waitTimeoutMs } : {}),
})
),
);
},
});
server.addTool({
@ -203,8 +212,9 @@ export function registerRuntimeTools(server: Pick<FastMCP, 'addTool'>) {
createdAt,
summary,
metadata,
}) =>
jsonTextContent(
}) => {
assertConfiguredTeam(teamName, claudeDir);
return jsonTextContent(
await getController(teamName, claudeDir).runtime.runtimeTaskEvent({
idempotencyKey,
runId,
@ -218,7 +228,8 @@ export function registerRuntimeTools(server: Pick<FastMCP, 'addTool'>) {
...(controlUrl ? { controlUrl } : {}),
...(waitTimeoutMs ? { waitTimeoutMs } : {}),
})
),
);
},
});
server.addTool({
@ -241,8 +252,9 @@ export function registerRuntimeTools(server: Pick<FastMCP, 'addTool'>) {
observedAt,
status,
metadata,
}) =>
jsonTextContent(
}) => {
assertConfiguredTeam(teamName, claudeDir);
return jsonTextContent(
await getController(teamName, claudeDir).runtime.runtimeHeartbeat({
runId,
memberName,
@ -253,6 +265,7 @@ export function registerRuntimeTools(server: Pick<FastMCP, 'addTool'>) {
...(controlUrl ? { controlUrl } : {}),
...(waitTimeoutMs ? { waitTimeoutMs } : {}),
})
),
);
},
});
}

View file

@ -1,9 +1,8 @@
import type { FastMCP } from 'fastmcp';
import fs from 'node:fs';
import path from 'node:path';
import { z } from 'zod';
import { agentBlocks, getController } from '../controller';
import { assertConfiguredTeam } from '../utils/teamConfig';
import { jsonTextContent, taskWriteResult, slimTask } from '../utils/format';
/** stripAgentBlocks from canonical agentBlocks module — single source of truth for the tag format. */
@ -70,42 +69,6 @@ function buildCreateTaskPayload(params: {
};
}
function resolveConfigPath(teamName: string, claudeDir?: string): string {
const controller = getController(teamName, claudeDir) as {
context?: { paths?: { teamDir?: string } };
};
const teamDir = controller.context?.paths?.teamDir;
if (typeof teamDir !== 'string' || teamDir.trim().length === 0) {
throw new Error(
`Unknown team "${teamName}". Board tools require an existing configured team with config.json. Use the real board teamName from durable team context - never use a member or lead name as teamName.`
);
}
return path.join(teamDir, 'config.json');
}
function assertConfiguredTeam(teamName: string, claudeDir?: string): void {
const configPath = resolveConfigPath(teamName, claudeDir);
let raw = '';
try {
raw = fs.readFileSync(configPath, 'utf8');
} catch {
throw new Error(
`Unknown team "${teamName}". Board tools require an existing configured team with config.json. Use the real board teamName from durable team context - never use a member or lead name as teamName.`
);
}
try {
const parsed = JSON.parse(raw) as { name?: unknown };
if (typeof parsed?.name !== 'string' || parsed.name.trim().length === 0) {
throw new Error('invalid');
}
} catch {
throw new Error(
`Unknown team "${teamName}". Board tools require an existing configured team with config.json. Use the real board teamName from durable team context - never use a member or lead name as teamName.`
);
}
}
export function registerTaskTools(server: Pick<FastMCP, 'addTool'>) {
server.addTool({
name: 'task_create',
@ -288,8 +251,12 @@ export function registerTaskTools(server: Pick<FastMCP, 'addTool'>) {
...toolContextSchema,
taskId: z.string().min(1),
}),
execute: async ({ teamName, claudeDir, taskId }) =>
await Promise.resolve(jsonTextContent(getController(teamName, claudeDir).tasks.getTask(taskId))),
execute: async ({ teamName, claudeDir, taskId }) => {
assertConfiguredTeam(teamName, claudeDir);
return await Promise.resolve(
jsonTextContent(getController(teamName, claudeDir).tasks.getTask(taskId))
);
},
});
server.addTool({
@ -301,12 +268,12 @@ export function registerTaskTools(server: Pick<FastMCP, 'addTool'>) {
taskId: z.string().min(1),
commentId: z.string().min(1),
}),
execute: async ({ teamName, claudeDir, taskId, commentId }) =>
await Promise.resolve(
jsonTextContent(
getController(teamName, claudeDir).tasks.getTaskComment(taskId, commentId)
)
),
execute: async ({ teamName, claudeDir, taskId, commentId }) => {
assertConfiguredTeam(teamName, claudeDir);
return await Promise.resolve(
jsonTextContent(getController(teamName, claudeDir).tasks.getTaskComment(taskId, commentId))
);
},
});
server.addTool({
@ -333,8 +300,9 @@ export function registerTaskTools(server: Pick<FastMCP, 'addTool'>) {
relatedTo,
blockedBy,
limit,
}) =>
await Promise.resolve(
}) => {
assertConfiguredTeam(teamName, claudeDir);
return await Promise.resolve(
jsonTextContent(
getController(teamName, claudeDir).tasks.listTaskInventory({
...(owner ? { owner } : {}),
@ -346,7 +314,8 @@ export function registerTaskTools(server: Pick<FastMCP, 'addTool'>) {
limit: normalizeTaskListLimit(limit),
})
)
),
);
},
});
server.addTool({
@ -358,10 +327,42 @@ export function registerTaskTools(server: Pick<FastMCP, 'addTool'>) {
status: z.enum(['pending', 'in_progress', 'completed', 'deleted']),
actor: z.string().optional(),
}),
execute: async ({ teamName, claudeDir, taskId, status, actor }) =>
await Promise.resolve(
jsonTextContent(slimTask(getController(teamName, claudeDir).tasks.setTaskStatus(taskId, status, actor) as Record<string, unknown>))
),
execute: async ({ teamName, claudeDir, taskId, status, actor }) => {
assertConfiguredTeam(teamName, claudeDir);
return await Promise.resolve(
jsonTextContent(
slimTask(
getController(teamName, claudeDir).tasks.setTaskStatus(taskId, status, actor) as Record<
string,
unknown
>
)
)
);
},
});
server.addTool({
name: 'task_restore',
description: 'Restore a deleted task back to pending work state',
parameters: z.object({
...toolContextSchema,
taskId: z.string().min(1),
actor: z.string().optional(),
}),
execute: async ({ teamName, claudeDir, taskId, actor }) => {
assertConfiguredTeam(teamName, claudeDir);
return await Promise.resolve(
jsonTextContent(
slimTask(
getController(teamName, claudeDir).tasks.restoreTask(taskId, actor) as Record<
string,
unknown
>
)
)
);
},
});
server.addTool({
@ -372,8 +373,19 @@ export function registerTaskTools(server: Pick<FastMCP, 'addTool'>) {
taskId: z.string().min(1),
actor: z.string().optional(),
}),
execute: async ({ teamName, claudeDir, taskId, actor }) =>
await Promise.resolve(jsonTextContent(slimTask(getController(teamName, claudeDir).tasks.startTask(taskId, actor) as Record<string, unknown>))),
execute: async ({ teamName, claudeDir, taskId, actor }) => {
assertConfiguredTeam(teamName, claudeDir);
return await Promise.resolve(
jsonTextContent(
slimTask(
getController(teamName, claudeDir).tasks.startTask(taskId, actor) as Record<
string,
unknown
>
)
)
);
},
});
server.addTool({
@ -384,10 +396,19 @@ export function registerTaskTools(server: Pick<FastMCP, 'addTool'>) {
taskId: z.string().min(1),
actor: z.string().optional(),
}),
execute: async ({ teamName, claudeDir, taskId, actor }) =>
await Promise.resolve(
jsonTextContent(slimTask(getController(teamName, claudeDir).tasks.completeTask(taskId, actor) as Record<string, unknown>))
),
execute: async ({ teamName, claudeDir, taskId, actor }) => {
assertConfiguredTeam(teamName, claudeDir);
return await Promise.resolve(
jsonTextContent(
slimTask(
getController(teamName, claudeDir).tasks.completeTask(taskId, actor) as Record<
string,
unknown
>
)
)
);
},
});
server.addTool({
@ -398,10 +419,19 @@ export function registerTaskTools(server: Pick<FastMCP, 'addTool'>) {
taskId: z.string().min(1),
owner: z.string().nullable(),
}),
execute: async ({ teamName, claudeDir, taskId, owner }) =>
await Promise.resolve(
jsonTextContent(slimTask(getController(teamName, claudeDir).tasks.setTaskOwner(taskId, owner) as Record<string, unknown>))
),
execute: async ({ teamName, claudeDir, taskId, owner }) => {
assertConfiguredTeam(teamName, claudeDir);
return await Promise.resolve(
jsonTextContent(
slimTask(
getController(teamName, claudeDir).tasks.setTaskOwner(taskId, owner) as Record<
string,
unknown
>
)
)
);
},
});
server.addTool({
@ -413,17 +443,19 @@ export function registerTaskTools(server: Pick<FastMCP, 'addTool'>) {
text: z.string().min(1),
from: z.string().optional(),
}),
execute: async ({ teamName, claudeDir, taskId, text, from }) =>
await Promise.resolve(
execute: async ({ teamName, claudeDir, taskId, text, from }) => {
assertConfiguredTeam(teamName, claudeDir);
return await Promise.resolve(
jsonTextContent(
taskWriteResult(
getController(teamName, claudeDir).tasks.addTaskComment(taskId, {
text,
...(from ? { from } : {}),
text,
...(from ? { from } : {}),
}) as Record<string, unknown>
)
)
),
);
},
});
server.addTool({
@ -448,20 +480,22 @@ export function registerTaskTools(server: Pick<FastMCP, 'addTool'>) {
filename,
mimeType,
noFallback,
}) =>
await Promise.resolve(
}) => {
assertConfiguredTeam(teamName, claudeDir);
return await Promise.resolve(
jsonTextContent(
taskWriteResult(
getController(teamName, claudeDir).tasks.attachTaskFile(taskId, {
file: filePath,
...(mode ? { mode } : {}),
...(filename ? { filename } : {}),
...(mimeType ? { 'mime-type': mimeType } : {}),
...(noFallback ? { 'no-fallback': true } : {}),
file: filePath,
...(mode ? { mode } : {}),
...(filename ? { filename } : {}),
...(mimeType ? { 'mime-type': mimeType } : {}),
...(noFallback ? { 'no-fallback': true } : {}),
}) as Record<string, unknown>
)
)
),
);
},
});
server.addTool({
@ -488,20 +522,22 @@ export function registerTaskTools(server: Pick<FastMCP, 'addTool'>) {
filename,
mimeType,
noFallback,
}) =>
await Promise.resolve(
}) => {
assertConfiguredTeam(teamName, claudeDir);
return await Promise.resolve(
jsonTextContent(
taskWriteResult(
getController(teamName, claudeDir).tasks.attachCommentFile(taskId, commentId, {
file: filePath,
...(mode ? { mode } : {}),
...(filename ? { filename } : {}),
...(mimeType ? { 'mime-type': mimeType } : {}),
...(noFallback ? { 'no-fallback': true } : {}),
file: filePath,
...(mode ? { mode } : {}),
...(filename ? { filename } : {}),
...(mimeType ? { 'mime-type': mimeType } : {}),
...(noFallback ? { 'no-fallback': true } : {}),
}) as Record<string, unknown>
)
)
),
);
},
});
server.addTool({
@ -512,17 +548,19 @@ export function registerTaskTools(server: Pick<FastMCP, 'addTool'>) {
taskId: z.string().min(1),
value: z.enum(['lead', 'user', 'clear']),
}),
execute: async ({ teamName, claudeDir, taskId, value }) =>
await Promise.resolve(
execute: async ({ teamName, claudeDir, taskId, value }) => {
assertConfiguredTeam(teamName, claudeDir);
return await Promise.resolve(
jsonTextContent(
slimTask(
getController(teamName, claudeDir).tasks.setNeedsClarification(
taskId,
value === 'clear' ? null : value
taskId,
value === 'clear' ? null : value
) as Record<string, unknown>
)
)
),
);
},
});
server.addTool({
@ -534,10 +572,20 @@ export function registerTaskTools(server: Pick<FastMCP, 'addTool'>) {
targetId: z.string().min(1),
relationship: relationshipTypeSchema,
}),
execute: async ({ teamName, claudeDir, taskId, targetId, relationship }) =>
await Promise.resolve(
jsonTextContent(slimTask(getController(teamName, claudeDir).tasks.linkTask(taskId, targetId, relationship) as Record<string, unknown>))
),
execute: async ({ teamName, claudeDir, taskId, targetId, relationship }) => {
assertConfiguredTeam(teamName, claudeDir);
return await Promise.resolve(
jsonTextContent(
slimTask(
getController(teamName, claudeDir).tasks.linkTask(
taskId,
targetId,
relationship
) as Record<string, unknown>
)
)
);
},
});
server.addTool({
@ -549,12 +597,20 @@ export function registerTaskTools(server: Pick<FastMCP, 'addTool'>) {
targetId: z.string().min(1),
relationship: relationshipTypeSchema,
}),
execute: async ({ teamName, claudeDir, taskId, targetId, relationship }) =>
await Promise.resolve(
execute: async ({ teamName, claudeDir, taskId, targetId, relationship }) => {
assertConfiguredTeam(teamName, claudeDir);
return await Promise.resolve(
jsonTextContent(
slimTask(getController(teamName, claudeDir).tasks.unlinkTask(taskId, targetId, relationship) as Record<string, unknown>)
slimTask(
getController(teamName, claudeDir).tasks.unlinkTask(
taskId,
targetId,
relationship
) as Record<string, unknown>
)
)
),
);
},
});
server.addTool({
@ -565,14 +621,17 @@ export function registerTaskTools(server: Pick<FastMCP, 'addTool'>) {
...toolContextSchema,
memberName: z.string().min(1),
}),
execute: async ({ teamName, claudeDir, memberName }) => ({
content: [
{
type: 'text' as const,
text: await getController(teamName, claudeDir).tasks.memberBriefing(memberName),
},
],
}),
execute: async ({ teamName, claudeDir, memberName }) => {
assertConfiguredTeam(teamName, claudeDir);
return {
content: [
{
type: 'text' as const,
text: await getController(teamName, claudeDir).tasks.memberBriefing(memberName),
},
],
};
},
});
server.addTool({
@ -582,13 +641,16 @@ export function registerTaskTools(server: Pick<FastMCP, 'addTool'>) {
...toolContextSchema,
memberName: z.string().min(1),
}),
execute: async ({ teamName, claudeDir, memberName }) => ({
content: [
{
type: 'text' as const,
text: await getController(teamName, claudeDir).tasks.taskBriefing(memberName),
},
],
}),
execute: async ({ teamName, claudeDir, memberName }) => {
assertConfiguredTeam(teamName, claudeDir);
return {
content: [
{
type: 'text' as const,
text: await getController(teamName, claudeDir).tasks.taskBriefing(memberName),
},
],
};
},
});
}

View file

@ -0,0 +1,40 @@
import fs from 'node:fs';
import path from 'node:path';
import { getController } from '../controller';
function resolveConfigPath(teamName: string, claudeDir?: string): string {
const controller = getController(teamName, claudeDir) as {
context?: { paths?: { teamDir?: string } };
};
const teamDir = controller.context?.paths?.teamDir;
if (typeof teamDir !== 'string' || teamDir.trim().length === 0) {
throw new Error(
`Unknown team "${teamName}". Board tools require an existing configured team with config.json. Use the real board teamName from durable team context - never use a member or lead name as teamName.`
);
}
return path.join(teamDir, 'config.json');
}
export function assertConfiguredTeam(teamName: string, claudeDir?: string): void {
const configPath = resolveConfigPath(teamName, claudeDir);
let raw = '';
try {
raw = fs.readFileSync(configPath, 'utf8');
} catch {
throw new Error(
`Unknown team "${teamName}". Board tools require an existing configured team with config.json. Use the real board teamName from durable team context - never use a member or lead name as teamName.`
);
}
try {
const parsed = JSON.parse(raw) as { name?: unknown };
if (typeof parsed?.name !== 'string' || parsed.name.trim().length === 0) {
throw new Error('invalid');
}
} catch {
throw new Error(
`Unknown team "${teamName}". Board tools require an existing configured team with config.json. Use the real board teamName from durable team context - never use a member or lead name as teamName.`
);
}
}

View file

@ -487,6 +487,52 @@ describe('agent-teams-mcp stdio e2e', () => {
}
});
it('fails closed for primary queue and inventory tools when team config is missing over stdio', async () => {
const client = new McpStdIoClient(serverPath, workspaceRoot);
const expected =
'Unknown team "team-lead". Board tools require an existing configured team with config.json.';
try {
await client.initialize();
const leadBriefing = (await client.callTool(
'lead_briefing',
{
claudeDir,
teamName: 'team-lead',
},
40
)) as { result?: { isError?: boolean; content?: Array<{ text?: string }> } };
expect(leadBriefing.result?.isError).toBe(true);
expect(leadBriefing.result?.content?.[0]?.text).toContain(expected);
const taskBriefing = (await client.callTool(
'task_briefing',
{
claudeDir,
teamName: 'team-lead',
memberName: 'alice',
},
41
)) as { result?: { isError?: boolean; content?: Array<{ text?: string }> } };
expect(taskBriefing.result?.isError).toBe(true);
expect(taskBriefing.result?.content?.[0]?.text).toContain(expected);
const taskList = (await client.callTool(
'task_list',
{
claudeDir,
teamName: 'team-lead',
},
42
)) as { result?: { isError?: boolean; content?: Array<{ text?: string }> } };
expect(taskList.result?.isError).toBe(true);
expect(taskList.result?.content?.[0]?.text).toContain(expected);
} finally {
await client.close();
}
});
it('caps high-volume task_list inventory over stdio and keeps rows compact', async () => {
await writeTeamConfig(claudeDir, 'bulk-inventory-team');
await writeBulkTaskRows(claudeDir, 'bulk-inventory-team', 225);
@ -1909,6 +1955,38 @@ describe('agent-teams-mcp stdio e2e', () => {
const startDeletedErrorText =
startDeletedResponse.error?.message ?? (startDeletedResponse.result?.content?.[0]?.text ?? '');
expect(startDeletedErrorText).toContain('use task_restore before starting work');
const restoreResult = await client.callTool(
'task_restore',
{
claudeDir,
teamName: 'stdio-hardening-team',
taskId: task.id,
actor: 'team-lead',
},
110
);
const restored = parseJsonToolResult((restoreResult as { result: unknown }).result);
expect(restored.status).toBe('pending');
expect(restored.reviewState).toBe('none');
const restoreAgainResult = await client.callTool(
'task_restore',
{
claudeDir,
teamName: 'stdio-hardening-team',
taskId: task.id,
actor: 'team-lead',
},
111
);
const restoreAgainResponse = restoreAgainResult as {
error?: { message?: string };
result?: { content?: Array<{ text?: string }> };
};
const restoreAgainErrorText =
restoreAgainResponse.error?.message ?? (restoreAgainResponse.result?.content?.[0]?.text ?? '');
expect(restoreAgainErrorText).toContain('task_restore only restores deleted tasks');
} finally {
await client.close();
}

View file

@ -88,7 +88,9 @@ describe('agent-teams-mcp tools', () => {
res.end(JSON.stringify(result.body));
} catch (error) {
res.writeHead(500, { 'content-type': 'application/json' });
res.end(JSON.stringify({ error: error instanceof Error ? error.message : String(error) }));
res.end(
JSON.stringify({ error: error instanceof Error ? error.message : String(error) })
);
}
});
});
@ -125,6 +127,10 @@ describe('agent-teams-mcp tools', () => {
});
it('launches and stops teams through the runtime MCP tools', async () => {
const claudeDir = makeClaudeDir();
writeTeamConfig(claudeDir, 'alpha', {
members: [{ name: 'lead', role: 'team-lead' }],
});
const calls: Array<{ method?: string; url?: string; body?: unknown }> = [];
const server = await startControlServer(async ({ method, url, body }) => {
calls.push({ method, url, body });
@ -171,6 +177,7 @@ describe('agent-teams-mcp tools', () => {
try {
const launched = parseJsonToolResult(
await getTool('team_launch').execute({
claudeDir,
teamName: 'alpha',
cwd: '/tmp/project',
controlUrl: server.baseUrl,
@ -182,6 +189,7 @@ describe('agent-teams-mcp tools', () => {
const stopped = parseJsonToolResult(
await getTool('team_stop').execute({
claudeDir,
teamName: 'alpha',
controlUrl: server.baseUrl,
})
@ -216,6 +224,13 @@ describe('agent-teams-mcp tools', () => {
});
it('forwards OpenCode runtime MCP tools through the runtime control bridge', async () => {
const claudeDir = makeClaudeDir();
writeTeamConfig(claudeDir, 'alpha', {
members: [
{ name: 'lead', role: 'team-lead' },
{ name: 'alice', role: 'developer' },
],
});
const calls: Array<{ method?: string; url?: string; body?: unknown }> = [];
const server = await startControlServer(async ({ method, url, body }) => {
calls.push({ method, url, body });
@ -224,6 +239,7 @@ describe('agent-teams-mcp tools', () => {
try {
await getTool('runtime_bootstrap_checkin').execute({
claudeDir,
teamName: 'alpha',
controlUrl: server.baseUrl,
runId: 'run-oc',
@ -231,6 +247,7 @@ describe('agent-teams-mcp tools', () => {
runtimeSessionId: 'ses-1',
});
await getTool('runtime_deliver_message').execute({
claudeDir,
teamName: 'alpha',
controlUrl: server.baseUrl,
idempotencyKey: 'idem-1',
@ -241,6 +258,7 @@ describe('agent-teams-mcp tools', () => {
text: 'hello',
});
await getTool('runtime_task_event').execute({
claudeDir,
teamName: 'alpha',
controlUrl: server.baseUrl,
idempotencyKey: 'idem-task-1',
@ -251,6 +269,7 @@ describe('agent-teams-mcp tools', () => {
event: 'started',
});
await getTool('runtime_heartbeat').execute({
claudeDir,
teamName: 'alpha',
controlUrl: server.baseUrl,
runId: 'run-oc',
@ -280,6 +299,9 @@ describe('agent-teams-mcp tools', () => {
it('discovers the control endpoint from the published state file', async () => {
const claudeDir = makeClaudeDir();
writeTeamConfig(claudeDir, 'alpha', {
members: [{ name: 'lead', role: 'team-lead' }],
});
const statePath = path.join(claudeDir, 'team-control-api.json');
const server = await startControlServer(async ({ method, url }) => {
@ -648,12 +670,16 @@ describe('agent-teams-mcp tools', () => {
expect(ownerInbox[0].text).toContain('task_start');
expect(ownerInbox[0].text).toContain('task_add_comment');
expect(ownerInbox[0].text).toContain('Read the plan before starting.');
expect(ownerInbox[0].text).toContain('If you are idle and this task is ready to start, start it now.');
expect(ownerInbox[0].text).toContain(
'If you are idle and this task is ready to start, start it now.'
);
expect(ownerInbox[0].text).toContain(
'If you are busy, blocked, or still need more context, immediately add a short task comment'
);
expect(ownerInbox[3].summary).toContain(`#${unassignedTask.displayId}`);
expect(ownerInbox[3].text).toContain('If you are idle and this task is ready to start, start it now.');
expect(ownerInbox[3].text).toContain(
'If you are idle and this task is ready to start, start it now.'
);
expect(ownerInbox[3].text).toContain('task_add_comment');
const briefing = (await getTool('task_briefing').execute({
@ -695,14 +721,22 @@ describe('agent-teams-mcp tools', () => {
expect(memberBriefingText).toContain(
'Awareness items are watch-only context and do not authorize you to start work unless the lead reroutes the task or you become the actionOwner.'
);
expect(memberBriefingText).toContain('TURN ACTION MODE PROTOCOL (HIGHEST PRIORITY FOR EACH USER TURN):');
expect(memberBriefingText).toContain(
'TURN ACTION MODE PROTOCOL (HIGHEST PRIORITY FOR EACH USER TURN):'
);
expect(memberBriefingText).toContain('Task briefing for alice:');
expect(memberBriefingText).toContain(`#${activeTask.displayId}`);
fs.mkdirSync(path.join(claudeDir, 'teams', teamName, 'inboxes'), { recursive: true });
fs.writeFileSync(path.join(claudeDir, 'teams', teamName, 'inboxes', 'carol.json'), '[]');
fs.writeFileSync(path.join(claudeDir, 'teams', teamName, 'inboxes', 'cross_team_send.json'), '[]');
fs.writeFileSync(path.join(claudeDir, 'teams', teamName, 'inboxes', 'other-team.alice.json'), '[]');
fs.writeFileSync(
path.join(claudeDir, 'teams', teamName, 'inboxes', 'cross_team_send.json'),
'[]'
);
fs.writeFileSync(
path.join(claudeDir, 'teams', teamName, 'inboxes', 'other-team.alice.json'),
'[]'
);
const inboxResolvedBriefing = (await getTool('member_briefing').execute({
claudeDir,
@ -710,7 +744,9 @@ describe('agent-teams-mcp tools', () => {
memberName: 'carol',
})) as { content: Array<{ text: string }> };
const inboxResolvedBriefingText = inboxResolvedBriefing.content[0]?.text ?? '';
expect(inboxResolvedBriefingText).toContain('Member briefing for carol on team "gamma" (gamma).');
expect(inboxResolvedBriefingText).toContain(
'Member briefing for carol on team "gamma" (gamma).'
);
expect(inboxResolvedBriefingText).toContain('Role: team member.');
await expect(
@ -897,9 +933,9 @@ describe('agent-teams-mcp tools', () => {
teamName,
})
);
expect(listedTasks.find((task: { id: string }) => task.id === createdTask.id)?.reviewState).toBe(
'needsFix'
);
expect(
listedTasks.find((task: { id: string }) => task.id === createdTask.id)?.reviewState
).toBe('needsFix');
const kanbanCleared = parseJsonToolResult(
await getTool('kanban_clear').execute({
@ -1044,6 +1080,26 @@ describe('agent-teams-mcp tools', () => {
);
expect(kanbanState.tasks[reviewTask.id]).toBeUndefined();
expect(JSON.stringify(kanbanState.columnOrder ?? {})).not.toContain(reviewTask.id);
const restored = parseJsonToolResult(
await getTool('task_restore').execute({
claudeDir,
teamName,
taskId: reviewTask.id,
actor: 'lead',
})
);
expect(restored.status).toBe('pending');
expect(restored.reviewState).toBe('none');
await expect(
getTool('task_restore').execute({
claudeDir,
teamName,
taskId: reviewTask.id,
actor: 'lead',
})
).rejects.toThrow('task_restore only restores deleted tasks');
});
it('only notifies the owner on review_approve when notifyOwner is explicit', async () => {
@ -1132,6 +1188,12 @@ describe('agent-teams-mcp tools', () => {
it('persists full message metadata through message_send', async () => {
const claudeDir = makeClaudeDir();
const teamName = 'gamma';
writeTeamConfig(claudeDir, teamName, {
members: [
{ name: 'lead', role: 'team-lead' },
{ name: 'alice', role: 'developer' },
],
});
const sent = parseJsonToolResult(
await getTool('message_send').execute({
@ -1155,6 +1217,41 @@ describe('agent-teams-mcp tools', () => {
expect(rows[0].attachments[0].filename).toBe('note.txt');
});
it('rejects non-configured teams before MCP side-effect writes', async () => {
const claudeDir = makeClaudeDir();
writeTeamConfig(claudeDir, 'real-team', {
members: [{ name: 'lead', role: 'team-lead' }],
});
await expect(
getTool('message_send').execute({
claudeDir,
teamName: 'typo-team',
to: 'alice',
text: 'Should not create inbox',
})
).rejects.toThrow('Unknown team "typo-team"');
await expect(
getTool('process_register').execute({
claudeDir,
teamName: 'typo-team',
pid: process.pid,
label: 'watcher',
})
).rejects.toThrow('Unknown team "typo-team"');
await expect(
getTool('cross_team_send').execute({
claudeDir,
teamName: 'typo-team',
toTeam: 'real-team',
text: 'Should not deliver',
})
).rejects.toThrow('Unknown team "typo-team"');
expect(fs.existsSync(path.join(claudeDir, 'teams', 'typo-team'))).toBe(false);
expect(fs.existsSync(path.join(claudeDir, 'teams', 'real-team', 'inboxes', 'lead.json'))).toBe(false);
});
it('exposes zod schemas that reject obviously invalid payloads', () => {
expect(
getTool('task_create').parameters?.safeParse({
@ -1303,9 +1400,7 @@ describe('agent-teams-mcp tools', () => {
expect(completed.comments).toBeUndefined();
// task_list: explicit inventory shape only
const listed = parseJsonToolResult(
await getTool('task_list').execute({ claudeDir, teamName })
);
const listed = parseJsonToolResult(await getTool('task_list').execute({ claudeDir, teamName }));
const listedTask = listed.find((t: { id: string }) => t.id === task.id);
expect(listedTask).toBeDefined();
expect(listedTask).toEqual({
@ -1345,9 +1440,7 @@ describe('agent-teams-mcp tools', () => {
const sentPath = path.join(claudeDir, 'teams', teamName, 'sentMessages.json');
const teamDir = path.join(claudeDir, 'teams', teamName);
fs.mkdirSync(teamDir, { recursive: true });
const existing = fs.existsSync(sentPath)
? JSON.parse(fs.readFileSync(sentPath, 'utf8'))
: [];
const existing = fs.existsSync(sentPath) ? JSON.parse(fs.readFileSync(sentPath, 'utf8')) : [];
existing.push(message);
fs.writeFileSync(sentPath, JSON.stringify(existing, null, 2));
}
@ -1693,9 +1786,7 @@ describe('agent-teams-mcp tools', () => {
text: 'Roundtrip test message',
timestamp: '2026-03-15T16:00:00.000Z',
source: 'user_sent',
attachments: [
{ id: 'att-rt', filename: 'data.csv', mimeType: 'text/csv', size: 1024 },
],
attachments: [{ id: 'att-rt', filename: 'data.csv', mimeType: 'text/csv', size: 1024 }],
});
const created = parseJsonToolResult(
@ -1813,4 +1904,20 @@ describe('agent-teams-mcp tools', () => {
'Unknown team "team-lead". Board tools require an existing configured team with config.json.'
);
});
it('fails closed for primary queue and inventory tools when team config does not exist', async () => {
const claudeDir = makeClaudeDir();
const params = { claudeDir, teamName: 'team-lead' };
const expected =
'Unknown team "team-lead". Board tools require an existing configured team with config.json.';
await expect(getTool('lead_briefing').execute(params)).rejects.toThrow(expected);
await expect(
getTool('task_briefing').execute({
...params,
memberName: 'alice',
})
).rejects.toThrow(expected);
await expect(getTool('task_list').execute(params)).rejects.toThrow(expected);
});
});

View file

@ -13,7 +13,7 @@ import type {
} from '../ports/RecentProjectsSourcePort';
const DEFAULT_CACHE_TTL_MS = 10_000;
const DEFAULT_DEGRADED_CACHE_TTL_MS = 1_500;
const DEFAULT_DEGRADED_CACHE_TTL_MS = 30_000;
interface SourceLoadResult {
candidates: RecentProjectCandidate[];
@ -99,9 +99,7 @@ export class ListDashboardRecentProjectsUseCase<TViewModel> {
}
const viewModel = this.deps.output.present(response);
const cacheTtlMs = hasDegradedSources
? Math.min(this.#cacheTtlMs, this.#degradedCacheTtlMs)
: this.#cacheTtlMs;
const cacheTtlMs = hasDegradedSources ? this.#degradedCacheTtlMs : this.#cacheTtlMs;
await this.deps.cache.set(cacheKey, viewModel, cacheTtlMs);
this.deps.logger.info('recent-projects loaded', {

View file

@ -16,16 +16,26 @@ import type {
import type { RecentProjectIdentityResolver } from '@features/recent-projects/main/infrastructure/identity/RecentProjectIdentityResolver';
import type { ServiceContext } from '@main/services';
const CODEX_THREAD_LIMIT = 40;
const CODEX_THREAD_LIMIT = 20;
const CODEX_INITIALIZE_TIMEOUT_MS = 6_000;
const CODEX_LIVE_FETCH_TIMEOUT_MS = 4_500;
const CODEX_ARCHIVED_FETCH_TIMEOUT_MS = 2_500;
const CODEX_LIVE_FETCH_TIMEOUT_MS = 12_000;
const CODEX_ARCHIVED_FETCH_TIMEOUT_MS = 4_000;
const CODEX_SESSION_OVERHEAD_TIMEOUT_MS = 1_500;
const CODEX_TOTAL_FETCH_TIMEOUT_MS =
CODEX_INITIALIZE_TIMEOUT_MS + CODEX_LIVE_FETCH_TIMEOUT_MS + CODEX_SESSION_OVERHEAD_TIMEOUT_MS;
CODEX_INITIALIZE_TIMEOUT_MS +
CODEX_ARCHIVED_FETCH_TIMEOUT_MS +
CODEX_LIVE_FETCH_TIMEOUT_MS +
CODEX_SESSION_OVERHEAD_TIMEOUT_MS;
const CODEX_SOURCE_TIMEOUT_MS = CODEX_TOTAL_FETCH_TIMEOUT_MS + 500;
const CODEX_LIVE_ONLY_FALLBACK_TOTAL_TIMEOUT_MS =
CODEX_INITIALIZE_TIMEOUT_MS + CODEX_LIVE_FETCH_TIMEOUT_MS + CODEX_SESSION_OVERHEAD_TIMEOUT_MS;
const CODEX_STALE_CANDIDATES_TTL_MS = 5 * 60_000;
const CODEX_FULL_FAILURE_COOLDOWN_MS = 30_000;
interface StaleCodexCandidatesSnapshot {
candidates: RecentProjectCandidate[];
capturedAt: number;
}
function isInteractiveSource(source: unknown): boolean {
return source === 'vscode' || source === 'cli';
@ -42,9 +52,24 @@ function isDegradedThreadResult(result: CodexRecentThreadsResult): boolean {
return Boolean(result.live.error || result.archived.error);
}
function getFullFailureReason(result: CodexRecentThreadsResult): string | null {
if (!result.live.error || !result.archived.error) {
return null;
}
if (result.live.error === result.archived.error) {
return result.live.error;
}
return `live: ${result.live.error}; archived: ${result.archived.error}`;
}
export class CodexRecentProjectsSourceAdapter implements RecentProjectsSourcePort {
readonly sourceId = 'codex';
readonly timeoutMs = CODEX_SOURCE_TIMEOUT_MS;
#staleCandidatesSnapshot: StaleCodexCandidatesSnapshot | null = null;
#fullFailureCooldownUntil = 0;
#fullFailureCooldownReason: string | null = null;
constructor(
private readonly deps: {
@ -77,8 +102,19 @@ export class CodexRecentProjectsSourceAdapter implements RecentProjectsSourcePor
};
}
const cooldown = this.#getActiveCooldown();
if (cooldown) {
this.deps.logger.info('codex recent-projects source cooldown active', cooldown);
return {
candidates: this.#getFreshStaleCandidates() ?? [],
degraded: true,
};
}
const threadSegments = await this.#listRecentThreadsSafe(binaryPath);
const degraded = isDegradedThreadResult(threadSegments);
const fullFailureReason = getFullFailureReason(threadSegments);
this.#updateFullFailureCooldown(fullFailureReason);
this.#logSegmentFailure(threadSegments, 'live');
this.#logSegmentFailure(threadSegments, 'archived');
const liveThreads = threadSegments.live.threads;
@ -92,6 +128,25 @@ export class CodexRecentProjectsSourceAdapter implements RecentProjectsSourcePor
await Promise.all(interactiveThreads.map((thread) => this.#toCandidate(thread)))
).filter((candidate): candidate is RecentProjectCandidate => candidate !== null);
if (!degraded) {
this.#rememberHealthyCandidates(candidates);
}
if (degraded && candidates.length === 0) {
const staleCandidates = this.#getFreshStaleCandidates();
if (staleCandidates) {
this.deps.logger.info('codex recent-projects served stale candidates', {
count: staleCandidates.length,
reason: fullFailureReason ?? 'degraded-empty-result',
});
return {
candidates: staleCandidates,
degraded: true,
};
}
}
this.deps.logger.info('codex recent-projects source loaded', {
count: candidates.length,
degraded,
@ -103,6 +158,53 @@ export class CodexRecentProjectsSourceAdapter implements RecentProjectsSourcePor
};
}
#getActiveCooldown(): { retryAfterMs: number; reason: string | null } | null {
const retryAfterMs = this.#fullFailureCooldownUntil - Date.now();
if (retryAfterMs <= 0) {
return null;
}
return {
retryAfterMs,
reason: this.#fullFailureCooldownReason,
};
}
#updateFullFailureCooldown(reason: string | null): void {
if (!reason) {
this.#fullFailureCooldownUntil = 0;
this.#fullFailureCooldownReason = null;
return;
}
this.#fullFailureCooldownUntil = Date.now() + CODEX_FULL_FAILURE_COOLDOWN_MS;
this.#fullFailureCooldownReason = reason;
}
#rememberHealthyCandidates(candidates: RecentProjectCandidate[]): void {
this.#staleCandidatesSnapshot =
candidates.length > 0
? {
candidates,
capturedAt: Date.now(),
}
: null;
}
#getFreshStaleCandidates(): RecentProjectCandidate[] | null {
const snapshot = this.#staleCandidatesSnapshot;
if (!snapshot) {
return null;
}
if (Date.now() - snapshot.capturedAt > CODEX_STALE_CANDIDATES_TTL_MS) {
this.#staleCandidatesSnapshot = null;
return null;
}
return [...snapshot.candidates];
}
async #listRecentThreads(binaryPath: string): Promise<CodexRecentThreadsResult> {
const result = await this.deps.appServerClient.listRecentThreads(binaryPath, {
limit: CODEX_THREAD_LIMIT,

View file

@ -74,10 +74,7 @@ export class CodexAppServerClient {
}
): Promise<CodexThreadSegmentResult> {
const requestTimeoutMs = options.requestTimeoutMs ?? DEFAULT_REQUEST_TIMEOUT_MS;
const initializeTimeoutMs = Math.max(
options.initializeTimeoutMs ?? DEFAULT_INITIALIZE_TIMEOUT_MS,
requestTimeoutMs
);
const initializeTimeoutMs = options.initializeTimeoutMs ?? DEFAULT_INITIALIZE_TIMEOUT_MS;
const totalTimeoutMs = Math.max(
options.totalTimeoutMs ?? DEFAULT_TOTAL_TIMEOUT_MS,
initializeTimeoutMs + requestTimeoutMs + MIN_SESSION_OVERHEAD_TIMEOUT_MS
@ -122,13 +119,13 @@ export class CodexAppServerClient {
const liveRequestTimeoutMs = options.liveRequestTimeoutMs ?? DEFAULT_REQUEST_TIMEOUT_MS;
const archivedRequestTimeoutMs = options.archivedRequestTimeoutMs ?? DEFAULT_REQUEST_TIMEOUT_MS;
const sessionRequestTimeoutMs = Math.max(liveRequestTimeoutMs, archivedRequestTimeoutMs);
const initializeTimeoutMs = Math.max(
options.initializeTimeoutMs ?? DEFAULT_INITIALIZE_TIMEOUT_MS,
sessionRequestTimeoutMs
);
const initializeTimeoutMs = options.initializeTimeoutMs ?? DEFAULT_INITIALIZE_TIMEOUT_MS;
const totalTimeoutMs = Math.max(
options.totalTimeoutMs ?? DEFAULT_TOTAL_TIMEOUT_MS,
initializeTimeoutMs + sessionRequestTimeoutMs + MIN_SESSION_OVERHEAD_TIMEOUT_MS
initializeTimeoutMs +
liveRequestTimeoutMs +
archivedRequestTimeoutMs +
MIN_SESSION_OVERHEAD_TIMEOUT_MS
);
return this.#withThreadListSession(
@ -140,50 +137,55 @@ export class CodexAppServerClient {
label: 'codex app-server thread/list',
},
async (session) => {
const [live, archived] = await Promise.allSettled([
session.request<ThreadListResponse>(
'thread/list',
{
archived: false,
limit: options.limit,
sortKey: 'updated_at',
},
liveRequestTimeoutMs
),
session.request<ThreadListResponse>(
'thread/list',
{
archived: true,
limit: options.limit,
sortKey: 'updated_at',
},
archivedRequestTimeoutMs
),
]);
const live = await this.#requestThreadListSegment(session, {
archived: false,
limit: options.limit,
timeoutMs: liveRequestTimeoutMs,
});
const archived = await this.#requestThreadListSegment(session, {
archived: true,
limit: options.limit,
timeoutMs: archivedRequestTimeoutMs,
});
return {
live:
live.status === 'fulfilled'
? { threads: live.value.data ?? [] }
: {
threads: [],
error: live.reason instanceof Error ? live.reason.message : String(live.reason),
},
archived:
archived.status === 'fulfilled'
? { threads: archived.value.data ?? [] }
: {
threads: [],
error:
archived.reason instanceof Error
? archived.reason.message
: String(archived.reason),
},
live,
archived,
};
}
);
}
async #requestThreadListSegment(
session: JsonRpcSession,
options: {
archived: boolean;
limit: number;
timeoutMs: number;
}
): Promise<CodexThreadSegmentResult> {
try {
const response = await session.request<ThreadListResponse>(
'thread/list',
{
archived: options.archived,
limit: options.limit,
sortKey: 'updated_at',
},
options.timeoutMs
);
return {
threads: response.data ?? [],
};
} catch (error) {
return {
threads: [],
error: error instanceof Error ? error.message : String(error),
};
}
}
async #withThreadListSession<T>(
options: ThreadListSessionOptions,
handler: (session: JsonRpcSession) => Promise<T>

View file

@ -25,8 +25,8 @@ import type { TeamSummary } from '@shared/types';
const INITIAL_RECENT_PROJECTS = 11;
const LOAD_MORE_STEP = 8;
const DEGRADED_RECENT_PROJECTS_FAST_RETRY_DELAY_MS = 1_500;
const DEGRADED_RECENT_PROJECTS_STEADY_RETRY_DELAY_MS = 5_000;
const DEGRADED_RECENT_PROJECTS_FAST_RETRY_DELAY_MS = 30_000;
const DEGRADED_RECENT_PROJECTS_STEADY_RETRY_DELAY_MS = 120_000;
const DEGRADED_RECENT_PROJECTS_FAST_RETRY_LIMIT = 3;
function matchesSearch(project: DashboardRecentProject, query: string): boolean {

View file

@ -6,7 +6,7 @@ import type {
} from '@features/recent-projects/contracts';
const RECENT_PROJECTS_CLIENT_CACHE_TTL_MS = 15_000;
const RECENT_PROJECTS_CLIENT_DEGRADED_CACHE_TTL_MS = 1_500;
const RECENT_PROJECTS_CLIENT_DEGRADED_CACHE_TTL_MS = 30_000;
let cachedPayload: DashboardRecentProjectsPayloadLike = null;
let cachedAt = 0;

View file

@ -76,6 +76,11 @@ describe('buildMixedPersistedLaunchSnapshot', () => {
pendingCount: 1,
failedCount: 0,
runtimeAlivePendingCount: 0,
shellOnlyPendingCount: 0,
runtimeProcessPendingCount: 0,
runtimeCandidatePendingCount: 0,
noRuntimePendingCount: 0,
permissionPendingCount: 0,
});
expect(snapshot.teamLaunchState).toBe('partial_pending');
});
@ -130,6 +135,11 @@ describe('buildMixedPersistedLaunchSnapshot', () => {
bootstrapConfirmed: true,
hardFailure: false,
runtimePid: 333,
runtimeSessionId: 'session-bob',
livenessKind: 'confirmed_bootstrap',
pidSource: 'runtime_bootstrap',
runtimeDiagnostic: 'OpenCode runtime bootstrap check-in accepted',
runtimeDiagnosticSeverity: 'info',
diagnostics: ['spawn accepted', 'late heartbeat received'],
},
},
@ -145,12 +155,22 @@ describe('buildMixedPersistedLaunchSnapshot', () => {
runtimeAlive: true,
bootstrapConfirmed: true,
runtimePid: 333,
runtimeSessionId: 'session-bob',
livenessKind: 'confirmed_bootstrap',
pidSource: 'runtime_bootstrap',
runtimeDiagnostic: 'OpenCode runtime bootstrap check-in accepted',
runtimeDiagnosticSeverity: 'info',
});
expect(snapshot.summary).toEqual({
confirmedCount: 2,
pendingCount: 0,
failedCount: 0,
runtimeAlivePendingCount: 0,
shellOnlyPendingCount: 0,
runtimeProcessPendingCount: 0,
runtimeCandidatePendingCount: 0,
noRuntimePendingCount: 0,
permissionPendingCount: 0,
});
expect(snapshot.teamLaunchState).toBe('clean_success');
});
@ -229,6 +249,11 @@ describe('buildMixedPersistedLaunchSnapshot', () => {
pendingCount: 0,
failedCount: 1,
runtimeAlivePendingCount: 0,
shellOnlyPendingCount: 0,
runtimeProcessPendingCount: 0,
runtimeCandidatePendingCount: 0,
noRuntimePendingCount: 0,
permissionPendingCount: 0,
});
expect(snapshot.teamLaunchState).toBe('partial_failure');
});
@ -279,9 +304,12 @@ describe('buildMixedPersistedLaunchSnapshot', () => {
evidence: {
launchState: 'runtime_pending_permission',
agentToolAccepted: true,
runtimeAlive: true,
runtimeAlive: false,
bootstrapConfirmed: false,
hardFailure: false,
livenessKind: 'permission_blocked',
runtimeDiagnostic: 'OpenCode runtime is waiting for permission approval',
runtimeDiagnosticSeverity: 'warning',
pendingPermissionRequestIds: ['opencode:run-1:perm_1'],
},
},
@ -292,9 +320,12 @@ describe('buildMixedPersistedLaunchSnapshot', () => {
laneKind: 'secondary',
laneOwnerProviderId: 'opencode',
launchState: 'runtime_pending_permission',
runtimeAlive: true,
runtimeAlive: false,
agentToolAccepted: true,
bootstrapConfirmed: false,
livenessKind: 'permission_blocked',
runtimeDiagnostic: 'OpenCode runtime is waiting for permission approval',
runtimeDiagnosticSeverity: 'warning',
pendingPermissionRequestIds: ['opencode:run-1:perm_1'],
hardFailure: false,
});
@ -303,7 +334,12 @@ describe('buildMixedPersistedLaunchSnapshot', () => {
confirmedCount: 1,
pendingCount: 1,
failedCount: 0,
runtimeAlivePendingCount: 1,
runtimeAlivePendingCount: 0,
shellOnlyPendingCount: 0,
runtimeProcessPendingCount: 0,
runtimeCandidatePendingCount: 0,
noRuntimePendingCount: 0,
permissionPendingCount: 1,
});
expect(snapshot.teamLaunchState).toBe('partial_pending');
});

View file

@ -11,6 +11,9 @@ import type {
PersistedTeamLaunchPhase,
PersistedTeamLaunchSnapshot,
ProviderModelLaunchIdentity,
TeamAgentRuntimeDiagnosticSeverity,
TeamAgentRuntimeLivenessKind,
TeamAgentRuntimePidSource,
TeamFastMode,
TeamProviderBackendId,
TeamProviderId,
@ -38,6 +41,12 @@ export interface MixedSecondaryLaneMemberStateInput {
hardFailureReason?: string;
pendingPermissionRequestIds?: string[];
runtimePid?: number;
runtimeSessionId?: string;
sessionId?: string;
livenessKind?: TeamAgentRuntimeLivenessKind;
pidSource?: TeamAgentRuntimePidSource;
runtimeDiagnostic?: string;
runtimeDiagnosticSeverity?: TeamAgentRuntimeDiagnosticSeverity;
diagnostics?: string[];
} | null;
pendingReason?: string;
@ -65,6 +74,19 @@ function deriveMemberLaunchState(params: {
return 'starting';
}
function preservesStrongRuntimeAlive(value: {
runtimeAlive?: boolean;
bootstrapConfirmed?: boolean;
livenessKind?: TeamAgentRuntimeLivenessKind;
}): boolean {
return (
value.runtimeAlive === true &&
(value.bootstrapConfirmed === true ||
value.livenessKind === 'confirmed_bootstrap' ||
value.livenessKind === 'runtime_process')
);
}
function buildDiagnostics(
member: Pick<
PersistedTeamLaunchMemberState,
@ -93,14 +115,17 @@ function buildDiagnostics(
}
function createSourcesFromStatus(
status: Pick<MemberSpawnStatusEntry, 'livenessSource' | 'runtimeAlive'>
status: Pick<
MemberSpawnStatusEntry,
'livenessSource' | 'runtimeAlive' | 'bootstrapConfirmed' | 'livenessKind'
>
): PersistedTeamLaunchMemberSources | undefined {
const sources: PersistedTeamLaunchMemberSources = {};
if (status.livenessSource === 'heartbeat') {
sources.nativeHeartbeat = true;
sources.inboxHeartbeat = true;
}
if (status.livenessSource === 'process' || status.runtimeAlive) {
if (status.livenessSource === 'process' && preservesStrongRuntimeAlive(status)) {
sources.processAlive = true;
}
return Object.values(sources).some(Boolean) ? sources : undefined;
@ -119,6 +144,7 @@ function createPrimaryLaneMemberState(params: {
const providerId =
normalizeOptionalTeamProviderId(params.member.providerId) ?? params.leadDefaults.providerId;
const runtime = params.status;
const strongRuntimeAlive = preservesStrongRuntimeAlive(runtime ?? {});
const sources = runtime ? createSourcesFromStatus(runtime) : undefined;
const base: PersistedTeamLaunchMemberState = {
name: params.member.name.trim(),
@ -151,21 +177,25 @@ function createPrimaryLaneMemberState(params: {
deriveMemberLaunchState({
hardFailure: runtime?.hardFailure,
bootstrapConfirmed: runtime?.bootstrapConfirmed,
runtimeAlive: runtime?.runtimeAlive,
runtimeAlive: strongRuntimeAlive,
agentToolAccepted: runtime?.agentToolAccepted,
pendingPermissionRequestIds: runtime?.pendingPermissionRequestIds,
}),
agentToolAccepted: runtime?.agentToolAccepted === true,
runtimeAlive: runtime?.runtimeAlive === true,
runtimeAlive: strongRuntimeAlive,
bootstrapConfirmed: runtime?.bootstrapConfirmed === true,
hardFailure: runtime?.hardFailure === true || runtime?.launchState === 'failed_to_start',
hardFailureReason: runtime?.hardFailureReason ?? runtime?.error,
pendingPermissionRequestIds: runtime?.pendingPermissionRequestIds?.length
? [...new Set(runtime.pendingPermissionRequestIds)]
: undefined,
livenessKind: runtime?.livenessKind,
runtimeDiagnostic: runtime?.runtimeDiagnostic,
runtimeDiagnosticSeverity: runtime?.runtimeDiagnosticSeverity,
firstSpawnAcceptedAt: runtime?.firstSpawnAcceptedAt,
lastHeartbeatAt: runtime?.lastHeartbeatAt,
lastRuntimeAliveAt: runtime?.runtimeAlive ? params.updatedAt : undefined,
runtimeLastSeenAt: runtime?.livenessLastCheckedAt,
lastRuntimeAliveAt: preservesStrongRuntimeAlive(runtime ?? {}) ? params.updatedAt : undefined,
lastEvaluatedAt: runtime?.updatedAt ?? params.updatedAt,
sources,
diagnostics: undefined,
@ -180,13 +210,14 @@ function createSecondaryLaneMemberState(
const providerId =
normalizeOptionalTeamProviderId(params.member.providerId) ?? params.leadDefaults.providerId;
const evidence = params.evidence;
const strongRuntimeAlive = preservesStrongRuntimeAlive(evidence ?? {});
const hardFailureReason = evidence?.hardFailureReason;
const launchState =
evidence?.launchState ??
deriveMemberLaunchState({
hardFailure: evidence?.hardFailure,
bootstrapConfirmed: evidence?.bootstrapConfirmed,
runtimeAlive: evidence?.runtimeAlive,
runtimeAlive: strongRuntimeAlive,
agentToolAccepted: evidence?.agentToolAccepted,
pendingPermissionRequestIds: evidence?.pendingPermissionRequestIds,
});
@ -214,7 +245,7 @@ function createSecondaryLaneMemberState(
laneOwnerProviderId: providerId,
launchState,
agentToolAccepted: evidence?.agentToolAccepted === true,
runtimeAlive: evidence?.runtimeAlive === true,
runtimeAlive: strongRuntimeAlive,
bootstrapConfirmed: evidence?.bootstrapConfirmed === true,
hardFailure: evidence?.hardFailure === true || launchState === 'failed_to_start',
hardFailureReason,
@ -227,15 +258,21 @@ function createSecondaryLaneMemberState(
evidence.runtimePid > 0
? Math.trunc(evidence.runtimePid)
: undefined,
runtimeSessionId: evidence?.runtimeSessionId ?? evidence?.sessionId,
livenessKind: evidence?.livenessKind,
pidSource: evidence?.pidSource,
runtimeDiagnostic: evidence?.runtimeDiagnostic,
runtimeDiagnosticSeverity: evidence?.runtimeDiagnosticSeverity,
firstSpawnAcceptedAt: evidence?.agentToolAccepted ? params.updatedAt : undefined,
lastHeartbeatAt: evidence?.bootstrapConfirmed ? params.updatedAt : undefined,
lastRuntimeAliveAt: evidence?.runtimeAlive ? params.updatedAt : undefined,
runtimeLastSeenAt: strongRuntimeAlive ? params.updatedAt : undefined,
lastRuntimeAliveAt: strongRuntimeAlive ? params.updatedAt : undefined,
lastEvaluatedAt: params.updatedAt,
sources: evidence?.runtimeAlive
sources: strongRuntimeAlive
? {
processAlive: true,
nativeHeartbeat: evidence.bootstrapConfirmed === true || undefined,
inboxHeartbeat: evidence.bootstrapConfirmed === true || undefined,
nativeHeartbeat: evidence?.bootstrapConfirmed === true || undefined,
inboxHeartbeat: evidence?.bootstrapConfirmed === true || undefined,
}
: undefined,
diagnostics: evidence?.diagnostics?.length
@ -256,6 +293,11 @@ function summarizeMembers(
let pendingCount = 0;
let failedCount = 0;
let runtimeAlivePendingCount = 0;
let shellOnlyPendingCount = 0;
let runtimeProcessPendingCount = 0;
let runtimeCandidatePendingCount = 0;
let noRuntimePendingCount = 0;
let permissionPendingCount = 0;
for (const memberName of expectedMembers) {
const entry = members[memberName];
@ -275,6 +317,22 @@ function summarizeMembers(
if (entry.runtimeAlive) {
runtimeAlivePendingCount += 1;
}
if (entry.launchState === 'runtime_pending_permission') {
permissionPendingCount += 1;
}
if (entry.livenessKind === 'shell_only') {
shellOnlyPendingCount += 1;
} else if (entry.livenessKind === 'runtime_process') {
runtimeProcessPendingCount += 1;
} else if (entry.livenessKind === 'runtime_process_candidate') {
runtimeCandidatePendingCount += 1;
} else if (
entry.livenessKind === 'not_found' ||
entry.livenessKind === 'stale_metadata' ||
entry.livenessKind === 'registered_only'
) {
noRuntimePendingCount += 1;
}
}
return {
@ -282,6 +340,11 @@ function summarizeMembers(
pendingCount,
failedCount,
runtimeAlivePendingCount,
shellOnlyPendingCount,
runtimeProcessPendingCount,
runtimeCandidatePendingCount,
noRuntimePendingCount,
permissionPendingCount,
};
}

View file

@ -1,8 +1,8 @@
import { TmuxStatusSourceAdapter } from '../adapters/output/sources/TmuxStatusSourceAdapter';
import {
TmuxPlatformCommandExecutor,
type RuntimeProcessTableRow,
type TmuxPaneRuntimeInfo,
TmuxPlatformCommandExecutor,
} from '../infrastructure/runtime/TmuxPlatformCommandExecutor';
const runtimeStatusSource = new TmuxStatusSourceAdapter();

View file

@ -17,3 +17,4 @@ export type {
RuntimeProcessTableRow,
TmuxPaneRuntimeInfo,
} from './infrastructure/runtime/TmuxPlatformCommandExecutor';
export { parseRuntimeProcessTable } from './infrastructure/runtime/TmuxPlatformCommandExecutor';

View file

@ -96,4 +96,26 @@ describe('TmuxPlatformCommandExecutor', () => {
3_000
);
});
it('lists runtime processes inside WSL on Windows instead of using host ps', async () => {
setPlatform('win32');
const execInPreferredDistro = vi.fn(async () => ({
exitCode: 0,
stdout: ' 42 1 opencode runtime --team-name demo\n',
stderr: '',
}));
const executor = new TmuxPlatformCommandExecutor(
{
execInPreferredDistro,
getPersistedPreferredDistroSync: () => 'Ubuntu',
} as never,
{} as never
);
await expect(executor.listRuntimeProcesses()).resolves.toEqual([
{ pid: 42, ppid: 1, command: 'opencode runtime --team-name demo' },
]);
expect(execInPreferredDistro).toHaveBeenCalledWith(['ps', '-ax', '-o', 'pid=,ppid=,command=']);
expect(childProcess.execFile).not.toHaveBeenCalled();
});
});

View file

@ -17,8 +17,6 @@
process.env.UV_THREADPOOL_SIZE ??= '16';
// Keep userData stable before any integration can initialize Electron storage.
import { earlyElectronUserDataMigrationResult } from './bootstrapUserDataMigration';
// Sentry must stay near the top to capture early errors after storage migration.
import './sentry';
@ -142,6 +140,7 @@ import {
markRendererUnavailable,
safeSendToRenderer,
} from './utils/safeWebContentsSend';
import { earlyElectronUserDataMigrationResult } from './bootstrapUserDataMigration';
import { syncTelemetryFlag } from './sentry';
import {
ActiveTeamRegistry,

View file

@ -570,6 +570,7 @@ export class ConfigManager {
...DEFAULT_CONFIG.general,
...(loaded.general ?? {}),
};
mergedGeneral.multimodelEnabled = true;
mergedGeneral.claudeRootPath = normalizeConfiguredClaudeRootPath(mergedGeneral.claudeRootPath);
// Merge triggers: preserve existing triggers, add missing builtin ones

View file

@ -1,4 +1,3 @@
import { once } from 'node:events';
import readline from 'node:readline';
import { killProcessTree, spawnCli } from '@main/utils/childProcess';
@ -49,6 +48,9 @@ function withTimeout<T>(promise: Promise<T>, timeoutMs: number, label: string):
const DEFAULT_REQUEST_TIMEOUT_MS = 3_000;
const DEFAULT_TOTAL_TIMEOUT_MS = 8_000;
const DEFAULT_STDIN_CLOSE_TIMEOUT_MS = 250;
const DEFAULT_CLOSE_TIMEOUT_MS = 1_000;
const DEFAULT_FORCE_CLOSE_TIMEOUT_MS = 1_000;
export class JsonRpcRequestError extends Error {
readonly code: number | null;
@ -76,6 +78,9 @@ export class JsonRpcStdioClient {
env?: NodeJS.ProcessEnv;
requestTimeoutMs?: number;
totalTimeoutMs?: number;
stdinCloseTimeoutMs?: number;
closeTimeoutMs?: number;
forceCloseTimeoutMs?: number;
label: string;
},
handler: (session: JsonRpcSession) => Promise<T>
@ -95,8 +100,14 @@ export class JsonRpcStdioClient {
args: string[];
env?: NodeJS.ProcessEnv;
requestTimeoutMs?: number;
stdinCloseTimeoutMs?: number;
closeTimeoutMs?: number;
forceCloseTimeoutMs?: number;
}): Promise<JsonRpcSession> {
const requestTimeoutMs = options.requestTimeoutMs ?? DEFAULT_REQUEST_TIMEOUT_MS;
const stdinCloseTimeoutMs = options.stdinCloseTimeoutMs ?? DEFAULT_STDIN_CLOSE_TIMEOUT_MS;
const closeTimeoutMs = options.closeTimeoutMs ?? DEFAULT_CLOSE_TIMEOUT_MS;
const forceCloseTimeoutMs = options.forceCloseTimeoutMs ?? DEFAULT_FORCE_CLOSE_TIMEOUT_MS;
const child = spawnCli(options.binaryPath, options.args, {
env: options.env,
stdio: ['pipe', 'pipe', 'pipe'],
@ -194,6 +205,58 @@ export class JsonRpcStdioClient {
);
});
const waitForChildClose = (timeoutMs: number): Promise<boolean> => {
if (child.exitCode !== null || child.signalCode !== null) {
return Promise.resolve(true);
}
return new Promise<boolean>((resolve) => {
let settled = false;
const finish = (closedByEvent: boolean): void => {
if (settled) {
return;
}
settled = true;
clearTimeout(timeoutId);
child.off('close', onClose);
resolve(closedByEvent);
};
const onClose = (): void => finish(true);
const timeoutId = setTimeout(() => finish(false), timeoutMs);
timeoutId.unref?.();
child.once('close', onClose);
});
};
const closeStdin = async (): Promise<void> => {
if (!child.stdin || child.stdin.destroyed || child.stdin.writableEnded) {
return;
}
await new Promise<void>((resolve) => {
let settled = false;
const finish = (): void => {
if (settled) {
return;
}
settled = true;
clearTimeout(timeoutId);
resolve();
};
const timeoutId = setTimeout(finish, stdinCloseTimeoutMs);
timeoutId.unref?.();
try {
child.stdin!.end(() => finish());
} catch {
finish();
}
});
};
const close = async (): Promise<void> => {
if (closed) {
return;
@ -204,21 +267,26 @@ export class JsonRpcStdioClient {
notificationListeners.clear();
lineReader.close();
if (child.stdin && !child.stdin.destroyed && !child.stdin.writableEnded) {
await new Promise<void>((resolve) => {
try {
child.stdin!.end(() => resolve());
} catch {
resolve();
}
});
await closeStdin();
const gracefulClose = waitForChildClose(closeTimeoutMs);
killProcessTree(child, 'SIGTERM');
if (await gracefulClose) {
return;
}
killProcessTree(child);
try {
await once(child, 'close');
} catch {
this.logger.warn('json-rpc close wait failed');
this.logger.warn('json-rpc close timed out; force killing process', {
pid: child.pid,
timeoutMs: closeTimeoutMs,
});
const forcedClose = waitForChildClose(forceCloseTimeoutMs);
killProcessTree(child, 'SIGKILL');
if (!(await forcedClose)) {
this.logger.warn('json-rpc force close timed out', {
pid: child.pid,
timeoutMs: forceCloseTimeoutMs,
});
}
};

View file

@ -2,7 +2,7 @@ import fs from 'node:fs';
import os from 'node:os';
import path from 'node:path';
import { afterEach, describe, expect, it } from 'vitest';
import { afterEach, describe, expect, it, vi } from 'vitest';
import { JsonRpcStdioClient } from '../JsonRpcStdioClient';
@ -42,6 +42,35 @@ rl.on('line', (line) => {
return scriptPath;
}
function createSignalIgnoringJsonRpcServerScript(): string {
const tempDir = fs.mkdtempSync(path.join(os.tmpdir(), 'json-rpc-stdio-client-'));
tempDirs.push(tempDir);
const scriptPath = path.join(tempDir, 'server.cjs');
fs.writeFileSync(
scriptPath,
`
const readline = require('node:readline');
process.on('SIGTERM', () => {});
setInterval(() => {}, 10_000);
const rl = readline.createInterface({ input: process.stdin });
rl.on('line', (line) => {
const message = JSON.parse(line);
if (message.jsonrpc !== '2.0') {
return;
}
process.stdout.write(JSON.stringify({
jsonrpc: '2.0',
id: message.id,
result: { ok: true },
}) + '\\n');
});
`,
'utf8'
);
return scriptPath;
}
afterEach(() => {
for (const dir of tempDirs.splice(0)) {
fs.rmSync(dir, { recursive: true, force: true });
@ -76,4 +105,30 @@ describe('JsonRpcStdioClient', () => {
}
);
});
it('force kills the child when session close does not finish gracefully', async () => {
const scriptPath = createSignalIgnoringJsonRpcServerScript();
const warn = vi.fn();
const client = new JsonRpcStdioClient({ warn });
await client.withSession(
{
binaryPath: process.execPath,
args: [scriptPath],
label: 'stubborn json-rpc close',
requestTimeoutMs: 1_000,
totalTimeoutMs: 2_000,
closeTimeoutMs: 25,
forceCloseTimeoutMs: 1_000,
},
async (session) => {
await expect(session.request('ping')).resolves.toEqual({ ok: true });
}
);
expect(warn).toHaveBeenCalledWith('json-rpc close timed out; force killing process', {
pid: expect.any(Number),
timeoutMs: 25,
});
});
});

View file

@ -337,10 +337,11 @@ function normalizeBootstrapMemberState(
const status = typeof raw.status === 'string' ? raw.status : 'pending';
const hardFailure = status === 'failed';
const bootstrapConfirmed = status === 'bootstrap_confirmed';
const runtimeAlive = bootstrapConfirmed || status === 'runtime_alive';
const bootstrapReportedRuntimeAlive = status === 'runtime_alive';
const runtimeAlive = bootstrapConfirmed;
const agentToolAccepted =
bootstrapConfirmed ||
runtimeAlive ||
bootstrapReportedRuntimeAlive ||
status === 'registered' ||
status === 'spawn_started' ||
hardFailure;
@ -351,7 +352,7 @@ function normalizeBootstrapMemberState(
? 'failed_to_start'
: bootstrapConfirmed
? 'confirmed_alive'
: runtimeAlive || agentToolAccepted
: agentToolAccepted
? 'runtime_pending_bootstrap'
: 'starting',
agentToolAccepted,
@ -381,13 +382,13 @@ function normalizeBootstrapMemberState(
? raw.failureReason.trim()
: 'deterministic bootstrap failed',
]
: runtimeAlive
? bootstrapConfirmed
? ['late heartbeat received']
: ['runtime alive', 'waiting for teammate check-in']
: agentToolAccepted
? ['spawn accepted']
: undefined,
: bootstrapConfirmed
? ['late heartbeat received']
: bootstrapReportedRuntimeAlive
? ['runtime alive reported by bootstrap state', 'waiting for strict live verification']
: agentToolAccepted
? ['spawn accepted']
: undefined,
};
}

View file

@ -14,7 +14,7 @@ import { classifyIdleNotificationText } from '@shared/utils/idleNotificationSema
import { isLeadMember } from '@shared/utils/leadDetection';
import { createLogger } from '@shared/utils/logger';
import { migrateProviderBackendId } from '@shared/utils/providerBackend';
import { getKanbanColumnFromReviewState, normalizeReviewState } from '@shared/utils/reviewState';
import { getKanbanColumnFromReviewState, getReviewStateFromTask } from '@shared/utils/reviewState';
import { buildStandaloneSlashCommandMeta } from '@shared/utils/slashCommands';
import { formatTaskDisplayLabel } from '@shared/utils/taskIdentity';
import { buildTeamMemberColorMap } from '@shared/utils/teamMemberColors';
@ -265,6 +265,11 @@ function extractPassiveUserPeerSummaryBody(text: string): string | null {
return body.length > 0 ? body : null;
}
function isExplicitLeadRole(role: string | undefined): boolean {
const normalized = role?.trim().toLowerCase();
return normalized === 'lead' || normalized === 'team lead' || normalized === 'team-lead';
}
function hasVisibleLeadMember(members: readonly TeamMemberSnapshot[]): boolean {
return members.some((member) => {
if (isLeadMember(member)) {
@ -274,7 +279,7 @@ function hasVisibleLeadMember(members: readonly TeamMemberSnapshot[]): boolean {
if (normalizedName === 'lead') {
return true;
}
return member.role?.toLowerCase().includes('lead') === true;
return isExplicitLeadRole(member.role);
});
}
@ -287,7 +292,7 @@ function hasExplicitLeadInConfig(config: TeamConfig): boolean {
if (normalizedName === 'lead') {
return true;
}
return member.role?.toLowerCase().includes('lead') === true;
return isExplicitLeadRole(member.role);
});
}
@ -530,16 +535,22 @@ export class TeamDataService {
}
private resolveTaskReviewState(
task: Pick<TeamTask, 'reviewState'>
task: Pick<TeamTask, 'reviewState' | 'historyEvents' | 'status'>,
kanbanTaskState?: KanbanState['tasks'][string]
): 'none' | 'review' | 'needsFix' | 'approved' {
return normalizeReviewState(task.reviewState);
return getReviewStateFromTask({
historyEvents: task.historyEvents,
reviewState: task.reviewState,
status: task.status,
kanbanColumn: kanbanTaskState?.column,
});
}
private attachKanbanCompatibility(
task: TeamTask,
kanbanTaskState?: KanbanState['tasks'][string]
): TeamTaskWithKanban {
const reviewState = this.resolveTaskReviewState(task);
const reviewState = this.resolveTaskReviewState(task, kanbanTaskState);
const reviewer = this.resolveReviewerFromHistory(task, kanbanTaskState, reviewState) ?? null;
return {
...task,
@ -557,8 +568,15 @@ export class TeamDataService {
private resolveReviewerFromHistory(
task: TeamTask,
kanbanTaskState?: KanbanState['tasks'][string],
reviewState: 'none' | 'review' | 'needsFix' | 'approved' = this.resolveTaskReviewState(task)
reviewState: 'none' | 'review' | 'needsFix' | 'approved' = this.resolveTaskReviewState(
task,
kanbanTaskState
)
): string | null {
if (reviewState !== 'review') {
return null;
}
if (task.historyEvents?.length) {
for (let i = task.historyEvents.length - 1; i >= 0; i--) {
const event = task.historyEvents[i];
@ -571,7 +589,10 @@ export class TeamDataService {
if (event.type === 'review_approved' || event.type === 'review_changes_requested') {
break;
}
if (event.type === 'status_changed' && event.to === 'in_progress') {
if (
event.type === 'status_changed' &&
(event.to === 'in_progress' || event.to === 'pending' || event.to === 'deleted')
) {
break;
}
if (event.type === 'task_created') {
@ -894,7 +915,8 @@ export class TeamDataService {
continue;
}
const info = teamInfoMap.get(task.teamName)!;
const reviewState = this.resolveTaskReviewState(task);
const kanbanTaskState = kanbanByTeam.get(task.teamName)?.tasks[task.id];
const reviewState = this.resolveTaskReviewState(task, kanbanTaskState);
const kanbanColumn = getKanbanColumnFromReviewState(reviewState);
// IPC payload safety: GlobalTask lists can be enormous (especially comments and large nested fields).
@ -2156,7 +2178,11 @@ export class TeamDataService {
private resolveLeadNameFromConfig(config: TeamConfig | null): string {
if (!config) return 'team-lead';
const lead = config.members?.find((m) => m.role?.toLowerCase().includes('lead'));
const members = config.members ?? [];
const lead =
members.find((member) => isLeadMember(member)) ??
members.find((member) => member.name?.trim().toLowerCase() === 'lead') ??
members.find((member) => isExplicitLeadRole(member.role));
return lead?.name ?? config.members?.[0]?.name ?? 'team-lead';
}
@ -2729,9 +2755,9 @@ export class TeamDataService {
}
async requestReview(teamName: string, taskId: string): Promise<void> {
const { leadSessionId } = await this.resolveLeadRuntimeContext(teamName);
const { leadName, leadSessionId } = await this.resolveLeadRuntimeContext(teamName);
this.getController(teamName).review.requestReview(taskId, {
from: 'user',
from: leadName,
...(leadSessionId ? { leadSessionId } : {}),
});
}
@ -3194,15 +3220,15 @@ export class TeamDataService {
if (patch.op === 'set_column') {
if (patch.column === 'review') {
const { leadSessionId } = await this.resolveLeadRuntimeContext(teamName);
const { leadName, leadSessionId } = await this.resolveLeadRuntimeContext(teamName);
controller.review.requestReview(taskId, {
from: 'user',
from: leadName,
...(leadSessionId ? { leadSessionId } : {}),
});
} else {
const { leadSessionId } = await this.resolveLeadRuntimeContext(teamName);
const { leadName, leadSessionId } = await this.resolveLeadRuntimeContext(teamName);
controller.review.approveReview(taskId, {
from: 'user',
from: leadName,
suppressTaskComment: true,
'notify-owner': true,
...(leadSessionId ? { leadSessionId } : {}),
@ -3211,9 +3237,9 @@ export class TeamDataService {
return;
}
const { leadSessionId } = await this.resolveLeadRuntimeContext(teamName);
const { leadName, leadSessionId } = await this.resolveLeadRuntimeContext(teamName);
controller.review.requestChanges(taskId, {
from: 'user',
from: leadName,
comment: patch.comment?.trim() || 'Reviewer requested changes.',
...(patch.op === 'request_changes' && patch.taskRefs?.length
? { taskRefs: patch.taskRefs }

View file

@ -80,6 +80,23 @@ function normalizeLivenessKind(value: unknown): TeamAgentRuntimeLivenessKind | u
: undefined;
}
function preservesStrongRuntimeAlive(
value:
| {
runtimeAlive?: boolean;
bootstrapConfirmed?: boolean;
livenessKind?: TeamAgentRuntimeLivenessKind;
}
| undefined
): boolean {
return (
value?.runtimeAlive === true &&
(value.bootstrapConfirmed === true ||
value.livenessKind === 'confirmed_bootstrap' ||
value.livenessKind === 'runtime_process')
);
}
function normalizePidSource(value: unknown): TeamAgentRuntimePidSource | undefined {
return value === 'lead_process' ||
value === 'tmux_pane' ||
@ -181,7 +198,7 @@ export function summarizePersistedLaunchMembers(
continue;
}
pendingCount += 1;
if (entry.runtimeAlive) {
if (preservesStrongRuntimeAlive(entry)) {
runtimeAlivePendingCount += 1;
}
if (entry.launchState === 'runtime_pending_permission') {
@ -193,7 +210,11 @@ export function summarizePersistedLaunchMembers(
runtimeProcessPendingCount += 1;
} else if (entry.livenessKind === 'runtime_process_candidate') {
runtimeCandidatePendingCount += 1;
} else if (entry.livenessKind === 'not_found' || entry.livenessKind === 'stale_metadata') {
} else if (
entry.livenessKind === 'not_found' ||
entry.livenessKind === 'stale_metadata' ||
entry.livenessKind === 'registered_only'
) {
noRuntimePendingCount += 1;
}
}
@ -369,6 +390,18 @@ function normalizePersistedMemberState(
return null;
}
const providerId = normalizeOptionalTeamProviderId(parsed.providerId);
const bootstrapConfirmed =
toBoolean(parsed.bootstrapConfirmed) || parsed.launchState === 'confirmed_alive';
const livenessKind = normalizeLivenessKind(parsed.livenessKind);
const runtimeAlive = preservesStrongRuntimeAlive({
runtimeAlive: toBoolean(parsed.runtimeAlive),
bootstrapConfirmed,
livenessKind,
});
const sources = normalizeSources(parsed.sources) ?? {};
if (!runtimeAlive) {
sources.processAlive = undefined;
}
const next: PersistedTeamLaunchMemberState = {
name: normalizedName,
providerId,
@ -399,8 +432,8 @@ function normalizePersistedMemberState(
launchIdentity: normalizeLaunchIdentity(parsed.launchIdentity, providerId),
launchState: 'starting',
agentToolAccepted: toBoolean(parsed.agentToolAccepted),
runtimeAlive: toBoolean(parsed.runtimeAlive),
bootstrapConfirmed: toBoolean(parsed.bootstrapConfirmed),
runtimeAlive,
bootstrapConfirmed,
hardFailure: toBoolean(parsed.hardFailure),
hardFailureReason:
typeof parsed.hardFailureReason === 'string' && parsed.hardFailureReason.trim().length > 0
@ -411,7 +444,7 @@ function normalizePersistedMemberState(
),
runtimePid: normalizeRuntimePid(parsed.runtimePid),
runtimeSessionId: normalizeOptionalString(parsed.runtimeSessionId),
livenessKind: normalizeLivenessKind(parsed.livenessKind),
livenessKind,
pidSource: normalizePidSource(parsed.pidSource),
runtimeDiagnostic: normalizeOptionalString(parsed.runtimeDiagnostic),
runtimeDiagnosticSeverity: normalizeDiagnosticSeverity(parsed.runtimeDiagnosticSeverity),
@ -424,7 +457,7 @@ function normalizePersistedMemberState(
typeof parsed.lastRuntimeAliveAt === 'string' ? parsed.lastRuntimeAliveAt : undefined,
lastEvaluatedAt:
typeof parsed.lastEvaluatedAt === 'string' ? parsed.lastEvaluatedAt : updatedAtFallback,
sources: normalizeSources(parsed.sources),
sources: Object.values(sources).some(Boolean) ? sources : undefined,
diagnostics: Array.isArray(parsed.diagnostics)
? parsed.diagnostics.filter(
(item): item is string => typeof item === 'string' && item.trim().length > 0
@ -554,14 +587,15 @@ export function snapshotFromRuntimeMemberStatuses(params: {
sources.nativeHeartbeat = true;
sources.inboxHeartbeat = true;
}
if (runtime?.livenessSource === 'process' || runtime?.runtimeAlive) {
const runtimeAlive = preservesStrongRuntimeAlive(runtime);
if (runtime?.livenessSource === 'process' && runtimeAlive) {
sources.processAlive = true;
}
const entry: PersistedTeamLaunchMemberState = {
name,
launchState: runtime?.launchState ?? 'starting',
agentToolAccepted: runtime?.agentToolAccepted === true,
runtimeAlive: runtime?.runtimeAlive === true,
runtimeAlive,
bootstrapConfirmed: runtime?.bootstrapConfirmed === true,
hardFailure: runtime?.hardFailure === true || runtime?.launchState === 'failed_to_start',
hardFailureReason: runtime?.hardFailureReason ?? runtime?.error,
@ -574,7 +608,7 @@ export function snapshotFromRuntimeMemberStatuses(params: {
runtimeLastSeenAt: runtime?.livenessLastCheckedAt,
firstSpawnAcceptedAt: runtime?.firstSpawnAcceptedAt,
lastHeartbeatAt: runtime?.lastHeartbeatAt,
lastRuntimeAliveAt: runtime?.runtimeAlive ? updatedAt : undefined,
lastRuntimeAliveAt: runtimeAlive ? updatedAt : undefined,
lastEvaluatedAt: runtime?.updatedAt ?? updatedAt,
sources: Object.values(sources).some(Boolean) ? sources : undefined,
diagnostics: undefined,
@ -610,6 +644,7 @@ export function snapshotToMemberSpawnStatuses(
if (!entry) continue;
let status: MemberSpawnStatusEntry['status'] = 'offline';
let livenessSource: MemberSpawnLivenessSource | undefined;
const runtimeAlive = preservesStrongRuntimeAlive(entry);
if (entry.launchState === 'failed_to_start') {
status = 'error';
} else if (entry.launchState === 'confirmed_alive') {
@ -619,8 +654,8 @@ export function snapshotToMemberSpawnStatuses(
entry.launchState === 'runtime_pending_permission' ||
entry.launchState === 'runtime_pending_bootstrap'
) {
status = entry.runtimeAlive ? 'online' : 'waiting';
livenessSource = entry.runtimeAlive ? 'process' : undefined;
status = runtimeAlive ? 'online' : 'waiting';
livenessSource = runtimeAlive ? 'process' : undefined;
} else {
status = entry.agentToolAccepted ? 'waiting' : 'spawning';
}
@ -631,7 +666,7 @@ export function snapshotToMemberSpawnStatuses(
hardFailureReason: entry.hardFailureReason,
livenessSource,
agentToolAccepted: entry.agentToolAccepted,
runtimeAlive: entry.runtimeAlive,
runtimeAlive,
bootstrapConfirmed: entry.bootstrapConfirmed,
hardFailure: entry.hardFailure,
pendingPermissionRequestIds: entry.pendingPermissionRequestIds,

View file

@ -1,14 +0,0 @@
export type TeamMemberLivenessMode = 'diagnostics' | 'strict';
export const CLAUDE_TEAM_MEMBER_LIVENESS_MODE_ENV = 'CLAUDE_TEAM_MEMBER_LIVENESS_MODE';
export function resolveTeamMemberLivenessModeFromEnv(
env: NodeJS.ProcessEnv = process.env
): TeamMemberLivenessMode {
const raw = env[CLAUDE_TEAM_MEMBER_LIVENESS_MODE_ENV]?.trim().toLowerCase();
return raw === 'strict' ? 'strict' : 'diagnostics';
}
export function isStrictTeamMemberLivenessMode(env: NodeJS.ProcessEnv = process.env): boolean {
return resolveTeamMemberLivenessModeFromEnv(env) === 'strict';
}

View file

@ -177,14 +177,14 @@ import {
} from './TeamLaunchStateEvaluator';
import { TeamLaunchStateStore } from './TeamLaunchStateStore';
import { TeamMcpConfigBuilder } from './TeamMcpConfigBuilder';
import {
isStrongRuntimeEvidence,
resolveTeamMemberRuntimeLiveness,
} from './TeamRuntimeLivenessResolver';
import { isStrictTeamMemberLivenessMode } from './TeamMemberLivenessMode';
import { TeamMemberLogsFinder } from './TeamMemberLogsFinder';
import { TeamMembersMetaStore } from './TeamMembersMetaStore';
import { TeamMetaStore } from './TeamMetaStore';
import {
isStrongRuntimeEvidence,
resolveTeamMemberRuntimeLiveness,
sanitizeProcessCommandForDiagnostics,
} from './TeamRuntimeLivenessResolver';
import { TeamSentMessagesStore } from './TeamSentMessagesStore';
import { TeamTaskReader } from './TeamTaskReader';
import { TeamTranscriptProjectResolver } from './TeamTranscriptProjectResolver';
@ -425,6 +425,54 @@ function parseRuntimeToolMetadata(value: unknown): RuntimeToolMetadata {
};
}
function mentionsProcessTableUnavailable(value: string | undefined): boolean {
return /\bprocess table\b.*\bunavailable\b/i.test(value ?? '');
}
function buildRuntimeToolMetadataDiagnostics(metadata: RuntimeToolMetadata | undefined): string[] {
if (!metadata) {
return [];
}
const diagnostics: string[] = [];
if (metadata.runtimePid != null) {
diagnostics.push(`runtime pid: ${metadata.runtimePid}`);
}
if (metadata.processCommand) {
const processCommand = sanitizeProcessCommandForDiagnostics(metadata.processCommand);
if (processCommand) {
diagnostics.push(`runtime process command: ${processCommand}`);
}
}
if (metadata.runtimeVersion) {
diagnostics.push(`runtime version: ${metadata.runtimeVersion}`);
}
if (metadata.hostPid != null) {
diagnostics.push(`runtime host pid: ${metadata.hostPid}`);
}
if (metadata.cwd) {
diagnostics.push(`runtime cwd: ${metadata.cwd}`);
}
return diagnostics;
}
function buildRuntimeDiagnosticForSpawn(
metadata: LiveTeamAgentRuntimeMetadata
): string | undefined {
const baseDiagnostic = metadata.runtimeDiagnostic;
const processTableUnavailable =
mentionsProcessTableUnavailable(baseDiagnostic) ||
metadata.diagnostics?.some((diagnostic) => mentionsProcessTableUnavailable(diagnostic));
if (!processTableUnavailable) {
return baseDiagnostic;
}
if (mentionsProcessTableUnavailable(baseDiagnostic)) {
return baseDiagnostic;
}
return baseDiagnostic
? `${baseDiagnostic}; process table unavailable`
: 'process table unavailable';
}
function runtimeTaskRefs(teamName: string, value: unknown): InboxMessage['taskRefs'] | undefined {
const refs = normalizeRuntimeStringArray(value);
return refs.length > 0
@ -515,6 +563,32 @@ const PREFLIGHT_BINARY_TIMEOUT_MS = 8000;
const PREFLIGHT_AUTH_RETRY_DELAY_MS = 2000;
const PREFLIGHT_AUTH_MAX_RETRIES = 2;
const OPENCODE_PREFLIGHT_MODEL_PROBE_CONCURRENCY = 2;
const OPENCODE_PROJECT_EVIDENCE_MISSING_DIAGNOSTIC =
'OpenCode production E2E evidence artifact has no entry for the current working directory';
const OPENCODE_PROJECT_EVIDENCE_NOTE =
'OpenCode has not been verified on this project yet. This does not mean the selected models are broken.';
function pushUniqueProvisioningWarning(warnings: string[], warning: string): void {
if (!warnings.includes(warning)) {
warnings.push(warning);
}
}
function isOpenCodeProjectEvidenceMissingDiagnostic(value: string): boolean {
return value.trim() === OPENCODE_PROJECT_EVIDENCE_MISSING_DIAGNOSTIC;
}
function isOpenCodeProjectEvidenceMissingPrepareFailure(
prepare: TeamRuntimePrepareResult
): prepare is TeamRuntimePrepareResult & { ok: false } {
if (prepare.ok || prepare.reason !== 'e2e_missing') {
return false;
}
const diagnostics = prepare.diagnostics
.map((diagnostic) => diagnostic.trim())
.filter((diagnostic) => diagnostic.length > 0);
return diagnostics.length > 0 && diagnostics.every(isOpenCodeProjectEvidenceMissingDiagnostic);
}
function applyDistinctProvisioningMemberColors<
T extends { name: string; color?: string; removedAt?: number },
@ -1537,7 +1611,11 @@ function summarizeMemberSpawnStatusRecord(
runtimeProcessPendingCount += 1;
} else if (entry.livenessKind === 'runtime_process_candidate') {
runtimeCandidatePendingCount += 1;
} else if (entry.livenessKind === 'not_found' || entry.livenessKind === 'stale_metadata') {
} else if (
entry.livenessKind === 'not_found' ||
entry.livenessKind === 'stale_metadata' ||
entry.livenessKind === 'registered_only'
) {
noRuntimePendingCount += 1;
}
}
@ -3245,12 +3323,13 @@ function updateProgress(
function buildLaunchDiagnosticsFromRun(
run: ProvisioningRun
): TeamLaunchDiagnosticItem[] | undefined {
if (!run.isLaunch || run.memberSpawnStatuses.size === 0) {
const memberSpawnStatuses = run.memberSpawnStatuses;
if (!run.isLaunch || !memberSpawnStatuses || memberSpawnStatuses.size === 0) {
return undefined;
}
const observedAt = nowIso();
const items: TeamLaunchDiagnosticItem[] = [];
for (const [memberName, entry] of run.memberSpawnStatuses.entries()) {
for (const [memberName, entry] of memberSpawnStatuses.entries()) {
if (entry.launchState === 'confirmed_alive') {
items.push({
id: `${memberName}:bootstrap_confirmed`,
@ -3286,6 +3365,18 @@ function buildLaunchDiagnosticsFromRun(
});
continue;
}
if (mentionsProcessTableUnavailable(entry.runtimeDiagnostic)) {
items.push({
id: `${memberName}:process_table_unavailable`,
memberName,
severity: 'warning',
code: 'process_table_unavailable',
label: `${memberName} - process table unavailable`,
detail: entry.runtimeDiagnostic,
observedAt,
});
continue;
}
if (entry.livenessKind === 'shell_only') {
items.push({
id: `${memberName}:tmux_shell_only`,
@ -3322,6 +3413,22 @@ function buildLaunchDiagnosticsFromRun(
});
continue;
}
if (
entry.livenessKind === 'registered_only' ||
entry.livenessKind === 'stale_metadata' ||
entry.livenessKind === 'not_found'
) {
items.push({
id: `${memberName}:runtime_not_found`,
memberName,
severity: 'warning',
code: 'runtime_not_found',
label: `${memberName} - no runtime found`,
detail: entry.runtimeDiagnostic,
observedAt,
});
continue;
}
if (entry.agentToolAccepted) {
items.push({
id: `${memberName}:spawn_accepted`,
@ -3679,7 +3786,12 @@ export class TeamProvisioningService {
private readonly runtimeAdapterProgressByRunId = new Map<string, TeamProvisioningProgress>();
private readonly runtimeAdapterRunByTeam = new Map<
string,
{ runId: string; providerId: TeamProviderId; cwd?: string }
{
runId: string;
providerId: TeamProviderId;
cwd?: string;
members?: Record<string, TeamRuntimeMemberLaunchEvidence>;
}
>();
private readonly cancelledRuntimeAdapterRunIds = new Set<string>();
private stopAllTeamsGeneration = 0;
@ -5882,7 +5994,10 @@ export class TeamProvisioningService {
},
diagnostics: mergeRuntimeDiagnostics(
previousMember?.diagnostics,
input.diagnostics,
[
...normalizeRuntimeStringArray(input.diagnostics),
...buildRuntimeToolMetadataDiagnostics(input.metadata),
],
input.reason
),
};
@ -6498,17 +6613,13 @@ export class TeamProvisioningService {
}
this.agentRuntimeSnapshotCache.delete(run.teamName);
this.liveTeamAgentRuntimeMetadataCache.delete(run.teamName);
if (isStrictTeamMemberLivenessMode()) {
this.setMemberSpawnStatus(run, spawnedMemberName, 'waiting');
this.appendMemberBootstrapDiagnostic(
run,
spawnedMemberName,
'already_running requires strong runtime verification'
);
void this.reevaluateMemberLaunchStatus(run, spawnedMemberName);
return;
}
this.setMemberSpawnStatus(run, spawnedMemberName, 'online', undefined, 'process');
this.setMemberSpawnStatus(run, spawnedMemberName, 'waiting');
this.appendMemberBootstrapDiagnostic(
run,
spawnedMemberName,
'already_running requires strong runtime verification'
);
void this.reevaluateMemberLaunchStatus(run, spawnedMemberName);
} else {
this.setMemberSpawnStatus(run, spawnedMemberName, 'waiting');
}
@ -6924,7 +7035,6 @@ export class TeamProvisioningService {
}
const updatedAt = nowIso();
const strictLiveness = isStrictTeamMemberLivenessMode();
const run = runId ? (this.runs.get(runId) ?? null) : null;
const persistedTeamMeta = await this.teamMetaStore.getMeta(teamName).catch(() => null);
@ -7066,14 +7176,9 @@ export class TeamProvisioningService {
: isSharedOpenCodeHost
? false
: backendType !== 'in-process';
const launchSnapshotAlive =
this.isTeamAlive(teamName) &&
(strictLiveness
? launchMember?.bootstrapConfirmed === true ||
launchMember?.launchState === 'confirmed_alive'
: launchMember?.runtimeAlive === true ||
launchMember?.bootstrapConfirmed === true ||
launchMember?.launchState === 'confirmed_alive');
const historicalBootstrapConfirmed =
launchMember?.bootstrapConfirmed === true ||
launchMember?.launchState === 'confirmed_alive';
let rssBytes = rssPid ? rssBytesByPid.get(rssPid) : undefined;
if (rssBytes == null && isSharedOpenCodeHost && typeof rssPid === 'number' && rssPid > 0) {
try {
@ -7089,7 +7194,7 @@ export class TeamProvisioningService {
snapshotMembers[memberName] = {
memberName,
alive: liveRuntimeMember?.alive === true || launchSnapshotAlive,
alive: liveRuntimeMember?.alive === true,
restartable,
...(backendType ? { backendType } : {}),
...(memberProviderId ? { providerId: memberProviderId } : {}),
@ -7120,6 +7225,7 @@ export class TeamProvisioningService {
...(liveRuntimeMember?.runtimeLastSeenAt
? { runtimeLastSeenAt: liveRuntimeMember.runtimeLastSeenAt }
: {}),
...(historicalBootstrapConfirmed ? { historicalBootstrapConfirmed: true } : {}),
...(liveRuntimeMember?.runtimeDiagnostic
? { runtimeDiagnostic: liveRuntimeMember.runtimeDiagnostic }
: {}),
@ -7593,8 +7699,7 @@ export class TeamProvisioningService {
if (!refreshed) return;
if (
refreshed.launchState === 'failed_to_start' ||
refreshed.launchState === 'confirmed_alive' ||
refreshed.runtimeAlive
refreshed.launchState === 'confirmed_alive'
) {
return;
}
@ -7602,81 +7707,92 @@ export class TeamProvisioningService {
if (!refreshedFirstSpawnAcceptedAt) {
return;
}
if (isStrictTeamMemberLivenessMode()) {
const runtimeByMember = await this.getLiveTeamAgentRuntimeMetadata(run.teamName);
const metadata =
runtimeByMember.get(memberName) ??
[...runtimeByMember.entries()].find(([candidateName]) =>
matchesObservedMemberNameForExpected(candidateName, memberName)
)?.[1];
const acceptedAtMs = Date.parse(refreshedFirstSpawnAcceptedAt);
const elapsedMs = Number.isFinite(acceptedAtMs) ? Date.now() - acceptedAtMs : Infinity;
const runtimeDiagnostic = metadata?.runtimeDiagnostic;
if (metadata?.livenessKind === 'runtime_process') {
this.setMemberSpawnStatus(run, memberName, 'online', undefined, 'process');
return;
}
if (metadata?.livenessKind === 'permission_blocked') {
const next = {
const restartPending = run.pendingMemberRestarts.has(memberName);
const runtimeByMember = await this.getLiveTeamAgentRuntimeMetadata(run.teamName);
const metadata =
runtimeByMember.get(memberName) ??
[...runtimeByMember.entries()].find(([candidateName]) =>
matchesObservedMemberNameForExpected(candidateName, memberName)
)?.[1];
const acceptedAtMs = Date.parse(refreshedFirstSpawnAcceptedAt);
const elapsedMs = Number.isFinite(acceptedAtMs) ? Date.now() - acceptedAtMs : Infinity;
const runtimeDiagnostic = metadata?.runtimeDiagnostic;
if (metadata?.livenessKind === 'runtime_process') {
if (elapsedMs >= MEMBER_BOOTSTRAP_STALL_MS) {
run.memberSpawnStatuses.set(memberName, {
...refreshed,
livenessKind: metadata.livenessKind,
runtimeDiagnostic: runtimeDiagnostic ?? 'waiting for permission approval',
runtimeDiagnosticSeverity: metadata.runtimeDiagnosticSeverity ?? 'warning',
runtimeDiagnostic: 'Runtime process is alive, but no bootstrap check-in after 5 min.',
runtimeDiagnosticSeverity: 'warning',
livenessLastCheckedAt: nowIso(),
launchState: 'runtime_pending_permission' as const,
};
run.memberSpawnStatuses.set(memberName, next);
this.emitMemberSpawnChange(run, memberName);
return;
});
}
if (
metadata?.livenessKind === 'runtime_process_candidate' &&
elapsedMs < MEMBER_BOOTSTRAP_STALL_MS
) {
const next = {
...refreshed,
livenessKind: metadata.livenessKind,
runtimeDiagnostic: runtimeDiagnostic ?? 'runtime process candidate detected',
runtimeDiagnosticSeverity: metadata.runtimeDiagnosticSeverity ?? 'warning',
livenessLastCheckedAt: nowIso(),
};
run.memberSpawnStatuses.set(memberName, next);
this.emitMemberSpawnChange(run, memberName);
const stallDelayMs = Math.max(
1_000,
Date.parse(refreshedFirstSpawnAcceptedAt) + MEMBER_BOOTSTRAP_STALL_MS - Date.now()
);
const stallKey = `${this.getMemberLaunchGraceKey(run, memberName)}:bootstrap-stall`;
if (!this.pendingTimeouts.has(stallKey)) {
const timer = setTimeout(() => {
this.pendingTimeouts.delete(stallKey);
void this.reevaluateMemberLaunchStatus(run, memberName);
}, stallDelayMs);
timer.unref?.();
this.pendingTimeouts.set(stallKey, timer);
}
return;
}
const strictReason =
runtimeDiagnostic ??
(metadata?.livenessKind === 'shell_only'
? 'Tmux pane is alive, but no teammate runtime process was found.'
: 'Teammate did not join within the launch grace window.');
this.setMemberSpawnStatus(run, memberName, 'error', strictReason);
this.setMemberSpawnStatus(run, memberName, 'online', undefined, 'process');
return;
}
const restartPending = run.pendingMemberRestarts.has(memberName);
if (metadata?.livenessKind === 'permission_blocked') {
const next = {
...refreshed,
livenessKind: metadata.livenessKind,
runtimeDiagnostic: runtimeDiagnostic ?? 'waiting for permission approval',
runtimeDiagnosticSeverity: metadata.runtimeDiagnosticSeverity ?? 'warning',
livenessLastCheckedAt: nowIso(),
launchState: 'runtime_pending_permission' as const,
};
run.memberSpawnStatuses.set(memberName, next);
this.emitMemberSpawnChange(run, memberName);
return;
}
if (
metadata?.livenessKind === 'runtime_process_candidate' &&
elapsedMs < MEMBER_BOOTSTRAP_STALL_MS
) {
const next = {
...refreshed,
livenessKind: metadata.livenessKind,
runtimeDiagnostic: runtimeDiagnostic ?? 'runtime process candidate detected',
runtimeDiagnosticSeverity: metadata.runtimeDiagnosticSeverity ?? 'warning',
livenessLastCheckedAt: nowIso(),
};
run.memberSpawnStatuses.set(memberName, next);
this.emitMemberSpawnChange(run, memberName);
const stallDelayMs = Math.max(
1_000,
Date.parse(refreshedFirstSpawnAcceptedAt) + MEMBER_BOOTSTRAP_STALL_MS - Date.now()
);
const stallKey = `${this.getMemberLaunchGraceKey(run, memberName)}:bootstrap-stall`;
if (!this.pendingTimeouts.has(stallKey)) {
const timer = setTimeout(() => {
this.pendingTimeouts.delete(stallKey);
void this.reevaluateMemberLaunchStatus(run, memberName);
}, stallDelayMs);
timer.unref?.();
this.pendingTimeouts.set(stallKey, timer);
}
return;
}
const strictReason = restartPending
? buildRestartGraceTimeoutReason(memberName)
: (runtimeDiagnostic ??
(metadata?.livenessKind === 'shell_only'
? 'Tmux pane is alive, but no teammate runtime process was found.'
: 'Teammate did not join within the launch grace window.'));
if (restartPending) {
run.pendingMemberRestarts.delete(memberName);
}
this.setMemberSpawnStatus(
run,
memberName,
'error',
restartPending
? buildRestartGraceTimeoutReason(memberName)
: 'Teammate did not join within the launch grace window.'
);
run.memberSpawnStatuses.set(memberName, {
...refreshed,
runtimeAlive: false,
livenessSource: undefined,
bootstrapConfirmed: false,
...(metadata?.livenessKind ? { livenessKind: metadata.livenessKind } : {}),
...(runtimeDiagnostic ? { runtimeDiagnostic } : {}),
...(metadata?.runtimeDiagnosticSeverity
? { runtimeDiagnosticSeverity: metadata.runtimeDiagnosticSeverity }
: {}),
livenessLastCheckedAt: nowIso(),
});
this.setMemberSpawnStatus(run, memberName, 'error', strictReason);
}
private shouldSkipMemberSpawnAudit(run: ProvisioningRun): boolean {
@ -10152,6 +10268,7 @@ export class TeamProvisioningService {
runId,
providerId: 'opencode',
cwd: input.request.cwd,
members: result.members,
});
this.aliveRunByTeam.set(input.request.teamName, runId);
}
@ -11978,7 +12095,6 @@ export class TeamProvisioningService {
async getRuntimeState(teamName: string): Promise<TeamRuntimeState> {
const runId = this.getTrackedRunId(teamName);
const run = runId ? (this.runs.get(runId) ?? null) : null;
const strictLiveness = isStrictTeamMemberLivenessMode();
if (!run) {
const recovered = await readBootstrapRuntimeState(teamName);
@ -12221,6 +12337,11 @@ export class TeamProvisioningService {
// Read config.json to get the actual registered members
const registeredNames = await this.getRegisteredTeamMemberNames(run.teamName);
if (!registeredNames) {
try {
await fs.promises.access(path.join(getTeamsBasePath(), run.teamName));
} catch {
return;
}
const now = Date.now();
if (
shouldWarnOnUnreadableMemberAuditConfig({
@ -12351,7 +12472,6 @@ export class TeamProvisioningService {
statuses: Record<string, MemberSpawnStatusEntry>
): Promise<Record<string, MemberSpawnStatusEntry>> {
const runtimeByMember = await this.getLiveTeamAgentRuntimeMetadata(teamName);
const strictLiveness = isStrictTeamMemberLivenessMode();
const nextStatuses = { ...statuses };
for (const [memberName, metadata] of runtimeByMember.entries()) {
const resolvedStatusKey =
@ -12370,20 +12490,23 @@ export class TeamProvisioningService {
if (!current) {
continue;
}
const runtimeDiagnostic = buildRuntimeDiagnosticForSpawn(metadata);
const nextEntry: MemberSpawnStatusEntry = {
...current,
...(metadata.model ? { runtimeModel: metadata.model } : {}),
...(metadata.livenessKind ? { livenessKind: metadata.livenessKind } : {}),
...(metadata.runtimeDiagnostic ? { runtimeDiagnostic: metadata.runtimeDiagnostic } : {}),
...(runtimeDiagnostic ? { runtimeDiagnostic } : {}),
...(metadata.runtimeDiagnosticSeverity
? { runtimeDiagnosticSeverity: metadata.runtimeDiagnosticSeverity }
: {}),
livenessLastCheckedAt: nowIso(),
};
const failureReason = current.hardFailureReason ?? current.error;
const hasStrongEvidence = strictLiveness
? isStrongRuntimeEvidence(metadata)
: metadata.alive === true;
const hasStrongEvidence = isStrongRuntimeEvidence(metadata);
const hasWeakEvidence =
metadata.livenessKind != null &&
!isStrongRuntimeEvidence(metadata) &&
current.bootstrapConfirmed !== true;
if (
hasStrongEvidence &&
current.hardFailure !== true &&
@ -12412,6 +12535,26 @@ export class TeamProvisioningService {
nextEntry.livenessSource = current.bootstrapConfirmed ? current.livenessSource : 'process';
nextEntry.launchState = deriveMemberLaunchState(nextEntry);
}
if (hasWeakEvidence) {
nextEntry.runtimeAlive = false;
if (nextEntry.livenessSource === 'process') {
nextEntry.livenessSource = undefined;
}
if (
current.launchState === 'runtime_pending_bootstrap' ||
current.launchState === 'runtime_pending_permission'
) {
nextEntry.agentToolAccepted = true;
}
if (
current.status === 'online' &&
current.hardFailure !== true &&
current.launchState !== 'failed_to_start'
) {
nextEntry.status = nextEntry.agentToolAccepted ? 'waiting' : 'spawning';
}
nextEntry.launchState = deriveMemberLaunchState(nextEntry);
}
nextStatuses[resolvedStatusKey] = nextEntry;
}
return nextStatuses;
@ -12669,7 +12812,6 @@ export class TeamProvisioningService {
if (cached && cached.expiresAtMs > Date.now()) {
return cached.metadata;
}
const strictLiveness = isStrictTeamMemberLivenessMode();
const runId = this.getTrackedRunId(teamName);
const run = runId ? (this.runs.get(runId) ?? null) : null;
@ -12817,7 +12959,7 @@ export class TeamProvisioningService {
upsertMetadata(memberName, {
backendType: 'process',
providerId: 'opencode',
alive: evidence?.runtimeAlive === true,
alive: false,
livenessKind: evidence?.livenessKind,
pidSource: evidence?.pidSource,
runtimeDiagnostic: evidence?.runtimeDiagnostic,
@ -12829,34 +12971,42 @@ export class TeamProvisioningService {
});
}
const currentRuntimeAdapterRun = this.runtimeAdapterRunByTeam.get(teamName);
const persistedLaunchSnapshot = await this.launchStateStore.read(teamName).catch(() => null);
for (const persistedMember of Object.values(persistedLaunchSnapshot?.members ?? {})) {
const memberName = persistedMember.name?.trim() ?? '';
if (!memberName || this.isMemberRemovedInMeta(metaMembers, memberName)) {
continue;
}
const currentRuntimeAdapterEvidence = currentRuntimeAdapterRun?.members?.[memberName];
upsertMetadata(memberName, {
backendType:
persistedMember.providerId === 'opencode'
? 'process'
: metadataByMember.get(memberName)?.backendType,
providerId: persistedMember.providerId,
alive: persistedMember.runtimeAlive === true || persistedMember.bootstrapConfirmed === true,
livenessKind: persistedMember.livenessKind,
pidSource: persistedMember.pidSource,
runtimeDiagnostic: persistedMember.runtimeDiagnostic,
alive: false,
livenessKind: currentRuntimeAdapterEvidence?.livenessKind ?? persistedMember.livenessKind,
pidSource: currentRuntimeAdapterEvidence?.pidSource ?? persistedMember.pidSource,
runtimeDiagnostic:
currentRuntimeAdapterEvidence?.runtimeDiagnostic ?? persistedMember.runtimeDiagnostic,
runtimeDiagnosticSeverity: persistedMember.runtimeDiagnosticSeverity,
runtimeLastSeenAt:
persistedMember.runtimeLastSeenAt ??
persistedMember.lastHeartbeatAt ??
persistedMember.lastRuntimeAliveAt,
...(persistedMember.model?.trim() ? { model: persistedMember.model.trim() } : {}),
...(typeof persistedMember.runtimePid === 'number' && persistedMember.runtimePid > 0
? { metricsPid: persistedMember.runtimePid }
: {}),
...(persistedMember.runtimeSessionId
? { runtimeSessionId: persistedMember.runtimeSessionId }
: {}),
...(typeof currentRuntimeAdapterEvidence?.runtimePid === 'number' &&
currentRuntimeAdapterEvidence.runtimePid > 0
? { metricsPid: currentRuntimeAdapterEvidence.runtimePid }
: typeof persistedMember.runtimePid === 'number' && persistedMember.runtimePid > 0
? { metricsPid: persistedMember.runtimePid }
: {}),
...(currentRuntimeAdapterEvidence?.sessionId
? { runtimeSessionId: currentRuntimeAdapterEvidence.sessionId }
: persistedMember.runtimeSessionId
? { runtimeSessionId: persistedMember.runtimeSessionId }
: {}),
});
}
@ -12891,8 +13041,37 @@ export class TeamProvisioningService {
for (const [memberName, metadata] of metadataByMember.entries()) {
const paneId = metadata.tmuxPaneId?.trim() ?? '';
const status = this.findTrackedMemberSpawnStatus(run, memberName);
const launchMember = persistedLaunchSnapshot?.members[memberName];
const adapterEvidence = currentRuntimeAdapterRun?.members?.[memberName];
const adapterStatus: MemberSpawnStatusEntry | undefined = adapterEvidence
? {
status: adapterEvidence.hardFailure
? 'error'
: adapterEvidence.bootstrapConfirmed
? 'online'
: adapterEvidence.agentToolAccepted
? 'waiting'
: 'spawning',
launchState: adapterEvidence.launchState,
...(adapterEvidence.hardFailureReason
? { hardFailureReason: adapterEvidence.hardFailureReason }
: {}),
...(adapterEvidence.pendingPermissionRequestIds?.length
? { pendingPermissionRequestIds: adapterEvidence.pendingPermissionRequestIds }
: {}),
agentToolAccepted: adapterEvidence.agentToolAccepted,
runtimeAlive: adapterEvidence.runtimeAlive,
bootstrapConfirmed: adapterEvidence.bootstrapConfirmed,
hardFailure: adapterEvidence.hardFailure,
...(metadata.model ? { runtimeModel: metadata.model } : {}),
...(adapterEvidence.livenessKind ? { livenessKind: adapterEvidence.livenessKind } : {}),
...(adapterEvidence.runtimeDiagnostic
? { runtimeDiagnostic: adapterEvidence.runtimeDiagnostic }
: {}),
updatedAt: persistedLaunchSnapshot?.updatedAt ?? nowIso(),
}
: undefined;
const status = this.findTrackedMemberSpawnStatus(run, memberName) ?? adapterStatus;
const resolved = resolveTeamMemberRuntimeLiveness({
teamName,
memberName,
@ -12910,15 +13089,9 @@ export class TeamProvisioningService {
processTableAvailable,
nowIso: nowIso(),
});
const legacyWeakAlive =
resolved.alive ||
(resolved.pidSource === 'tmux_pane' && typeof resolved.pid === 'number') ||
(metadata.backendType === 'process' &&
typeof metadata.metricsPid === 'number' &&
metadata.metricsPid > 0);
metadataByMember.set(memberName, {
...metadata,
alive: strictLiveness ? resolved.alive : legacyWeakAlive,
alive: resolved.alive,
...(typeof resolved.pid === 'number' && resolved.pid > 0 ? { pid: resolved.pid } : {}),
...(typeof (resolved.metricsPid ?? metadata.metricsPid) === 'number' &&
Number.isFinite(resolved.metricsPid ?? metadata.metricsPid) &&
@ -13050,7 +13223,11 @@ export class TeamProvisioningService {
runtimeProcessPendingCount += 1;
} else if (entry.livenessKind === 'runtime_process_candidate') {
runtimeCandidatePendingCount += 1;
} else if (entry.livenessKind === 'not_found' || entry.livenessKind === 'stale_metadata') {
} else if (
entry.livenessKind === 'not_found' ||
entry.livenessKind === 'stale_metadata' ||
entry.livenessKind === 'registered_only'
) {
noRuntimePendingCount += 1;
}
}
@ -13100,10 +13277,8 @@ export class TeamProvisioningService {
}`;
}
const stillStartingCount = Math.max(
0,
launchSummary.pendingCount - launchSummary.runtimeAlivePendingCount
);
const runtimeProcessPendingCount = launchSummary.runtimeProcessPendingCount ?? 0;
const stillStartingCount = Math.max(0, launchSummary.pendingCount - runtimeProcessPendingCount);
const diagnosticParts = [
launchSummary.shellOnlyPendingCount
? `${launchSummary.shellOnlyPendingCount} shell-only`
@ -13121,16 +13296,15 @@ export class TeamProvisioningService {
const diagnosticSuffix = diagnosticParts.length > 0 ? ` - ${diagnosticParts.join(', ')}` : '';
if (launchSummary.confirmedCount === 0) {
const allRuntimeAlive =
launchSummary.runtimeAlivePendingCount > 0 &&
launchSummary.runtimeAlivePendingCount === expectedTeammateCount;
runtimeProcessPendingCount > 0 && runtimeProcessPendingCount === expectedTeammateCount;
return allRuntimeAlive
? `${prefix} — teammates online`
: launchSummary.runtimeAlivePendingCount > 0
? `${prefix}${launchSummary.runtimeAlivePendingCount}/${expectedTeammateCount} teammate${launchSummary.runtimeAlivePendingCount === 1 ? '' : 's'} online${stillStartingCount > 0 ? `, ${stillStartingCount} still starting` : ''}`
: runtimeProcessPendingCount > 0
? `${prefix}${runtimeProcessPendingCount}/${expectedTeammateCount} teammate${runtimeProcessPendingCount === 1 ? '' : 's'} online${stillStartingCount > 0 ? `, ${stillStartingCount} still starting` : ''}`
: `${prefix} — teammates are still starting${diagnosticSuffix}`;
}
return `${prefix}${launchSummary.confirmedCount}/${expectedTeammateCount} teammates made contact${launchSummary.runtimeAlivePendingCount > 0 ? `, ${launchSummary.runtimeAlivePendingCount} teammate${launchSummary.runtimeAlivePendingCount === 1 ? '' : 's'} online` : ''}${stillStartingCount > 0 ? `${launchSummary.runtimeAlivePendingCount > 0 ? ', ' : ', '}${stillStartingCount} still joining${diagnosticSuffix}` : ''}`;
return `${prefix}${launchSummary.confirmedCount}/${expectedTeammateCount} teammates made contact${runtimeProcessPendingCount > 0 ? `, ${runtimeProcessPendingCount} teammate${runtimeProcessPendingCount === 1 ? '' : 's'} online` : ''}${stillStartingCount > 0 ? `${runtimeProcessPendingCount > 0 ? ', ' : ', '}${stillStartingCount} still joining${diagnosticSuffix}` : ''}`;
}
private buildAggregatePendingLaunchMessage(
@ -13141,6 +13315,7 @@ export class TeamProvisioningService {
pendingCount: number;
failedCount: number;
runtimeAlivePendingCount: number;
runtimeProcessPendingCount?: number;
},
snapshot?: PersistedTeamLaunchSnapshot | null
): string {
@ -13554,10 +13729,14 @@ export class TeamProvisioningService {
if (filteredSnapshot.teamLaunchState === 'clean_success' && launchPhase !== 'active') {
await this.clearPersistedLaunchState(run.teamName);
this.agentRuntimeSnapshotCache.delete(run.teamName);
this.liveTeamAgentRuntimeMetadataCache.delete(run.teamName);
return null;
}
await this.launchStateStore.write(run.teamName, filteredSnapshot);
this.agentRuntimeSnapshotCache.delete(run.teamName);
this.liveTeamAgentRuntimeMetadataCache.delete(run.teamName);
return filteredSnapshot;
}
@ -14242,7 +14421,7 @@ export class TeamProvisioningService {
};
}
const liveAgentNames = await this.getLiveTeamAgentNames(teamName);
const liveRuntimeByMember = await this.getLiveTeamAgentRuntimeMetadata(teamName);
const nextMembers = { ...filteredPersisted.members };
const persistedMemberNames = this.getPersistedLaunchMemberNames(filteredPersisted);
const now = nowIso();
@ -14262,11 +14441,6 @@ export class TeamProvisioningService {
current.firstSpawnAcceptedAt =
current.firstSpawnAcceptedAt ?? bootstrapMember.firstSpawnAcceptedAt;
}
if (bootstrapMember?.runtimeAlive && !current.runtimeAlive) {
current.runtimeAlive = true;
current.lastRuntimeAliveAt =
current.lastRuntimeAliveAt ?? bootstrapMember.lastRuntimeAliveAt;
}
if (bootstrapMember?.bootstrapConfirmed && !current.bootstrapConfirmed) {
current.bootstrapConfirmed = true;
current.lastHeartbeatAt = current.lastHeartbeatAt ?? bootstrapMember.lastHeartbeatAt;
@ -14274,10 +14448,13 @@ export class TeamProvisioningService {
const matchedConfigNames = [...configMembers].filter((name) =>
matchesObservedMemberNameForExpected(name, expected)
);
const observedRuntimeAlive = [...liveAgentNames].some((name) =>
const runtimeMetadataCandidates = [...liveRuntimeByMember.entries()].filter(([name]) =>
matchesObservedMemberNameForExpected(name, expected)
);
const runtimeAlive = current.runtimeAlive === true || observedRuntimeAlive;
const runtimeMetadata =
runtimeMetadataCandidates.find(([, metadata]) => metadata.alive) ??
runtimeMetadataCandidates[0];
const observedRuntimeAlive = runtimeMetadata?.[1].alive === true;
const heartbeatMessage = this.selectLatestLeadInboxLaunchReconcileMessage(
leadInboxMessages,
persistedMemberNames,
@ -14289,11 +14466,15 @@ export class TeamProvisioningService {
: null;
const acceptedAtMs =
current.firstSpawnAcceptedAt != null ? Date.parse(current.firstSpawnAcceptedAt) : NaN;
current.runtimeAlive = runtimeAlive;
current.runtimeAlive = observedRuntimeAlive;
current.lastRuntimeAliveAt = observedRuntimeAlive ? now : current.lastRuntimeAliveAt;
current.livenessKind = runtimeMetadata?.[1].livenessKind;
current.pidSource = runtimeMetadata?.[1].pidSource;
current.runtimeDiagnostic = runtimeMetadata?.[1].runtimeDiagnostic;
current.runtimeDiagnosticSeverity = runtimeMetadata?.[1].runtimeDiagnosticSeverity;
current.sources = {
...(current.sources ?? {}),
processAlive: runtimeAlive || undefined,
processAlive: observedRuntimeAlive || undefined,
configRegistered: matchedConfigNames.length > 0 || undefined,
configDrift:
heartbeatMessage != null && matchedConfigNames.length === 0
@ -15050,13 +15231,14 @@ export class TeamProvisioningService {
run.processKilled = true;
run.cancelRequested = true;
killTeamProcess(run.child);
if (this.hasSecondaryRuntimeRuns(teamName)) {
await this.stopMixedSecondaryRuntimeLanes(teamName);
}
const stopSecondaryRuntimeLanes = this.hasSecondaryRuntimeRuns(teamName)
? this.stopMixedSecondaryRuntimeLanes(teamName)
: null;
const progress = updateProgress(run, 'disconnected', 'Team stopped by user');
run.onProgress(progress);
this.cleanupRun(run);
logger.info(`[${teamName}] Process stopped (SIGKILL)`);
await stopSecondaryRuntimeLanes;
}
private getShutdownTrackedTeamNames(): string[] {
@ -15448,8 +15630,8 @@ export class TeamProvisioningService {
killTrackedCliProcesses('SIGKILL');
this.killTransientProbeProcessesForShutdown();
await this.cancelPendingRuntimeAdapterLaunchesForShutdown();
const initialTracked = await this.stopTrackedTeamsForShutdown('Shutdown');
await this.cancelPendingRuntimeAdapterLaunchesForShutdown();
// A create/launch may have been inside a per-team lock before it exposed a
// run in provisioningRunByTeam. Wait briefly, then rescan to catch anything
@ -15584,7 +15766,15 @@ export class TeamProvisioningService {
);
return true;
}
this.setMemberSpawnStatus(run, memberName, 'online', undefined, 'process');
this.agentRuntimeSnapshotCache.delete(run.teamName);
this.liveTeamAgentRuntimeMetadataCache.delete(run.teamName);
this.setMemberSpawnStatus(run, memberName, 'waiting');
this.appendMemberBootstrapDiagnostic(
run,
memberName,
'already_running requires strong runtime verification'
);
void this.reevaluateMemberLaunchStatus(run, memberName);
return true;
}

View file

@ -128,6 +128,10 @@ function isVerifiedRuntimeProcess(params: {
);
}
function isOpenCodeRuntimeProcess(command: string | undefined): boolean {
return (command ?? '').toLowerCase().includes('opencode');
}
function hasPersistedEvidence(input: ResolveTeamMemberRuntimeLivenessInput): boolean {
return Boolean(
input.agentId?.trim() ||
@ -186,18 +190,6 @@ export function resolveTeamMemberRuntimeLiveness(
diagnostics.push('process table unavailable');
}
if (tracked?.bootstrapConfirmed === true || tracked?.launchState === 'confirmed_alive') {
return result({
alive: true,
livenessKind: 'confirmed_bootstrap',
pidSource: 'runtime_bootstrap',
runtimeSessionId,
runtimeLastSeenAt: tracked.lastHeartbeatAt ?? tracked.updatedAt,
runtimeDiagnostic: 'bootstrap confirmed',
diagnostics: [...diagnostics, 'bootstrap confirmed'],
});
}
if (
tracked?.launchState === 'runtime_pending_permission' ||
(tracked?.pendingPermissionRequestIds?.length ?? 0) > 0
@ -236,15 +228,44 @@ export function resolveTeamMemberRuntimeLiveness(
? input.processRows.find((row) => row.pid === runtimePid)
: undefined;
if (runtimePidRow && input.providerId === 'opencode') {
const processCommand = sanitizeProcessCommandForDiagnostics(runtimePidRow.command);
if (isOpenCodeRuntimeProcess(runtimePidRow.command)) {
return result({
alive: true,
livenessKind: 'runtime_process',
pidSource: 'opencode_bridge',
pid: runtimePidRow.pid,
runtimeSessionId,
processCommand,
runtimeDiagnostic: 'OpenCode runtime process detected',
diagnostics: [...diagnostics, 'matched OpenCode runtime pid and process identity'],
});
}
return result({
alive: true,
livenessKind: 'runtime_process',
alive: false,
livenessKind: 'runtime_process_candidate',
pidSource: 'opencode_bridge',
pid: runtimePidRow.pid,
runtimeSessionId,
processCommand: sanitizeProcessCommandForDiagnostics(runtimePidRow.command),
runtimeDiagnostic: 'OpenCode runtime process detected',
diagnostics: [...diagnostics, 'matched OpenCode runtime pid in process table'],
processCommand,
runtimeDiagnostic: 'OpenCode runtime pid is alive, but process identity is unverified',
runtimeDiagnosticSeverity: 'warning',
diagnostics: [
...diagnostics,
'matched OpenCode runtime pid without OpenCode process identity',
],
});
}
if (tracked?.bootstrapConfirmed === true || tracked?.launchState === 'confirmed_alive') {
return result({
alive: true,
livenessKind: 'confirmed_bootstrap',
pidSource: 'runtime_bootstrap',
runtimeSessionId,
runtimeLastSeenAt: tracked.lastHeartbeatAt ?? tracked.updatedAt,
runtimeDiagnostic: 'bootstrap confirmed',
diagnostics: [...diagnostics, 'bootstrap confirmed'],
});
}
@ -311,6 +332,18 @@ export function resolveTeamMemberRuntimeLiveness(
}
if (runtimePid && !runtimePidRow) {
if (!input.processTableAvailable) {
return result({
alive: false,
livenessKind: 'registered_only',
pidSource: 'persisted_metadata',
pid: runtimePid,
runtimeSessionId,
runtimeDiagnostic: 'runtime pid could not be verified because process table is unavailable',
runtimeDiagnosticSeverity: 'warning',
diagnostics: [...diagnostics, 'runtime pid could not be verified'],
});
}
return result({
alive: false,
livenessKind: 'stale_metadata',

View file

@ -170,6 +170,11 @@ export class TeamTaskReader {
completedAt: i.completedAt,
}))
: undefined;
const status = (['pending', 'in_progress', 'completed', 'deleted'] as const).includes(
parsed.status as TeamTask['status']
)
? (parsed.status as TeamTask['status'])
: 'pending';
const task: TeamTask = {
id:
typeof parsed.id === 'string' || typeof parsed.id === 'number' ? String(parsed.id) : '',
@ -192,11 +197,7 @@ export class TeamTaskReader {
promptTaskRefs: normalizeTaskRefs(parsed.promptTaskRefs),
owner: typeof parsed.owner === 'string' ? parsed.owner : undefined,
createdBy: typeof parsed.createdBy === 'string' ? parsed.createdBy : undefined,
status: (['pending', 'in_progress', 'completed', 'deleted'] as const).includes(
parsed.status as TeamTask['status']
)
? (parsed.status as TeamTask['status'])
: 'pending',
status,
workIntervals,
historyEvents,
blocks: Array.isArray(parsed.blocks)
@ -299,6 +300,7 @@ export class TeamTaskReader {
reviewState: getReviewStateFromTask({
historyEvents,
reviewState: parsed.reviewState as TeamTask['reviewState'],
status,
}),
sourceMessageId:
typeof parsed.sourceMessageId === 'string' && parsed.sourceMessageId.trim()
@ -413,6 +415,7 @@ export class TeamTaskReader {
createdAt: typeof parsed.createdAt === 'string' ? parsed.createdAt : undefined,
reviewState: getReviewStateFromTask({
reviewState: parsed.reviewState as TeamTask['reviewState'],
status: 'deleted',
}),
};

View file

@ -1,5 +1,3 @@
import { configManager } from '../infrastructure/ConfigManager';
import type { CliFlavor, CliFlavorUiOptions } from '@shared/types';
export const DEFAULT_CLI_FLAVOR: CliFlavor = 'agent_teams_orchestrator';
@ -18,8 +16,7 @@ export function getConfiguredCliFlavor(): CliFlavor {
return envOverride;
}
const multimodelEnabled = configManager.getConfig().general.multimodelEnabled;
return multimodelEnabled ? 'agent_teams_orchestrator' : 'claude';
return DEFAULT_CLI_FLAVOR;
}
export function getCliFlavorUiOptions(flavor: CliFlavor): CliFlavorUiOptions {

View file

@ -18,6 +18,8 @@ export const PROGRESS_LOG_TAIL_LINES = 200;
export const PROGRESS_OUTPUT_TAIL_PARTS = 20;
export const PROGRESS_LAUNCH_DIAGNOSTICS_LIMIT = 20;
const PROGRESS_LAUNCH_DIAGNOSTIC_TEXT_LIMIT = 500;
const SECRET_FLAG_PATTERN =
/(--(?:api-key|token|password|secret|authorization|auth-token)(?:=|\s+))("[^"]*"|'[^']*'|\S+)/gi;
/**
* Return the trailing `maxLines` of a line-buffered CLI log, joined with "\n"
@ -60,9 +62,10 @@ function boundDiagnosticText(value: string | undefined): string | undefined {
if (!trimmed) {
return undefined;
}
return trimmed.length > PROGRESS_LAUNCH_DIAGNOSTIC_TEXT_LIMIT
? `${trimmed.slice(0, PROGRESS_LAUNCH_DIAGNOSTIC_TEXT_LIMIT - 3).trimEnd()}...`
: trimmed;
const redacted = trimmed.replace(SECRET_FLAG_PATTERN, '$1[redacted]');
return redacted.length > PROGRESS_LAUNCH_DIAGNOSTIC_TEXT_LIMIT
? `${redacted.slice(0, PROGRESS_LAUNCH_DIAGNOSTIC_TEXT_LIMIT - 3).trimEnd()}...`
: redacted;
}
export function boundLaunchDiagnostics(

View file

@ -302,7 +302,7 @@ export class OpenCodeTeamRuntimeAdapter implements TeamLaunchRuntimeAdapter {
providerId: this.providerId,
launchState: member.launchState,
agentToolAccepted: member.agentToolAccepted,
runtimeAlive: member.runtimeAlive,
runtimeAlive: member.bootstrapConfirmed === true,
bootstrapConfirmed: member.bootstrapConfirmed,
hardFailure: member.hardFailure,
hardFailureReason: member.hardFailureReason,
@ -544,7 +544,6 @@ function mapBridgeMemberToRuntimeEvidence(
diagnostics: string[]
): TeamRuntimeMemberLaunchEvidence {
const confirmed = launchState === 'confirmed_alive';
const createdOrBlocked = launchState === 'created' || launchState === 'permission_blocked';
const failed = launchState === 'failed';
const hasRuntimePid =
typeof runtimePid === 'number' && Number.isFinite(runtimePid) && runtimePid > 0;
@ -552,14 +551,14 @@ function mapBridgeMemberToRuntimeEvidence(
const livenessKind = confirmed
? 'confirmed_bootstrap'
: pendingRuntimeObserved
? 'runtime_process'
? 'runtime_process_candidate'
: launchState === 'permission_blocked'
? 'permission_blocked'
: runtimeMaterialized || sessionId
? 'runtime_process_candidate'
: 'registered_only';
const runtimeDiagnostic = pendingRuntimeObserved
? 'OpenCode runtime process reported by bridge'
? 'OpenCode runtime pid reported by bridge without local process verification'
: launchState === 'permission_blocked'
? 'OpenCode runtime is waiting for permission approval'
: runtimeMaterialized || sessionId
@ -575,8 +574,13 @@ function mapBridgeMemberToRuntimeEvidence(
: launchState === 'permission_blocked'
? 'runtime_pending_permission'
: 'runtime_pending_bootstrap',
agentToolAccepted: confirmed || createdOrBlocked || runtimeMaterialized,
runtimeAlive: confirmed || pendingRuntimeObserved,
agentToolAccepted:
confirmed ||
pendingRuntimeObserved ||
launchState === 'permission_blocked' ||
runtimeMaterialized ||
Boolean(sessionId),
runtimeAlive: confirmed,
bootstrapConfirmed: confirmed,
hardFailure: failed,
hardFailureReason: failed ? 'OpenCode bridge reported member launch failure' : undefined,

View file

@ -31,6 +31,10 @@ export function encodePath(absolutePath: string): string {
}
const encoded = absolutePath.replace(/[/\\]/g, '-');
const windowsDriveMatch = /^([a-zA-Z]):-(.*)$/.exec(encoded);
if (windowsDriveMatch) {
return `${windowsDriveMatch[1].toUpperCase()}--${windowsDriveMatch[2]}`;
}
// Ensure leading dash for absolute paths
return encoded.startsWith('-') ? encoded : `-${encoded}`;
@ -50,7 +54,7 @@ export function decodePath(encodedName: string): string {
// Legacy Windows format observed in some Claude installs: "C--Users-name-project"
// (no leading dash, drive separator encoded as "--").
const legacyWindowsRegex = /^([a-zA-Z])--(.+)$/;
const legacyWindowsRegex = /^([a-zA-Z])--(.*)$/;
const legacyWindowsMatch = legacyWindowsRegex.exec(encodedName);
if (legacyWindowsMatch) {
const drive = legacyWindowsMatch[1].toUpperCase();

View file

@ -97,6 +97,13 @@ interface TaskReadDiag {
const MAX_LAUNCH_STATE_BYTES = 32 * 1024;
const TEAM_LAUNCH_STATE_FILE = 'launch-state.json';
const REVIEW_LIFECYCLE_EVENTS = new Set([
'review_requested',
'review_changes_requested',
'review_approved',
'review_started',
]);
const REVIEW_RESET_STATUSES = new Set(['in_progress', 'deleted']);
// ---------------------------------------------------------------------------
// Parsed JSON types (loose shapes from disk)
@ -743,28 +750,66 @@ function normalizeHistoryEvents(parsed: ParsedTask): RawHistoryEvent[] | undefin
.map((i) => ({ ...i }));
}
/** Derive review state from historyEvents (inline reducer for worker isolation). */
function deriveReviewStateFromEvents(events: RawHistoryEvent[] | undefined): string {
if (!Array.isArray(events) || events.length === 0) return 'none';
for (let i = events.length - 1; i >= 0; i--) {
const e = events[i];
const t = e.type;
if (
t === 'review_requested' ||
t === 'review_changes_requested' ||
t === 'review_approved' ||
t === 'review_started'
) {
const to = typeof e.to === 'string' ? e.to : 'none';
return to === 'review' || to === 'needsFix' || to === 'approved' ? to : 'none';
function normalizeReviewState(value: unknown): string {
return value === 'review' || value === 'needsFix' || value === 'approved' ? value : 'none';
}
function normalizeFallbackReviewState(value: unknown, status: string): string {
const reviewState = normalizeReviewState(value);
if (reviewState === 'none') return 'none';
if (status === 'in_progress' || status === 'deleted') return 'none';
if (status === 'pending') return reviewState === 'needsFix' ? 'needsFix' : 'none';
if (status === 'completed') {
return reviewState === 'review' || reviewState === 'approved' ? reviewState : 'none';
}
return reviewState;
}
function eventReviewState(event: RawHistoryEvent): string | null {
const type = typeof event.type === 'string' ? event.type : '';
if (!REVIEW_LIFECYCLE_EVENTS.has(type)) {
return null;
}
return normalizeReviewState(event.to);
}
function derivePendingReviewState(events: RawHistoryEvent[], startIndex: number): string {
for (let i = startIndex - 1; i >= 0; i--) {
const previous = events[i];
const reviewState = eventReviewState(previous);
if (reviewState) {
return reviewState === 'needsFix' ? 'needsFix' : 'none';
}
if (t === 'status_changed' && e.to === 'in_progress') {
if (
previous.type === 'task_created' ||
(previous.type === 'status_changed' &&
(REVIEW_RESET_STATUSES.has(String(previous.to || '')) || previous.to === 'pending'))
) {
return 'none';
}
}
return 'none';
}
/** Derive review state from historyEvents (inline reducer for worker isolation). */
function deriveReviewStateFromEvents(events: RawHistoryEvent[] | undefined): string | null {
if (!Array.isArray(events) || events.length === 0) return null;
for (let i = events.length - 1; i >= 0; i--) {
const e = events[i];
const reviewState = eventReviewState(e);
if (reviewState) {
return reviewState;
}
if (e.type === 'status_changed' && REVIEW_RESET_STATUSES.has(String(e.to || ''))) {
return 'none';
}
if (e.type === 'status_changed' && e.to === 'pending') {
return derivePendingReviewState(events, i);
}
}
return null;
}
function normalizeComments(parsed: ParsedTask): unknown[] | undefined {
if (!Array.isArray(parsed.comments)) return undefined;
return (parsed.comments as unknown[])
@ -869,7 +914,16 @@ async function readTasksDirForTeam(
? (parsed.needsClarification as string)
: undefined;
const historyEvents = normalizeHistoryEvents(parsed);
const reviewState = deriveReviewStateFromEvents(historyEvents);
const status =
parsed.status === 'pending' ||
parsed.status === 'in_progress' ||
parsed.status === 'completed' ||
parsed.status === 'deleted'
? (parsed.status as string)
: 'pending';
const reviewState =
deriveReviewStateFromEvents(historyEvents) ??
normalizeFallbackReviewState(parsed.reviewState, status);
tasks.push({
id: typeof parsed.id === 'string' || typeof parsed.id === 'number' ? String(parsed.id) : '',
@ -896,13 +950,7 @@ async function readTasksDirForTeam(
: undefined,
owner: typeof parsed.owner === 'string' ? parsed.owner : undefined,
createdBy: typeof parsed.createdBy === 'string' ? parsed.createdBy : undefined,
status:
parsed.status === 'pending' ||
parsed.status === 'in_progress' ||
parsed.status === 'completed' ||
parsed.status === 'deleted'
? (parsed.status as string)
: 'pending',
status,
workIntervals: normalizeWorkIntervals(parsed),
historyEvents: normalizeHistoryEvents(parsed),
blocks: Array.isArray(parsed.blocks) ? (parsed.blocks as unknown[]) : undefined,

View file

@ -32,7 +32,6 @@ import {
import { ProviderModelBadges } from '@renderer/components/runtime/ProviderModelBadges';
import { getProviderRuntimeBackendSummary } from '@renderer/components/runtime/ProviderRuntimeBackendSelector';
import { ProviderRuntimeSettingsDialog } from '@renderer/components/runtime/ProviderRuntimeSettingsDialog';
import { SettingsToggle } from '@renderer/components/settings/components';
import { TerminalLogPanel } from '@renderer/components/terminal/TerminalLogPanel';
import { TerminalModal } from '@renderer/components/terminal/TerminalModal';
import { useCliInstaller } from '@renderer/hooks/useCliInstaller';
@ -265,11 +264,8 @@ interface InstalledBannerProps {
cliStatusError: string | null;
providersCollapsed: boolean;
isBusy: boolean;
multimodelEnabled: boolean;
multimodelBusy: boolean;
onInstall: () => void;
onRefresh: () => void;
onMultimodelToggle: (enabled: boolean) => void;
onToggleProvidersCollapsed: () => void;
onProviderLogin: (providerId: CliProviderId) => void;
onProviderLogout: (providerId: CliProviderId) => void;
@ -573,11 +569,8 @@ const InstalledBanner = ({
cliStatusError,
providersCollapsed,
isBusy,
multimodelEnabled,
multimodelBusy,
onInstall,
onRefresh,
onMultimodelToggle,
onToggleProvidersCollapsed,
onProviderLogin,
onProviderLogout,
@ -683,23 +676,6 @@ const InstalledBanner = ({
<span className="text-xs font-medium" style={{ color: 'var(--color-text-secondary)' }}>
Multimodel
</span>
{multimodelEnabled && (
<span
className="rounded-full border px-1.5 py-0.5 text-[10px] font-semibold uppercase tracking-[0.08em]"
style={{
borderColor: 'rgba(245, 158, 11, 0.35)',
backgroundColor: 'rgba(245, 158, 11, 0.1)',
color: '#fbbf24',
}}
>
Beta
</span>
)}
<SettingsToggle
enabled={multimodelEnabled}
onChange={onMultimodelToggle}
disabled={isBusy || cliStatusLoading || multimodelBusy}
/>
</div>
{/* Extensions button — available whenever the runtime is installed */}
{canOpenExtensions && (
@ -1033,7 +1009,6 @@ export const CliStatusBanner = (): React.JSX.Element | null => {
const [manageProviderId, setManageProviderId] = useState<CliProviderId>('anthropic');
const [manageDialogOpen, setManageDialogOpen] = useState(false);
const [isVerifyingAuth, setIsVerifyingAuth] = useState(false);
const [isSwitchingFlavor, setIsSwitchingFlavor] = useState(false);
const [showTroubleshoot, setShowTroubleshoot] = useState(false);
const [providersCollapsed, setProvidersCollapsed] = useState(() =>
loadDashboardCliStatusBannerCollapsed()
@ -1147,37 +1122,6 @@ export const CliStatusBanner = (): React.JSX.Element | null => {
})();
}, [bootstrapCliStatus, codexAccount, fetchCliStatus, multimodelEnabled]);
const handleMultimodelToggle = useCallback(
async (enabled: boolean) => {
setIsSwitchingFlavor(true);
let nextMultimodelEnabled = multimodelEnabled;
try {
useStore.setState({
cliStatus: enabled ? createLoadingMultimodelCliStatus() : null,
cliStatusLoading: true,
cliStatusError: null,
});
await updateConfig('general', { multimodelEnabled: enabled });
nextMultimodelEnabled = enabled;
await invalidateCliStatus();
if (enabled) {
await bootstrapCliStatus({ multimodelEnabled: true });
} else {
await fetchCliStatus();
}
} catch {
if (nextMultimodelEnabled) {
await bootstrapCliStatus({ multimodelEnabled: true });
} else {
await fetchCliStatus();
}
} finally {
setIsSwitchingFlavor(false);
}
},
[bootstrapCliStatus, fetchCliStatus, invalidateCliStatus, multimodelEnabled, updateConfig]
);
const recheckAuthState = useCallback(() => {
setIsVerifyingAuth(true);
void (async () => {
@ -1422,11 +1366,8 @@ export const CliStatusBanner = (): React.JSX.Element | null => {
cliStatusError={cliStatusError ?? null}
providersCollapsed={providersCollapsed}
isBusy={isBusy}
multimodelEnabled={multimodelEnabled}
multimodelBusy={isSwitchingFlavor}
onInstall={handleInstall}
onRefresh={handleRefresh}
onMultimodelToggle={(enabled) => void handleMultimodelToggle(enabled)}
onToggleProvidersCollapsed={handleToggleProvidersCollapsed}
onProviderLogin={handleProviderLogin}
onProviderLogout={handleProviderLogout}
@ -1650,11 +1591,8 @@ export const CliStatusBanner = (): React.JSX.Element | null => {
cliStatusError={cliStatusError ?? null}
providersCollapsed={providersCollapsed}
isBusy={isBusy}
multimodelEnabled={multimodelEnabled}
multimodelBusy={isSwitchingFlavor}
onInstall={handleInstall}
onRefresh={handleRefresh}
onMultimodelToggle={(enabled) => void handleMultimodelToggle(enabled)}
onToggleProvidersCollapsed={handleToggleProvidersCollapsed}
onProviderLogin={handleProviderLogin}
onProviderLogout={handleProviderLogout}
@ -1712,11 +1650,8 @@ export const CliStatusBanner = (): React.JSX.Element | null => {
cliStatusError={cliStatusError ?? null}
providersCollapsed={providersCollapsed}
isBusy={isBusy}
multimodelEnabled={multimodelEnabled}
multimodelBusy={isSwitchingFlavor}
onInstall={handleInstall}
onRefresh={handleRefresh}
onMultimodelToggle={(enabled) => void handleMultimodelToggle(enabled)}
onToggleProvidersCollapsed={handleToggleProvidersCollapsed}
onProviderLogin={handleProviderLogin}
onProviderLogout={handleProviderLogout}
@ -1934,11 +1869,8 @@ export const CliStatusBanner = (): React.JSX.Element | null => {
cliStatusError={cliStatusError ?? null}
providersCollapsed={providersCollapsed}
isBusy={isBusy}
multimodelEnabled={multimodelEnabled}
multimodelBusy={isSwitchingFlavor}
onInstall={handleInstall}
onRefresh={handleRefresh}
onMultimodelToggle={(enabled) => void handleMultimodelToggle(enabled)}
onToggleProvidersCollapsed={handleToggleProvidersCollapsed}
onProviderLogin={handleProviderLogin}
onProviderLogout={handleProviderLogout}

View file

@ -27,7 +27,6 @@ import {
import { ProviderModelBadges } from '@renderer/components/runtime/ProviderModelBadges';
import { getProviderRuntimeBackendSummary } from '@renderer/components/runtime/ProviderRuntimeBackendSelector';
import { ProviderRuntimeSettingsDialog } from '@renderer/components/runtime/ProviderRuntimeSettingsDialog';
import { SettingsToggle } from '@renderer/components/settings/components';
import { TerminalModal } from '@renderer/components/terminal/TerminalModal';
import { useCliInstaller } from '@renderer/hooks/useCliInstaller';
import { useStore } from '@renderer/store';
@ -211,7 +210,6 @@ export const CliStatusSection = (): React.JSX.Element | null => {
} | null>(null);
const [manageProviderId, setManageProviderId] = useState<CliProviderId>('gemini');
const [manageDialogOpen, setManageDialogOpen] = useState(false);
const [isSwitchingFlavor, setIsSwitchingFlavor] = useState(false);
const multimodelEnabled = appConfig?.general?.multimodelEnabled ?? true;
const loadingCliStatus =
!cliStatus && cliStatusLoading && multimodelEnabled
@ -323,37 +321,6 @@ export const CliStatusSection = (): React.JSX.Element | null => {
})();
}, [bootstrapCliStatus, fetchCliStatus, invalidateCliStatus, multimodelEnabled]);
const handleMultimodelToggle = useCallback(
async (enabled: boolean) => {
setIsSwitchingFlavor(true);
let nextMultimodelEnabled = multimodelEnabled;
try {
useStore.setState({
cliStatus: enabled ? createLoadingMultimodelCliStatus() : null,
cliStatusLoading: true,
cliStatusError: null,
});
await updateConfig('general', { multimodelEnabled: enabled });
nextMultimodelEnabled = enabled;
await invalidateCliStatus();
if (enabled) {
await bootstrapCliStatus({ multimodelEnabled: true });
} else {
await fetchCliStatus();
}
} catch {
if (nextMultimodelEnabled) {
await bootstrapCliStatus({ multimodelEnabled: true });
} else {
await fetchCliStatus();
}
} finally {
setIsSwitchingFlavor(false);
}
},
[bootstrapCliStatus, fetchCliStatus, invalidateCliStatus, multimodelEnabled, updateConfig]
);
const handleRuntimeBackendChange = useCallback(
async (providerId: CliProviderId, backendId: string) => {
const currentBackends = appConfig?.runtime?.providerBackends ?? {
@ -440,23 +407,6 @@ export const CliStatusSection = (): React.JSX.Element | null => {
>
Multimodel
</span>
{multimodelEnabled && (
<span
className="rounded-full border px-1.5 py-0.5 text-[10px] font-semibold uppercase tracking-[0.08em]"
style={{
borderColor: 'rgba(245, 158, 11, 0.35)',
backgroundColor: 'rgba(245, 158, 11, 0.1)',
color: '#fbbf24',
}}
>
Beta
</span>
)}
<SettingsToggle
enabled={multimodelEnabled}
onChange={(value) => void handleMultimodelToggle(value)}
disabled={isBusy || cliStatusLoading || isSwitchingFlavor}
/>
</div>
{/* Inline action buttons */}
{effectiveCliStatus.supportsSelfUpdate && effectiveCliStatus.updateAvailable ? (

View file

@ -167,6 +167,9 @@ export const ProvisioningProgressBlock = ({
const outputScrollRef = useRef<HTMLDivElement>(null);
const isError = tone === 'error';
const displayAssistantOutput = sanitizeAssistantOutput(assistantOutput, isError);
const visibleLaunchDiagnostics =
launchDiagnostics?.filter((item) => item.severity === 'warning' || item.severity === 'error') ??
[];
// Auto-scroll assistant output
useEffect(() => {
@ -298,7 +301,7 @@ export const ProvisioningProgressBlock = ({
errorIndex={errorStepIndex}
/>
</div>
{launchDiagnostics && launchDiagnostics.length > 0 ? (
{visibleLaunchDiagnostics.length > 0 ? (
<div className="mt-2">
<button
type="button"
@ -310,7 +313,7 @@ export const ProvisioningProgressBlock = ({
</button>
{diagnosticsOpen ? (
<div className="mt-1 space-y-1 rounded border border-[var(--color-border)] bg-[var(--color-surface)] p-2">
{launchDiagnostics.map((item) => (
{visibleLaunchDiagnostics.map((item) => (
<div key={item.id} className="text-[11px]">
<div
className={cn(

View file

@ -257,7 +257,7 @@ const TeamOfflineStatusBanner = memo(function TeamOfflineStatusBanner({
memberCount: team.memberCount,
expectedMemberCount: team.expectedMemberCount,
confirmedCount: team.confirmedCount,
runtimeAlivePendingCount: team.runtimeAlivePendingCount,
runtimeProcessPendingCount: team.runtimeProcessPendingCount,
teamLaunchState: team.teamLaunchState,
partialLaunchFailure: team.partialLaunchFailure,
missingMemberCount: team.missingMembers?.length ?? 0,
@ -267,12 +267,12 @@ const TeamOfflineStatusBanner = memo(function TeamOfflineStatusBanner({
const message =
summary?.teamLaunchState === 'partial_pending'
? summary.runtimeAlivePendingCount != null && summary.runtimeAlivePendingCount > 0
? summary.runtimeProcessPendingCount != null && summary.runtimeProcessPendingCount > 0
? buildPendingRuntimeSummaryCopy({
confirmedCount: summary.confirmedCount,
expectedMemberCount: summary.expectedMemberCount,
memberCount: summary.memberCount,
runtimeAlivePendingCount: summary.runtimeAlivePendingCount,
runtimeProcessPendingCount: summary.runtimeProcessPendingCount,
})
: 'Last launch is still reconciling'
: summary?.partialLaunchFailure
@ -809,6 +809,7 @@ const TeamMemberListBridge = memo(function TeamMemberListBridge({
() => buildTeamAgentRuntimeMap(runtimeSnapshot?.members),
[runtimeSnapshot?.members]
);
const runtimeRunId = runtimeSnapshot?.runId ?? memberSpawnSnapshot?.runId ?? progress?.runId;
const isLaunchSettling = useMemo(() => {
if (progress?.state !== 'ready') {
return false;
@ -828,6 +829,7 @@ const TeamMemberListBridge = memo(function TeamMemberListBridge({
leadActivity={leadActivity}
memberSpawnStatuses={memberSpawnStatusMap}
memberRuntimeEntries={memberRuntimeMap}
runtimeRunId={runtimeRunId}
isLaunchSettling={isLaunchSettling}
/>
);
@ -889,6 +891,7 @@ const TeamMemberDetailDialogBridge = memo(function TeamMemberDetailDialogBridge(
memberSpawnStatuses,
memberSpawnSnapshot,
spawnEntry,
runtimeRunId,
runtimeEntry,
} = useStore(
useShallow((s) => ({
@ -899,6 +902,10 @@ const TeamMemberDetailDialogBridge = memo(function TeamMemberDetailDialogBridge(
memberSpawnStatuses: s.memberSpawnStatusesByTeam[teamName],
memberSpawnSnapshot: s.memberSpawnSnapshotsByTeam[teamName],
spawnEntry: member ? s.memberSpawnStatusesByTeam[teamName]?.[member.name] : undefined,
runtimeRunId:
s.teamAgentRuntimeByTeam[teamName]?.runId ??
s.memberSpawnSnapshotsByTeam[teamName]?.runId ??
getCurrentProvisioningProgressForTeam(s, teamName)?.runId,
runtimeEntry: member ? s.teamAgentRuntimeByTeam[teamName]?.members[member.name] : undefined,
}))
);
@ -924,6 +931,7 @@ const TeamMemberDetailDialogBridge = memo(function TeamMemberDetailDialogBridge(
leadActivity={leadActivity}
spawnEntry={spawnEntry}
runtimeEntry={runtimeEntry}
runtimeRunId={runtimeRunId}
/>
);
});

View file

@ -981,12 +981,12 @@ export const TeamListView = (): React.JSX.Element => {
</div>
{team.teamLaunchState === 'partial_pending' ? (
<p className="mt-2 text-[11px] text-amber-300">
{team.runtimeAlivePendingCount && team.runtimeAlivePendingCount > 0
{team.runtimeProcessPendingCount && team.runtimeProcessPendingCount > 0
? buildPendingRuntimeSummaryCopy({
confirmedCount: team.confirmedCount,
expectedMemberCount: team.expectedMemberCount,
memberCount: team.memberCount,
runtimeAlivePendingCount: team.runtimeAlivePendingCount,
runtimeProcessPendingCount: team.runtimeProcessPendingCount,
includePeriod: true,
})
: 'Last launch is still reconciling.'}

View file

@ -86,6 +86,8 @@ import { isTeamProviderId, normalizeOptionalTeamProviderId } from '@shared/utils
import { AlertTriangle, CheckCircle2, Info, Loader2, X } from 'lucide-react';
import { AdvancedCliSection } from './AdvancedCliSection';
import { AnthropicFastModeSelector } from './AnthropicFastModeSelector';
import { CodexFastModeSelector } from './CodexFastModeSelector';
import {
clearInheritedMemberModelsUnavailableForProvider,
resolveProviderScopedMemberModel,
@ -1421,10 +1423,12 @@ export const CreateTeamDialog = ({
const summary: string[] = [];
if (prompt.trim()) summary.push('Lead prompt');
if (skipPermissions) summary.push('Auto-approve tools');
if (selectedProviderId === 'anthropic') {
if (selectedProviderId === 'anthropic' || selectedProviderId === 'codex') {
if (selectedFastMode === 'on') summary.push('Fast mode');
else if (selectedFastMode === 'off') summary.push('Fast disabled');
else if (anthropicProviderFastModeDefault) summary.push('Fast default');
else if (selectedProviderId === 'anthropic' && anthropicProviderFastModeDefault) {
summary.push('Fast default');
}
}
if (worktreeEnabled && worktreeName.trim()) summary.push(`Worktree: ${worktreeName.trim()}`);
if (customArgs.trim()) summary.push('Custom CLI args');
@ -1721,9 +1725,6 @@ export const CreateTeamDialog = ({
onProviderChange={setSelectedProviderId}
onModelChange={setSelectedModel}
onEffortChange={setSelectedEffort}
fastMode={selectedFastMode}
providerFastModeDefault={anthropicProviderFastModeDefault}
onFastModeChange={setSelectedFastMode}
onLimitContextChange={setLimitContext}
syncModelsWithTeammates={syncModelsWithLead}
onSyncModelsWithTeammatesChange={handleSyncModelsWithLeadChange}
@ -1732,7 +1733,6 @@ export const CreateTeamDialog = ({
onTeammateWorktreeDefaultChange={setTeammateWorktreeDefault}
disableGeminiOption={isGeminiUiFrozen()}
leadModelIssueText={leadModelIssueText}
leadFastModeNotice={anthropicRuntimeNotice}
memberModelIssueById={memberModelIssueById}
headerTop={
<div className="flex items-center gap-2">
@ -1818,6 +1818,47 @@ export const CreateTeamDialog = ({
summary={launchOptionalSummary}
>
<div className="space-y-4">
{selectedProviderId === 'anthropic' ? (
<div className="space-y-2">
<AnthropicFastModeSelector
value={selectedFastMode}
onValueChange={setSelectedFastMode}
providerFastModeDefault={anthropicProviderFastModeDefault}
model={selectedModel}
limitContext={limitContext}
id="create-fast-mode"
/>
{anthropicRuntimeNotice ? (
<div className="bg-amber-500/8 flex items-start gap-2 rounded-md border border-amber-500/25 px-3 py-2 text-[11px] leading-relaxed text-amber-200">
<Info className="mt-0.5 size-3.5 shrink-0 text-amber-300" />
<p>{anthropicRuntimeNotice}</p>
</div>
) : null}
</div>
) : null}
{selectedProviderId === 'codex' ? (
<div className="space-y-2">
<CodexFastModeSelector
value={selectedFastMode}
onValueChange={setSelectedFastMode}
model={selectedModel}
providerBackendId={
resolveUiOwnedProviderBackendId(
'codex',
runtimeProviderStatusById.get('codex')
) ?? undefined
}
id="create-fast-mode"
/>
{anthropicRuntimeNotice ? (
<div className="bg-amber-500/8 flex items-start gap-2 rounded-md border border-amber-500/25 px-3 py-2 text-[11px] leading-relaxed text-amber-200">
<Info className="mt-0.5 size-3.5 shrink-0 text-amber-300" />
<p>{anthropicRuntimeNotice}</p>
</div>
) : null}
</div>
) : null}
<div className="space-y-1.5">
<Label htmlFor="team-prompt" className="label-optional">
Prompt for team lead (optional)

View file

@ -79,6 +79,7 @@ import {
CheckCircle2,
ChevronDown,
ChevronRight,
Info,
Loader2,
RotateCcw,
X,
@ -1618,10 +1619,12 @@ export const LaunchTeamDialog = (props: LaunchTeamDialogProps): React.JSX.Elemen
summary.push(`Provider: ${getProviderLabel(selectedProviderId)}`);
if (selectedModel) summary.push(`Model: ${selectedModel}`);
if (selectedEffort) summary.push(`Effort: ${selectedEffort}`);
if (selectedProviderId === 'anthropic') {
if (selectedProviderId === 'anthropic' || selectedProviderId === 'codex') {
if (selectedFastMode === 'on') summary.push('Fast mode');
else if (selectedFastMode === 'off') summary.push('Fast disabled');
else if (anthropicProviderFastModeDefault) summary.push('Fast default');
else if (selectedProviderId === 'anthropic' && anthropicProviderFastModeDefault) {
summary.push('Fast default');
}
}
if (selectedProviderId === 'anthropic' && limitContext) summary.push('Limited to 200K context');
if (skipPermissions) summary.push('Auto-approve tools');
@ -2263,6 +2266,52 @@ export const LaunchTeamDialog = (props: LaunchTeamDialogProps): React.JSX.Elemen
summary={launchOptionalSummary}
>
<div className="space-y-4">
{selectedProviderId === 'anthropic' ? (
<div className="space-y-2">
<AnthropicFastModeSelector
value={selectedFastMode}
onValueChange={setSelectedFastMode}
providerFastModeDefault={anthropicProviderFastModeDefault}
model={selectedModel}
limitContext={effectiveAnthropicRuntimeLimitContext}
id="launch-fast-mode"
/>
{anthropicRuntimeNotice ? (
<div className="bg-amber-500/8 flex items-start gap-2 rounded-md border border-amber-500/25 px-3 py-2 text-[11px] leading-relaxed text-amber-200">
<Info className="mt-0.5 size-3.5 shrink-0 text-amber-300" />
<p>{anthropicRuntimeNotice}</p>
</div>
) : null}
</div>
) : null}
{selectedProviderId === 'codex' ? (
<div className="space-y-2">
<CodexFastModeSelector
value={selectedFastMode}
onValueChange={setSelectedFastMode}
model={selectedModel}
providerBackendId={
resolveUiOwnedProviderBackendId(
'codex',
runtimeProviderStatusById.get('codex')
) ??
migrateProviderBackendId(
'codex',
previousLaunchParams?.providerBackendId ?? savedLaunchProviderBackendId
) ??
undefined
}
id="launch-fast-mode"
/>
{anthropicRuntimeNotice ? (
<div className="bg-amber-500/8 flex items-start gap-2 rounded-md border border-amber-500/25 px-3 py-2 text-[11px] leading-relaxed text-amber-200">
<Info className="mt-0.5 size-3.5 shrink-0 text-amber-300" />
<p>{anthropicRuntimeNotice}</p>
</div>
) : null}
</div>
) : null}
<TeamRosterEditorSection
members={membersDrafts}
onMembersChange={setMembersDrafts}
@ -2285,13 +2334,10 @@ export const LaunchTeamDialog = (props: LaunchTeamDialogProps): React.JSX.Elemen
providerId={selectedProviderId}
model={selectedModel}
effort={(selectedEffort as EffortLevel) || undefined}
fastMode={selectedFastMode}
providerFastModeDefault={anthropicProviderFastModeDefault}
limitContext={limitContext}
onProviderChange={setSelectedProviderId}
onModelChange={setSelectedModel}
onEffortChange={setSelectedEffort}
onFastModeChange={setSelectedFastMode}
onLimitContextChange={setLimitContext}
syncModelsWithTeammates={syncModelsWithLead}
onSyncModelsWithTeammatesChange={setSyncModelsWithLead}
@ -2299,7 +2345,6 @@ export const LaunchTeamDialog = (props: LaunchTeamDialogProps): React.JSX.Elemen
teammateWorktreeDefault={teammateWorktreeDefault}
onTeammateWorktreeDefaultChange={setTeammateWorktreeDefault}
leadWarningText={leadRuntimeWarningText}
leadFastModeNotice={anthropicRuntimeNotice}
memberWarningById={memberRuntimeWarningById}
leadModelIssueText={leadModelIssueText}
memberModelIssueById={memberModelIssueById}

View file

@ -1,8 +1,6 @@
import React, { useState } from 'react';
import { ProviderBrandLogo } from '@renderer/components/common/ProviderBrandLogo';
import { AnthropicFastModeSelector } from '@renderer/components/team/dialogs/AnthropicFastModeSelector';
import { CodexFastModeSelector } from '@renderer/components/team/dialogs/CodexFastModeSelector';
import { EffortLevelSelector } from '@renderer/components/team/dialogs/EffortLevelSelector';
import { LimitContextCheckbox } from '@renderer/components/team/dialogs/LimitContextCheckbox';
import {
@ -22,24 +20,20 @@ import { AlertTriangle, ChevronDown, ChevronRight, Info } from 'lucide-react';
import { Button } from '../../ui/button';
import type { EffortLevel, TeamFastMode, TeamProviderId } from '@shared/types';
import type { EffortLevel, TeamProviderId } from '@shared/types';
interface LeadModelRowProps {
providerId: TeamProviderId;
model: string;
effort?: EffortLevel;
fastMode?: TeamFastMode;
providerFastModeDefault?: boolean;
limitContext: boolean;
onProviderChange: (providerId: TeamProviderId) => void;
onModelChange: (model: string) => void;
onEffortChange: (effort: string) => void;
onFastModeChange?: (fastMode: TeamFastMode) => void;
onLimitContextChange: (value: boolean) => void;
syncModelsWithTeammates: boolean;
onSyncModelsWithTeammatesChange: (value: boolean) => void;
warningText?: string | null;
fastModeNotice?: string | null;
disableGeminiOption?: boolean;
modelIssueText?: string | null;
}
@ -48,18 +42,14 @@ export const LeadModelRow = ({
providerId,
model,
effort,
fastMode = 'inherit',
providerFastModeDefault = false,
limitContext,
onProviderChange,
onModelChange,
onEffortChange,
onFastModeChange,
onLimitContextChange,
syncModelsWithTeammates,
onSyncModelsWithTeammatesChange,
warningText,
fastModeNotice,
disableGeminiOption = false,
modelIssueText,
}: LeadModelRowProps): React.JSX.Element => {
@ -169,24 +159,6 @@ export const LeadModelRow = ({
model={model}
limitContext={limitContext}
/>
{providerId === 'anthropic' && onFastModeChange ? (
<AnthropicFastModeSelector
value={fastMode}
onValueChange={onFastModeChange}
providerFastModeDefault={providerFastModeDefault}
model={model}
limitContext={limitContext}
id="lead-fast-mode"
/>
) : null}
{providerId === 'codex' && onFastModeChange ? (
<CodexFastModeSelector
value={fastMode}
onValueChange={onFastModeChange}
model={model}
id="lead-fast-mode"
/>
) : null}
{providerId === 'anthropic' ? (
<LimitContextCheckbox
id="lead-limit-context"
@ -195,12 +167,6 @@ export const LeadModelRow = ({
disabled={isAnthropicHaikuTeamModel(model)}
/>
) : null}
{fastModeNotice ? (
<div className="bg-amber-500/8 flex items-start gap-2 rounded-md border border-amber-500/25 px-3 py-2 text-[11px] leading-relaxed text-amber-200">
<Info className="mt-0.5 size-3.5 shrink-0 text-amber-300" />
<p>{fastModeNotice}</p>
</div>
) : null}
<div className="flex items-start gap-2 rounded-md border border-sky-500/20 bg-sky-500/5 px-3 py-2">
<Info className="mt-0.5 size-3.5 shrink-0 text-sky-400" />
<p className="text-[11px] leading-relaxed text-sky-300">

View file

@ -13,10 +13,17 @@ import {
buildMemberLaunchPresentation,
displayMemberName,
} from '@renderer/utils/memberHelpers';
import {
buildMemberLaunchDiagnosticsPayload,
hasMemberLaunchDiagnosticsDetails,
hasMemberLaunchDiagnosticsError,
} from '@renderer/utils/memberLaunchDiagnostics';
import { getRuntimeMemorySourceLabel } from '@renderer/utils/memberRuntimeSummary';
import { deriveTaskDisplayId } from '@shared/utils/taskIdentity';
import { AlertTriangle, GitBranch, Loader2, MessageSquare, Plus } from 'lucide-react';
import { CurrentTaskIndicator } from './CurrentTaskIndicator';
import { MemberLaunchDiagnosticsButton } from './MemberLaunchDiagnosticsButton';
import type { TaskStatusCounts } from '@renderer/utils/pathNormalize';
import type {
@ -24,6 +31,7 @@ import type {
MemberLaunchState,
MemberSpawnLivenessSource,
MemberSpawnStatus,
MemberSpawnStatusEntry,
ResolvedTeamMember,
TeamAgentRuntimeEntry,
TeamTaskWithKanban,
@ -34,6 +42,7 @@ interface MemberCardProps {
memberColor: string;
runtimeSummary?: string;
runtimeEntry?: TeamAgentRuntimeEntry;
runtimeRunId?: string | null;
taskCounts?: TaskStatusCounts | null;
isTeamAlive?: boolean;
isTeamProvisioning?: boolean;
@ -43,6 +52,7 @@ interface MemberCardProps {
isAwaitingReply?: boolean;
isRemoved?: boolean;
spawnStatus?: MemberSpawnStatus;
spawnEntry?: MemberSpawnStatusEntry;
spawnError?: string;
spawnLivenessSource?: MemberSpawnLivenessSource;
spawnLaunchState?: MemberLaunchState;
@ -80,6 +90,7 @@ export const MemberCard = ({
memberColor,
runtimeSummary,
runtimeEntry,
runtimeRunId,
taskCounts,
isTeamAlive,
isTeamProvisioning,
@ -89,6 +100,7 @@ export const MemberCard = ({
isAwaitingReply,
isRemoved,
spawnStatus,
spawnEntry,
spawnError,
spawnLivenessSource,
spawnLaunchState,
@ -150,6 +162,7 @@ export const MemberCard = ({
const roleLabel = formatAgentRole(member.role) ?? formatAgentRole(member.agentType);
const { summary: runtimeSummaryText, memory: memoryLabel } =
splitRuntimeSummaryMemory(runtimeSummary);
const memorySourceLabel = getRuntimeMemorySourceLabel(runtimeEntry);
const activityTask = currentTask ?? reviewTask ?? null;
const activityTitle = currentTask
? `Current task: #${deriveTaskDisplayId(currentTask.id)}`
@ -174,6 +187,33 @@ export const MemberCard = ({
launchVisualState === 'registered_only' ||
launchVisualState === 'stale_runtime');
const launchBadgeLabel = presenceLabel === 'starting' ? presenceLabel : displayPresenceLabel;
const launchDiagnosticsPayload = useMemo(
() =>
buildMemberLaunchDiagnosticsPayload({
teamName: selectedTeamName,
runId: runtimeRunId,
memberName: member.name,
spawnStatus,
launchState: spawnLaunchState,
livenessSource: spawnLivenessSource,
spawnEntry,
runtimeEntry,
}),
[
member.name,
runtimeEntry,
runtimeRunId,
selectedTeamName,
spawnEntry,
spawnLaunchState,
spawnLivenessSource,
spawnStatus,
]
);
const showCopyDiagnostics =
!isRemoved &&
hasMemberLaunchDiagnosticsError(launchDiagnosticsPayload) &&
hasMemberLaunchDiagnosticsDetails(launchDiagnosticsPayload);
const showRuntimeAdvisoryBadge =
!isRemoved &&
Boolean(runtimeAdvisoryLabel) &&
@ -297,16 +337,7 @@ export const MemberCard = ({
<span className="shrink-0 opacity-60"></span>
) : null}
{memoryLabel ? (
<span
className="shrink-0"
title={
runtimeEntry?.pidSource === 'tmux_pane'
? 'RSS source: tmux pane shell'
: runtimeEntry?.pidSource
? `PID source: ${runtimeEntry.pidSource}`
: undefined
}
>
<span className="shrink-0" title={memorySourceLabel}>
{memoryLabel}
</span>
) : null}
@ -330,20 +361,28 @@ export const MemberCard = ({
</Badge>
</span>
) : spawnStatus === 'error' ? (
<Tooltip>
<TooltipTrigger asChild>
<span className="flex shrink-0 items-center gap-1">
<AlertTriangle className="size-3.5 shrink-0 text-red-400" />
<Badge
variant="secondary"
className="shrink-0 bg-red-500/15 px-1.5 py-0.5 text-[10px] font-normal leading-none text-red-400"
>
{displayPresenceLabel}
</Badge>
</span>
</TooltipTrigger>
<TooltipContent side="bottom">{spawnError ?? 'Spawn failed'}</TooltipContent>
</Tooltip>
<span className="flex shrink-0 items-center gap-1">
<Tooltip>
<TooltipTrigger asChild>
<span className="flex shrink-0 items-center gap-1">
<AlertTriangle className="size-3.5 shrink-0 text-red-400" />
<Badge
variant="secondary"
className="shrink-0 bg-red-500/15 px-1.5 py-0.5 text-[10px] font-normal leading-none text-red-400"
>
{displayPresenceLabel}
</Badge>
</span>
</TooltipTrigger>
<TooltipContent side="bottom">{spawnError ?? 'Spawn failed'}</TooltipContent>
</Tooltip>
{showCopyDiagnostics ? (
<MemberLaunchDiagnosticsButton
payload={launchDiagnosticsPayload}
className="size-auto rounded p-1 text-red-300 transition-colors hover:bg-red-500/10 hover:text-red-200"
/>
) : null}
</span>
) : showRuntimeAdvisoryBadge ? (
<Tooltip>
<TooltipTrigger asChild>

View file

@ -6,7 +6,16 @@ import { Tabs, TabsContent, TabsList, TabsTrigger } from '@renderer/components/u
import { useMemberStats } from '@renderer/hooks/useMemberStats';
import { useStore } from '@renderer/store';
import { selectMemberMessagesForTeamMember } from '@renderer/store/slices/teamSlice';
import { resolveMemberRuntimeSummary } from '@renderer/utils/memberRuntimeSummary';
import {
buildMemberLaunchDiagnosticsPayload,
getMemberLaunchDiagnosticsErrorMessage,
hasMemberLaunchDiagnosticsDetails,
hasMemberLaunchDiagnosticsError,
} from '@renderer/utils/memberLaunchDiagnostics';
import {
getRuntimeMemorySourceLabel,
resolveMemberRuntimeSummary,
} from '@renderer/utils/memberRuntimeSummary';
import { isLeadMember } from '@shared/utils/leadDetection';
import {
BarChart3,
@ -22,6 +31,7 @@ import { buildMemberActivityEntries } from './memberActivityEntries';
import { MemberDetailHeader } from './MemberDetailHeader';
import { MemberDetailStats } from './MemberDetailStats';
import { type MemberActivityFilter, type MemberDetailTab } from './memberDetailTypes';
import { MemberLaunchDiagnosticsButton } from './MemberLaunchDiagnosticsButton';
import { MemberLogsTab } from './MemberLogsTab';
import { MemberMessagesTab } from './MemberMessagesTab';
import { MemberStatsTab } from './MemberStatsTab';
@ -50,6 +60,7 @@ interface MemberDetailDialogProps {
leadActivity?: LeadActivityState;
spawnEntry?: MemberSpawnStatusEntry;
runtimeEntry?: TeamAgentRuntimeEntry;
runtimeRunId?: string | null;
launchParams?: TeamLaunchParams;
onClose: () => void;
onSendMessage: () => void;
@ -76,6 +87,7 @@ export const MemberDetailDialog = ({
leadActivity,
spawnEntry,
runtimeEntry,
runtimeRunId,
launchParams,
onClose,
onSendMessage,
@ -128,10 +140,31 @@ export const MemberDetailDialog = ({
: undefined,
[launchParams, member, runtimeEntry, spawnEntry]
);
const memorySourceLabel = getRuntimeMemorySourceLabel(runtimeEntry);
const restartInFlight =
spawnEntry?.launchState === 'starting' ||
spawnEntry?.launchState === 'runtime_pending_bootstrap' ||
spawnEntry?.launchState === 'runtime_pending_permission';
const launchDiagnosticsPayload = useMemo(
() =>
member
? buildMemberLaunchDiagnosticsPayload({
teamName,
runId: runtimeRunId,
memberName: member.name,
spawnEntry,
runtimeEntry,
})
: null,
[member, runtimeEntry, runtimeRunId, spawnEntry, teamName]
);
const showCopyDiagnostics =
launchDiagnosticsPayload != null &&
hasMemberLaunchDiagnosticsError(launchDiagnosticsPayload) &&
hasMemberLaunchDiagnosticsDetails(launchDiagnosticsPayload);
const launchErrorMessage = launchDiagnosticsPayload
? getMemberLaunchDiagnosticsErrorMessage(launchDiagnosticsPayload)
: undefined;
useEffect(() => {
if (!open || !member) {
@ -251,10 +284,23 @@ export const MemberDetailDialog = ({
<DialogFooter>
{restartError ? (
<div className="mr-auto text-xs text-red-400">{restartError}</div>
) : launchErrorMessage ? (
<div className="mr-auto flex min-w-0 items-center gap-2 text-xs text-red-400">
<span className="min-w-0 truncate" title={launchErrorMessage}>
{launchErrorMessage}
</span>
{launchDiagnosticsPayload && showCopyDiagnostics ? (
<MemberLaunchDiagnosticsButton
payload={launchDiagnosticsPayload}
label="Copy diagnostics"
className="h-auto shrink-0 gap-1.5 px-2 py-1 text-red-300 hover:bg-red-500/10 hover:text-red-200"
/>
) : null}
</div>
) : runtimeEntry?.pid ? (
<div className="mr-auto text-xs text-[var(--color-text-muted)]">
PID {runtimeEntry.pid}
{runtimeEntry.pidSource ? ` · ${runtimeEntry.pidSource}` : ''}
{memorySourceLabel ? ` · ${memorySourceLabel}` : ''}
</div>
) : (
<div className="mr-auto" />

View file

@ -22,6 +22,12 @@ import {
buildMemberLaunchPresentation,
displayMemberName,
} from '@renderer/utils/memberHelpers';
import {
buildMemberLaunchDiagnosticsPayload,
getMemberLaunchDiagnosticsErrorMessage,
hasMemberLaunchDiagnosticsDetails,
hasMemberLaunchDiagnosticsError,
} from '@renderer/utils/memberLaunchDiagnostics';
import { isLeadMember } from '@shared/utils/leadDetection';
import { ExternalLink } from 'lucide-react';
import { useShallow } from 'zustand/react/shallow';
@ -29,6 +35,7 @@ import { useShallow } from 'zustand/react/shallow';
import { getLaunchJoinMilestonesFromMembers, getLaunchJoinState } from '../provisioningSteps';
import { CurrentTaskIndicator } from './CurrentTaskIndicator';
import { MemberLaunchDiagnosticsButton } from './MemberLaunchDiagnosticsButton';
import type { LeadActivityState, TeamTaskWithKanban } from '@shared/types';
@ -68,6 +75,7 @@ export const MemberHoverCard = ({
memberSpawnSnapshot,
memberSpawnStatuses,
spawnEntry,
runtimeRunId,
runtimeEntry,
leadActivity,
} = useStore(
@ -90,8 +98,11 @@ export const MemberHoverCard = ({
spawnEntry: effectiveTeamName
? s.memberSpawnStatusesByTeam[effectiveTeamName]?.[name]
: undefined,
runtimeRunId: effectiveTeamName
? s.teamAgentRuntimeByTeam?.[effectiveTeamName]?.runId
: undefined,
runtimeEntry: effectiveTeamName
? s.teamAgentRuntimeByTeam[effectiveTeamName]?.members[name]
? s.teamAgentRuntimeByTeam?.[effectiveTeamName]?.members[name]
: undefined,
leadActivity: effectiveTeamName ? s.leadActivityByTeam[effectiveTeamName] : undefined,
}))
@ -143,6 +154,17 @@ export const MemberHoverCard = ({
launchVisualState === 'stale_runtime'
? (launchStatusLabel ?? presenceLabel)
: presenceLabel;
const launchDiagnosticsPayload = buildMemberLaunchDiagnosticsPayload({
teamName: effectiveTeamName,
runId: runtimeRunId ?? memberSpawnSnapshot?.runId ?? progress?.runId,
memberName: member.name,
spawnEntry,
runtimeEntry,
});
const launchErrorMessage = getMemberLaunchDiagnosticsErrorMessage(launchDiagnosticsPayload);
const showCopyDiagnostics =
hasMemberLaunchDiagnosticsError(launchDiagnosticsPayload) &&
hasMemberLaunchDiagnosticsDetails(launchDiagnosticsPayload);
const currentTask: TeamTaskWithKanban | null = member.currentTaskId
? (tasks.find((t) => t.id === member.currentTaskId) ?? null)
: null;
@ -236,18 +258,33 @@ export const MemberHoverCard = ({
</div>
)}
{/* Open profile button */}
<button
type="button"
className="flex w-full items-center justify-center gap-1.5 rounded border border-[var(--color-border)] px-3 py-1.5 text-xs text-[var(--color-text-secondary)] transition-colors hover:bg-[var(--color-surface-raised)] hover:text-[var(--color-text)]"
onClick={(e) => {
e.stopPropagation();
openMemberProfile(member.name);
}}
>
<ExternalLink size={12} />
Open profile
</button>
{launchErrorMessage ? (
<div className="flex items-center gap-2 rounded border border-red-500/25 bg-red-500/10 px-2 py-1.5 text-xs text-red-300">
<span className="min-w-0 flex-1 truncate" title={launchErrorMessage}>
{launchErrorMessage}
</span>
{showCopyDiagnostics ? (
<MemberLaunchDiagnosticsButton
payload={launchDiagnosticsPayload}
className="h-auto shrink-0 rounded px-1.5 py-1 text-red-300 hover:bg-red-500/10 hover:text-red-200"
/>
) : null}
</div>
) : null}
<div className="flex gap-1.5">
<button
type="button"
className="flex flex-1 items-center justify-center gap-1.5 rounded border border-[var(--color-border)] px-3 py-1.5 text-xs text-[var(--color-text-secondary)] transition-colors hover:bg-[var(--color-surface-raised)] hover:text-[var(--color-text)]"
onClick={(e) => {
e.stopPropagation();
openMemberProfile(member.name);
}}
>
<ExternalLink size={12} />
Open profile
</button>
</div>
</div>
</HoverCardContent>
</HoverCard>

View file

@ -0,0 +1,60 @@
import { useState } from 'react';
import { Button } from '@renderer/components/ui/button';
import { Tooltip, TooltipContent, TooltipTrigger } from '@renderer/components/ui/tooltip';
import {
formatMemberLaunchDiagnosticsPayload,
type MemberLaunchDiagnosticsPayload,
} from '@renderer/utils/memberLaunchDiagnostics';
import { Check, ClipboardList } from 'lucide-react';
interface MemberLaunchDiagnosticsButtonProps {
payload: MemberLaunchDiagnosticsPayload;
label?: string;
className?: string;
size?: 'icon' | 'sm';
}
export const MemberLaunchDiagnosticsButton = ({
payload,
label,
className,
size = label ? 'sm' : 'icon',
}: MemberLaunchDiagnosticsButtonProps): React.JSX.Element => {
const [copied, setCopied] = useState(false);
const copyDiagnostics = async (event: React.MouseEvent<HTMLButtonElement>): Promise<void> => {
event.preventDefault();
event.stopPropagation();
try {
await navigator.clipboard.writeText(formatMemberLaunchDiagnosticsPayload(payload));
setCopied(true);
window.setTimeout(() => setCopied(false), 1500);
} catch {
setCopied(false);
}
};
const icon = copied ? <Check size={13} /> : <ClipboardList size={13} />;
const tooltip = copied ? 'Diagnostics copied' : 'Copy diagnostics';
return (
<Tooltip>
<TooltipTrigger asChild>
<Button
type="button"
variant="ghost"
size={size}
className={className}
title={tooltip}
aria-label={tooltip}
onClick={copyDiagnostics}
>
{icon}
{label ? <span>{label}</span> : null}
</Button>
</TooltipTrigger>
<TooltipContent side="bottom">{tooltip}</TooltipContent>
</Tooltip>
);
};

View file

@ -23,6 +23,7 @@ interface MemberListProps {
pendingRepliesByMember?: Record<string, number>;
memberSpawnStatuses?: Map<string, MemberSpawnStatusEntry>;
memberRuntimeEntries?: Map<string, TeamAgentRuntimeEntry>;
runtimeRunId?: string | null;
isLaunchSettling?: boolean;
isTeamAlive?: boolean;
isTeamProvisioning?: boolean;
@ -192,20 +193,34 @@ function areMemberRuntimeEntriesEquivalent(
if (left.size !== right.size) return false;
for (const [key, leftEntry] of left) {
const rightEntry = right.get(key);
const leftDiagnostics = leftEntry.diagnostics ?? [];
const rightDiagnostics = rightEntry?.diagnostics ?? [];
if (
leftEntry.memberName !== rightEntry?.memberName ||
leftEntry.alive !== rightEntry?.alive ||
leftEntry.restartable !== rightEntry?.restartable ||
leftEntry.backendType !== rightEntry?.backendType ||
leftEntry.providerId !== rightEntry?.providerId ||
leftEntry.providerBackendId !== rightEntry?.providerBackendId ||
leftEntry.laneId !== rightEntry?.laneId ||
leftEntry.laneKind !== rightEntry?.laneKind ||
leftEntry.pid !== rightEntry?.pid ||
leftEntry.runtimeModel !== rightEntry?.runtimeModel ||
leftEntry.rssBytes !== rightEntry?.rssBytes ||
leftEntry.livenessKind !== rightEntry?.livenessKind ||
leftEntry.pidSource !== rightEntry?.pidSource ||
leftEntry.processCommand !== rightEntry?.processCommand ||
leftEntry.paneId !== rightEntry?.paneId ||
leftEntry.panePid !== rightEntry?.panePid ||
leftEntry.paneCurrentCommand !== rightEntry?.paneCurrentCommand ||
leftEntry.runtimePid !== rightEntry?.runtimePid ||
leftEntry.runtimeSessionId !== rightEntry?.runtimeSessionId ||
leftEntry.runtimeDiagnostic !== rightEntry?.runtimeDiagnostic ||
leftEntry.runtimeDiagnosticSeverity !== rightEntry?.runtimeDiagnosticSeverity ||
leftEntry.runtimeLastSeenAt !== rightEntry?.runtimeLastSeenAt
leftEntry.runtimeLastSeenAt !== rightEntry?.runtimeLastSeenAt ||
leftEntry.historicalBootstrapConfirmed !== rightEntry?.historicalBootstrapConfirmed ||
leftDiagnostics.length !== rightDiagnostics.length ||
!leftDiagnostics.every((value, index) => value === rightDiagnostics[index])
) {
return false;
}
@ -224,6 +239,7 @@ function areMemberListPropsEqual(
arePendingRepliesEquivalent(prev.pendingRepliesByMember, next.pendingRepliesByMember) &&
areMemberSpawnStatusesEquivalent(prev.memberSpawnStatuses, next.memberSpawnStatuses) &&
areMemberRuntimeEntriesEquivalent(prev.memberRuntimeEntries, next.memberRuntimeEntries) &&
prev.runtimeRunId === next.runtimeRunId &&
prev.isLaunchSettling === next.isLaunchSettling &&
prev.isTeamAlive === next.isTeamAlive &&
prev.isTeamProvisioning === next.isTeamProvisioning &&
@ -239,6 +255,7 @@ export const MemberList = memo(function MemberList({
pendingRepliesByMember,
memberSpawnStatuses,
memberRuntimeEntries,
runtimeRunId,
isLaunchSettling,
isTeamAlive,
isTeamProvisioning,
@ -342,7 +359,9 @@ export const MemberList = memo(function MemberList({
isRemoved ? undefined : runtimeEntry
)}
runtimeEntry={isRemoved ? undefined : runtimeEntry}
runtimeRunId={isRemoved ? undefined : runtimeRunId}
spawnStatus={isRemoved ? undefined : spawnEntry?.status}
spawnEntry={isRemoved ? undefined : spawnEntry}
spawnError={isRemoved ? undefined : (spawnEntry?.error ?? spawnEntry?.hardFailureReason)}
spawnLivenessSource={isRemoved ? undefined : spawnEntry?.livenessSource}
spawnLaunchState={isRemoved ? undefined : spawnEntry?.launchState}

View file

@ -5,7 +5,7 @@ import { MembersEditorSection } from './MembersEditorSection';
import type { MemberDraft } from './membersEditorTypes';
import type { MentionSuggestion } from '@renderer/types/mention';
import type { EffortLevel, TeamFastMode, TeamProviderId } from '@shared/types';
import type { EffortLevel, TeamProviderId } from '@shared/types';
interface TeamRosterEditorSectionProps {
members: MemberDraft[];
@ -31,13 +31,10 @@ interface TeamRosterEditorSectionProps {
providerId: TeamProviderId;
model: string;
effort?: EffortLevel;
fastMode?: TeamFastMode;
providerFastModeDefault?: boolean;
limitContext: boolean;
onProviderChange: (providerId: TeamProviderId) => void;
onModelChange: (model: string) => void;
onEffortChange: (effort: string) => void;
onFastModeChange?: (fastMode: TeamFastMode) => void;
onLimitContextChange: (value: boolean) => void;
syncModelsWithTeammates: boolean;
onSyncModelsWithTeammatesChange: (value: boolean) => void;
@ -45,7 +42,6 @@ interface TeamRosterEditorSectionProps {
headerBottom?: React.ReactNode;
softDeleteMembers?: boolean;
leadWarningText?: string | null;
leadFastModeNotice?: string | null;
memberWarningById?: Record<string, string | null | undefined>;
disableGeminiOption?: boolean;
leadModelIssueText?: string | null;
@ -79,13 +75,10 @@ export const TeamRosterEditorSection = ({
providerId,
model,
effort,
fastMode,
providerFastModeDefault,
limitContext,
onProviderChange,
onModelChange,
onEffortChange,
onFastModeChange,
onLimitContextChange,
syncModelsWithTeammates,
onSyncModelsWithTeammatesChange,
@ -93,7 +86,6 @@ export const TeamRosterEditorSection = ({
headerBottom,
softDeleteMembers = false,
leadWarningText,
leadFastModeNotice,
memberWarningById,
disableGeminiOption = false,
leadModelIssueText,
@ -138,18 +130,14 @@ export const TeamRosterEditorSection = ({
providerId={providerId}
model={model}
effort={effort}
fastMode={fastMode}
providerFastModeDefault={providerFastModeDefault}
limitContext={limitContext}
onProviderChange={onProviderChange}
onModelChange={onModelChange}
onEffortChange={onEffortChange}
onFastModeChange={onFastModeChange}
onLimitContextChange={onLimitContextChange}
syncModelsWithTeammates={syncModelsWithTeammates}
onSyncModelsWithTeammatesChange={onSyncModelsWithTeammatesChange}
warningText={leadWarningText}
fastModeNotice={leadFastModeNotice}
disableGeminiOption={disableGeminiOption}
modelIssueText={leadModelIssueText}
/>

View file

@ -63,6 +63,10 @@ function isFailedSpawnEntry(entry: MemberSpawnStatusEntry | undefined): boolean
return entry?.launchState === 'failed_to_start' || entry?.status === 'error';
}
function isStrongRuntimeProcessSpawnEntry(entry: MemberSpawnStatusEntry): boolean {
return entry.runtimeAlive === true && entry.livenessKind === 'runtime_process';
}
function shouldPreferSnapshotEntryOverLive(
liveEntry: MemberSpawnStatusEntry | undefined,
snapshotEntry: MemberSpawnStatusEntry | undefined,
@ -127,11 +131,12 @@ function summarizeLiveLaunchJoinMilestones(params: {
heartbeatConfirmedCount += 1;
continue;
}
if (
entry.launchState === 'runtime_pending_bootstrap' ||
entry.launchState === 'runtime_pending_permission'
) {
if (entry.runtimeAlive === true && entry.livenessKind !== 'shell_only') {
if (entry.launchState === 'runtime_pending_permission') {
pendingSpawnCount += 1;
continue;
}
if (entry.launchState === 'runtime_pending_bootstrap') {
if (isStrongRuntimeProcessSpawnEntry(entry)) {
processOnlyAliveCount += 1;
} else {
pendingSpawnCount += 1;
@ -196,15 +201,12 @@ export function getLaunchJoinMilestonesFromMembers({
});
if (snapshotSummary) {
const snapshotProcessOnlyAliveCount = snapshotSummary.runtimeProcessPendingCount ?? 0;
const snapshotMilestones = {
expectedTeammateCount,
heartbeatConfirmedCount: snapshotSummary.confirmedCount,
processOnlyAliveCount:
snapshotSummary.runtimeProcessPendingCount ?? snapshotSummary.runtimeAlivePendingCount,
pendingSpawnCount: Math.max(
0,
snapshotSummary.pendingCount - snapshotSummary.runtimeAlivePendingCount
),
processOnlyAliveCount: snapshotProcessOnlyAliveCount,
pendingSpawnCount: Math.max(0, snapshotSummary.pendingCount - snapshotProcessOnlyAliveCount),
failedSpawnCount: snapshotSummary.failedCount,
};

View file

@ -374,6 +374,19 @@ export const createChangeReviewSlice: StateCreator<AppState, [], [], ChangeRevie
set,
get
) => {
const addMatchingReviewPathAliases = (
aliases: Set<string>,
filePath: string,
canonicalFilePath: string,
record: Record<string, unknown>
): void => {
for (const key of Object.keys(record)) {
if (reviewPathsEqual(key, filePath) || reviewPathsEqual(key, canonicalFilePath)) {
aliases.add(key);
}
}
};
const buildResolvedFileInvalidation = (
s: ChangeReviewSlice,
filePath: string
@ -388,17 +401,10 @@ export const createChangeReviewSlice: StateCreator<AppState, [], [], ChangeRevie
const existing = findReviewFileByPath(s.activeChangeSet?.files, filePath);
const canonicalFilePath = existing?.filePath ?? filePath;
const aliases = new Set([filePath, canonicalFilePath]);
const addMatchingAliases = (record: Record<string, unknown>): void => {
for (const key of Object.keys(record)) {
if (reviewPathsEqual(key, filePath) || reviewPathsEqual(key, canonicalFilePath)) {
aliases.add(key);
}
}
};
addMatchingAliases(s.fileChunkCounts);
addMatchingAliases(s.fileContents);
addMatchingAliases(s.fileContentsLoading);
addMatchingAliases(s.fileContentVersionByPath);
addMatchingReviewPathAliases(aliases, filePath, canonicalFilePath, s.fileChunkCounts);
addMatchingReviewPathAliases(aliases, filePath, canonicalFilePath, s.fileContents);
addMatchingReviewPathAliases(aliases, filePath, canonicalFilePath, s.fileContentsLoading);
addMatchingReviewPathAliases(aliases, filePath, canonicalFilePath, s.fileContentVersionByPath);
const nextFileChunkCounts = { ...s.fileChunkCounts };
for (const alias of aliases) delete nextFileChunkCounts[alias];
@ -1461,18 +1467,21 @@ export const createChangeReviewSlice: StateCreator<AppState, [], [], ChangeRevie
await api.review.saveEditedFile(canonicalFilePath, content, projectPath);
set((s) => {
const aliases = new Set([filePath, canonicalFilePath]);
const addMatchingAliases = (record: Record<string, unknown>): void => {
for (const key of Object.keys(record)) {
if (reviewPathsEqual(key, filePath) || reviewPathsEqual(key, canonicalFilePath)) {
aliases.add(key);
}
}
};
addMatchingAliases(s.editedContents);
addMatchingAliases(s.fileChunkCounts);
addMatchingAliases(s.hunkContextHashesByFile);
addMatchingAliases(s.reviewExternalChangesByFile);
addMatchingAliases(s.fileContents);
addMatchingReviewPathAliases(aliases, filePath, canonicalFilePath, s.editedContents);
addMatchingReviewPathAliases(aliases, filePath, canonicalFilePath, s.fileChunkCounts);
addMatchingReviewPathAliases(
aliases,
filePath,
canonicalFilePath,
s.hunkContextHashesByFile
);
addMatchingReviewPathAliases(
aliases,
filePath,
canonicalFilePath,
s.reviewExternalChangesByFile
);
addMatchingReviewPathAliases(aliases, filePath, canonicalFilePath, s.fileContents);
const nextEdited = { ...s.editedContents };
for (const alias of aliases) delete nextEdited[alias];

View file

@ -810,20 +810,34 @@ function areTeamAgentRuntimeEntriesEqual(
): boolean {
if (left === right) return true;
if (!left || !right) return left === right;
const leftDiagnostics = left.diagnostics ?? [];
const rightDiagnostics = right.diagnostics ?? [];
return (
left.memberName === right.memberName &&
left.alive === right.alive &&
left.restartable === right.restartable &&
left.backendType === right.backendType &&
left.providerId === right.providerId &&
left.providerBackendId === right.providerBackendId &&
left.laneId === right.laneId &&
left.laneKind === right.laneKind &&
left.pid === right.pid &&
left.runtimeModel === right.runtimeModel &&
left.rssBytes === right.rssBytes &&
left.livenessKind === right.livenessKind &&
left.pidSource === right.pidSource &&
left.processCommand === right.processCommand &&
left.paneId === right.paneId &&
left.panePid === right.panePid &&
left.paneCurrentCommand === right.paneCurrentCommand &&
left.runtimePid === right.runtimePid &&
left.runtimeSessionId === right.runtimeSessionId &&
left.runtimeDiagnostic === right.runtimeDiagnostic &&
left.runtimeDiagnosticSeverity === right.runtimeDiagnosticSeverity &&
left.runtimeLastSeenAt === right.runtimeLastSeenAt
left.runtimeLastSeenAt === right.runtimeLastSeenAt &&
left.historicalBootstrapConfirmed === right.historicalBootstrapConfirmed &&
leftDiagnostics.length === rightDiagnostics.length &&
leftDiagnostics.every((value, index) => value === rightDiagnostics[index])
);
}

View file

@ -656,12 +656,6 @@ export function buildMemberLaunchPresentation({
runtimeEntry?.livenessKind === 'not_found'
) {
launchVisualState = 'stale_runtime';
} else if (
spawnLaunchState === 'runtime_pending_bootstrap' &&
(runtimeEntry?.livenessKind === 'runtime_process' ||
(spawnStatus === 'online' && spawnRuntimeAlive === true))
) {
launchVisualState = 'runtime_pending';
} else if (
isLaunchStillStarting(
spawnStatus,
@ -671,6 +665,12 @@ export function buildMemberLaunchPresentation({
)
) {
launchVisualState = spawnStatus === 'spawning' ? 'spawning' : 'waiting';
} else if (
spawnLaunchState === 'runtime_pending_bootstrap' &&
(runtimeEntry?.livenessKind === 'runtime_process' ||
(spawnStatus === 'online' && spawnRuntimeAlive === true))
) {
launchVisualState = 'runtime_pending';
} else if (
isLaunchSettling &&
spawnStatus === 'online' &&
@ -681,15 +681,19 @@ export function buildMemberLaunchPresentation({
}
const launchStatusLabel = getMemberLaunchStatusLabel(launchVisualState);
const displayPresenceLabel =
const shouldShowLaunchStatusAsPresence =
launchVisualState === 'permission_pending' ||
launchVisualState === 'runtime_pending' ||
launchVisualState === 'shell_only' ||
launchVisualState === 'runtime_candidate' ||
launchVisualState === 'registered_only' ||
launchVisualState === 'stale_runtime'
? (launchStatusLabel ?? presenceLabel)
: presenceLabel;
launchVisualState === 'stale_runtime';
const displayPresenceLabel =
runtimeAdvisoryTone === 'error' && runtimeAdvisoryLabel
? runtimeAdvisoryLabel
: shouldShowLaunchStatusAsPresence
? (launchStatusLabel ?? presenceLabel)
: presenceLabel;
const spawnBadgeLabel =
spawnStatus && spawnStatus !== 'online'
? spawnStatus === 'waiting' || spawnStatus === 'spawning'

View file

@ -0,0 +1,189 @@
import type {
MemberLaunchState,
MemberSpawnLivenessSource,
MemberSpawnStatus,
MemberSpawnStatusEntry,
TeamAgentRuntimeDiagnosticSeverity,
TeamAgentRuntimeEntry,
TeamAgentRuntimeLivenessKind,
TeamAgentRuntimePidSource,
} from '@shared/types';
export interface MemberLaunchDiagnosticsPayload {
teamName?: string;
runId?: string;
memberName: string;
launchState?: MemberLaunchState;
spawnStatus?: MemberSpawnStatus;
livenessKind?: TeamAgentRuntimeLivenessKind;
livenessSource?: MemberSpawnLivenessSource;
pid?: number;
pidSource?: TeamAgentRuntimePidSource;
paneId?: string;
panePid?: number;
paneCurrentCommand?: string;
processCommand?: string;
runtimePid?: number;
runtimeSessionId?: string;
runtimeDiagnostic?: string;
runtimeDiagnosticSeverity?: TeamAgentRuntimeDiagnosticSeverity;
diagnostics?: string[];
updatedAt?: string;
}
const MAX_DIAGNOSTIC_STRING_LENGTH = 500;
const MAX_DIAGNOSTIC_ITEMS = 20;
const SECRET_FLAG_PATTERN =
/(--(?:api-key|token|password|secret|authorization|auth-token)(?:=|\s+))("[^"]*"|'[^']*'|\S+)/gi;
function boundedString(
value: string | undefined,
maxLength = MAX_DIAGNOSTIC_STRING_LENGTH
): string | undefined {
const trimmed = value?.replace(/\s+/g, ' ').trim();
if (!trimmed) return undefined;
const redacted = trimmed.replace(SECRET_FLAG_PATTERN, '$1[redacted]');
return redacted.length > maxLength
? `${redacted.slice(0, Math.max(0, maxLength - 3))}...`
: redacted;
}
function boundedNumber(value: number | undefined): number | undefined {
return typeof value === 'number' && Number.isFinite(value) && value > 0
? Math.trunc(value)
: undefined;
}
function uniqueDiagnostics(
...groups: Array<readonly (string | undefined)[] | undefined>
): string[] | undefined {
const seen = new Set<string>();
const diagnostics: string[] = [];
for (const group of groups) {
for (const item of group ?? []) {
const normalized = boundedString(item);
if (!normalized || seen.has(normalized)) continue;
seen.add(normalized);
diagnostics.push(normalized);
if (diagnostics.length >= MAX_DIAGNOSTIC_ITEMS) {
return diagnostics;
}
}
}
return diagnostics.length > 0 ? diagnostics : undefined;
}
export function buildMemberLaunchDiagnosticsPayload(params: {
teamName?: string | null;
runId?: string | null;
memberName: string;
spawnStatus?: MemberSpawnStatus;
launchState?: MemberLaunchState;
livenessSource?: MemberSpawnLivenessSource;
spawnEntry?: MemberSpawnStatusEntry;
runtimeEntry?: TeamAgentRuntimeEntry;
}): MemberLaunchDiagnosticsPayload {
const spawnEntry = params.spawnEntry;
const runtimeEntry = params.runtimeEntry;
const runtimeDiagnostic =
boundedString(spawnEntry?.runtimeDiagnostic) ??
boundedString(runtimeEntry?.runtimeDiagnostic) ??
boundedString(spawnEntry?.hardFailureReason) ??
boundedString(spawnEntry?.error);
const diagnostics = uniqueDiagnostics(
runtimeDiagnostic ? [runtimeDiagnostic] : undefined,
spawnEntry?.hardFailureReason ? [spawnEntry.hardFailureReason] : undefined,
spawnEntry?.error ? [spawnEntry.error] : undefined,
runtimeEntry?.diagnostics
);
const runId = boundedString(params.runId ?? undefined);
return {
...(params.teamName ? { teamName: params.teamName } : {}),
...(runId ? { runId } : {}),
memberName: params.memberName,
...((spawnEntry?.launchState ?? params.launchState)
? { launchState: spawnEntry?.launchState ?? params.launchState }
: {}),
...((spawnEntry?.status ?? params.spawnStatus)
? { spawnStatus: spawnEntry?.status ?? params.spawnStatus }
: {}),
...((spawnEntry?.livenessKind ?? runtimeEntry?.livenessKind)
? { livenessKind: spawnEntry?.livenessKind ?? runtimeEntry?.livenessKind }
: {}),
...((spawnEntry?.livenessSource ?? params.livenessSource)
? { livenessSource: spawnEntry?.livenessSource ?? params.livenessSource }
: {}),
...(boundedNumber(runtimeEntry?.pid) ? { pid: boundedNumber(runtimeEntry?.pid) } : {}),
...(runtimeEntry?.pidSource ? { pidSource: runtimeEntry.pidSource } : {}),
...(boundedString(runtimeEntry?.paneId) ? { paneId: boundedString(runtimeEntry?.paneId) } : {}),
...(boundedNumber(runtimeEntry?.panePid)
? { panePid: boundedNumber(runtimeEntry?.panePid) }
: {}),
...(boundedString(runtimeEntry?.paneCurrentCommand)
? { paneCurrentCommand: boundedString(runtimeEntry?.paneCurrentCommand) }
: {}),
...(boundedString(runtimeEntry?.processCommand)
? { processCommand: boundedString(runtimeEntry?.processCommand) }
: {}),
...(boundedNumber(runtimeEntry?.runtimePid)
? { runtimePid: boundedNumber(runtimeEntry?.runtimePid) }
: {}),
...(boundedString(runtimeEntry?.runtimeSessionId)
? { runtimeSessionId: boundedString(runtimeEntry?.runtimeSessionId) }
: {}),
...(runtimeDiagnostic ? { runtimeDiagnostic } : {}),
...((spawnEntry?.runtimeDiagnosticSeverity ?? runtimeEntry?.runtimeDiagnosticSeverity)
? {
runtimeDiagnosticSeverity:
spawnEntry?.runtimeDiagnosticSeverity ?? runtimeEntry?.runtimeDiagnosticSeverity,
}
: {}),
...(diagnostics ? { diagnostics } : {}),
...(boundedString(spawnEntry?.updatedAt ?? runtimeEntry?.updatedAt)
? { updatedAt: boundedString(spawnEntry?.updatedAt ?? runtimeEntry?.updatedAt) }
: {}),
};
}
export function hasMemberLaunchDiagnosticsDetails(
payload: MemberLaunchDiagnosticsPayload
): boolean {
const weakLiveness =
payload.livenessKind === 'runtime_process_candidate' ||
payload.livenessKind === 'permission_blocked' ||
payload.livenessKind === 'shell_only' ||
payload.livenessKind === 'registered_only' ||
payload.livenessKind === 'stale_metadata' ||
payload.livenessKind === 'not_found';
return Boolean(
(payload.launchState && payload.launchState !== 'confirmed_alive') ||
(payload.spawnStatus && payload.spawnStatus !== 'online') ||
weakLiveness ||
payload.runtimeDiagnostic ||
payload.diagnostics?.length
);
}
export function hasMemberLaunchDiagnosticsError(payload: MemberLaunchDiagnosticsPayload): boolean {
return Boolean(
payload.spawnStatus === 'error' ||
payload.launchState === 'failed_to_start' ||
payload.runtimeDiagnosticSeverity === 'error'
);
}
export function getMemberLaunchDiagnosticsErrorMessage(
payload: MemberLaunchDiagnosticsPayload
): string | undefined {
if (!hasMemberLaunchDiagnosticsError(payload)) {
return undefined;
}
return payload.runtimeDiagnostic ?? payload.diagnostics?.[0] ?? 'Launch failed';
}
export function formatMemberLaunchDiagnosticsPayload(
payload: MemberLaunchDiagnosticsPayload
): string {
return JSON.stringify(payload, null, 2);
}

View file

@ -40,6 +40,37 @@ function isMemberLaunchPending(spawnEntry: MemberSpawnStatusEntry | undefined):
);
}
export function getRuntimeMemorySourceLabel(
runtimeEntry: TeamAgentRuntimeEntry | undefined
): string | undefined {
if (!runtimeEntry?.pidSource) {
return undefined;
}
if (runtimeEntry.pidSource === 'tmux_pane') {
return 'RSS source: tmux pane shell';
}
if (
runtimeEntry.providerId === 'opencode' &&
runtimeEntry.restartable === false &&
runtimeEntry.pidSource === 'opencode_bridge'
) {
return 'RSS source: shared OpenCode host';
}
if (runtimeEntry.pidSource === 'tmux_child' || runtimeEntry.pidSource === 'agent_process_table') {
return 'RSS source: runtime process';
}
if (runtimeEntry.pidSource === 'lead_process') {
return 'RSS source: lead process';
}
if (runtimeEntry.pidSource === 'runtime_bootstrap') {
return 'RSS source: runtime bootstrap process';
}
if (runtimeEntry.pidSource === 'persisted_metadata') {
return 'RSS source: persisted runtime metadata';
}
return `PID source: ${runtimeEntry.pidSource}`;
}
export function resolveMemberRuntimeSummary(
member: ResolvedTeamMember,
launchParams: TeamLaunchParams | undefined,

View file

@ -2,10 +2,10 @@ export function buildPendingRuntimeSummaryCopy(input: {
confirmedCount?: number | null;
expectedMemberCount?: number | null;
memberCount?: number | null;
runtimeAlivePendingCount?: number | null;
runtimeProcessPendingCount?: number | null;
includePeriod?: boolean;
}): string {
const pendingCount = input.runtimeAlivePendingCount ?? 0;
const pendingCount = input.runtimeProcessPendingCount ?? 0;
if (pendingCount <= 0) {
return input.includePeriod
? 'Last launch is still reconciling.'

View file

@ -32,6 +32,17 @@ interface FailedSpawnDetail {
reason: string | null;
}
type PendingDiagnosticBucket =
| 'shellOnly'
| 'runtimeProcess'
| 'runtimeCandidate'
| 'permission'
| 'noRuntime';
type PendingDiagnosticNameGroups = Record<PendingDiagnosticBucket, string[]>;
const MAX_PENDING_DIAGNOSTIC_NAMES = 4;
function parseStatusUpdatedAtMs(value: string | undefined): number | null {
if (!value) {
return null;
@ -126,25 +137,130 @@ function buildAwaitingPermissionPhrase(count: number): string {
: `${count} teammates awaiting permission approval`;
}
function buildPendingDiagnosticPhrase(
summary: MemberSpawnStatusesSnapshot['summary'] | undefined,
fallbackJoiningPhrase: string
): string {
function getMemberNamesFromSpawnSources(params: {
memberSpawnStatuses: MemberSpawnStatusCollection;
memberSpawnSnapshotStatuses?: MemberSpawnStatusesSnapshot['statuses'];
}): string[] {
const names = new Set<string>();
if (params.memberSpawnStatuses instanceof Map) {
for (const name of params.memberSpawnStatuses.keys()) {
names.add(name);
}
} else if (params.memberSpawnStatuses) {
for (const name of Object.keys(params.memberSpawnStatuses)) {
names.add(name);
}
}
for (const name of Object.keys(params.memberSpawnSnapshotStatuses ?? {})) {
names.add(name);
}
return [...names].sort((left, right) => left.localeCompare(right));
}
function getPendingDiagnosticNameGroups(params: {
memberSpawnStatuses: MemberSpawnStatusCollection;
memberSpawnSnapshotStatuses?: MemberSpawnStatusesSnapshot['statuses'];
memberSpawnSnapshotUpdatedAt?: string;
}): PendingDiagnosticNameGroups {
const groups: PendingDiagnosticNameGroups = {
shellOnly: [],
runtimeProcess: [],
runtimeCandidate: [],
permission: [],
noRuntime: [],
};
for (const name of getMemberNamesFromSpawnSources(params)) {
const liveEntry =
params.memberSpawnStatuses instanceof Map
? params.memberSpawnStatuses.get(name)
: params.memberSpawnStatuses?.[name];
const snapshotEntry = params.memberSpawnSnapshotStatuses?.[name];
const entry = getPreferredSpawnEntry({
liveEntry,
snapshotEntry,
snapshotUpdatedAt: params.memberSpawnSnapshotUpdatedAt,
});
if (!entry || entry.launchState === 'confirmed_alive' || isFailedSpawnEntry(entry)) {
continue;
}
if (
entry.launchState === 'runtime_pending_permission' ||
(entry.pendingPermissionRequestIds?.length ?? 0) > 0
) {
groups.permission.push(name);
continue;
}
if (entry.livenessKind === 'shell_only') {
groups.shellOnly.push(name);
} else if (entry.livenessKind === 'runtime_process') {
groups.runtimeProcess.push(name);
} else if (entry.livenessKind === 'runtime_process_candidate') {
groups.runtimeCandidate.push(name);
} else if (
entry.livenessKind === 'not_found' ||
entry.livenessKind === 'stale_metadata' ||
entry.livenessKind === 'registered_only'
) {
groups.noRuntime.push(name);
}
}
return groups;
}
function formatNamedPendingDiagnostic(label: string, names: readonly string[]): string | null {
if (names.length === 0) {
return null;
}
const listedNames = names.slice(0, MAX_PENDING_DIAGNOSTIC_NAMES).join(', ');
const remainingCount = names.length - Math.min(names.length, MAX_PENDING_DIAGNOSTIC_NAMES);
return `${label}: ${listedNames}${remainingCount > 0 ? `, +${remainingCount} more` : ''}`;
}
function formatCountPendingDiagnostic(count: number | undefined, label: string): string | null {
return count && count > 0 ? `${count} ${label}` : null;
}
function buildPendingDiagnosticPhrase({
summary,
memberSpawnStatuses,
memberSpawnSnapshotStatuses,
memberSpawnSnapshotUpdatedAt,
fallbackJoiningPhrase,
}: {
summary: MemberSpawnStatusesSnapshot['summary'] | undefined;
memberSpawnStatuses: MemberSpawnStatusCollection;
memberSpawnSnapshotStatuses?: MemberSpawnStatusesSnapshot['statuses'];
memberSpawnSnapshotUpdatedAt?: string;
fallbackJoiningPhrase: string;
}): string {
const groups = getPendingDiagnosticNameGroups({
memberSpawnStatuses,
memberSpawnSnapshotStatuses,
memberSpawnSnapshotUpdatedAt,
});
const namedParts = [
formatNamedPendingDiagnostic('Shell-only', groups.shellOnly),
formatNamedPendingDiagnostic('Waiting for bootstrap', groups.runtimeProcess),
formatNamedPendingDiagnostic('Process candidates', groups.runtimeCandidate),
formatNamedPendingDiagnostic('Awaiting permission', groups.permission),
formatNamedPendingDiagnostic('No runtime found', groups.noRuntime),
].filter(Boolean);
if (namedParts.length > 0) {
return namedParts.join(', ');
}
if (!summary) {
return fallbackJoiningPhrase;
}
const parts = [
summary.shellOnlyPendingCount ? `${summary.shellOnlyPendingCount} shell-only` : '',
summary.runtimeProcessPendingCount
? `${summary.runtimeProcessPendingCount} waiting for bootstrap`
: '',
summary.runtimeCandidatePendingCount
? `${summary.runtimeCandidatePendingCount} process candidates`
: '',
summary.permissionPendingCount ? `${summary.permissionPendingCount} awaiting permission` : '',
summary.noRuntimePendingCount ? `${summary.noRuntimePendingCount} no runtime found` : '',
const countParts = [
formatCountPendingDiagnostic(summary.shellOnlyPendingCount, 'shell-only'),
formatCountPendingDiagnostic(summary.runtimeProcessPendingCount, 'waiting for bootstrap'),
formatCountPendingDiagnostic(summary.runtimeCandidatePendingCount, 'process candidates'),
formatCountPendingDiagnostic(summary.permissionPendingCount, 'awaiting permission'),
formatCountPendingDiagnostic(summary.noRuntimePendingCount, 'no runtime found'),
].filter(Boolean);
return parts.length > 0 ? parts.join(', ') : fallbackJoiningPhrase;
return countParts.length > 0 ? countParts.join(', ') : fallbackJoiningPhrase;
}
const ACTIVE_PROVISIONING_STATES = new Set([
@ -415,7 +531,13 @@ export function buildTeamProvisioningPresentation({
permissionBlockedCount === remainingJoinCount;
const pendingDetailPhrase = pendingMembersAwaitApproval
? buildAwaitingPermissionPhrase(permissionBlockedCount)
: buildPendingDiagnosticPhrase(memberSpawnSnapshot?.summary, joiningPhrase);
: buildPendingDiagnosticPhrase({
summary: memberSpawnSnapshot?.summary,
memberSpawnStatuses,
memberSpawnSnapshotStatuses: memberSpawnSnapshot?.statuses,
memberSpawnSnapshotUpdatedAt: memberSpawnSnapshot?.updatedAt,
fallbackJoiningPhrase: joiningPhrase,
});
const readyCompactDetail =
failedSpawnCount > 0
? (failedSpawnCompactDetail ??
@ -492,7 +614,13 @@ export function buildTeamProvisioningPresentation({
permissionBlockedCount > 0 &&
permissionBlockedCount === remainingJoinCount
? buildAwaitingPermissionPhrase(permissionBlockedCount)
: buildPendingDiagnosticPhrase(memberSpawnSnapshot?.summary, activeJoiningPhrase);
: buildPendingDiagnosticPhrase({
summary: memberSpawnSnapshot?.summary,
memberSpawnStatuses,
memberSpawnSnapshotStatuses: memberSpawnSnapshot?.statuses,
memberSpawnSnapshotUpdatedAt: memberSpawnSnapshot?.updatedAt,
fallbackJoiningPhrase: activeJoiningPhrase,
});
return {
progress,
isActive: true,

View file

@ -1043,6 +1043,8 @@ export interface TeamAgentRuntimeEntry {
runtimeSessionId?: string;
runtimeLeaseExpiresAt?: string;
runtimeLastSeenAt?: string;
/** True when a previous/persisted launch confirmed bootstrap, separate from current live liveness. */
historicalBootstrapConfirmed?: boolean;
runtimeDiagnostic?: string;
runtimeDiagnosticSeverity?: TeamAgentRuntimeDiagnosticSeverity;
diagnostics?: string[];

View file

@ -24,13 +24,28 @@ export function getReviewStateFromTask(task: ReviewStateLike): TeamReviewState {
}
}
const explicit = normalizeReviewState(task.reviewState);
if (explicit !== 'none') {
const fallbackStatus = typeof task.status === 'string' ? task.status : null;
const normalizeFallback = (value: unknown): TeamReviewState | null => {
const explicit = normalizeReviewState(value);
if (explicit === 'none') return null;
if (fallbackStatus === 'in_progress' || fallbackStatus === 'deleted') {
return 'none';
}
if (fallbackStatus === 'pending') {
return explicit === 'needsFix' ? 'needsFix' : 'none';
}
if (fallbackStatus === 'completed') {
return explicit === 'review' || explicit === 'approved' ? explicit : 'none';
}
return explicit;
}
};
const explicit = normalizeFallback(task.reviewState);
if (explicit) return explicit;
if (task.kanbanColumn === 'review' || task.kanbanColumn === 'approved') {
return task.kanbanColumn;
return normalizeFallback(task.kanbanColumn) ?? 'none';
}
return 'none';

View file

@ -1,6 +1,6 @@
import { getDerivedReviewState } from './taskHistory';
import { getReviewStateFromTask } from './reviewState';
import type { TaskHistoryEvent, TeamReviewState } from '@shared/types';
import type { TeamReviewState } from '@shared/types';
export type TaskChangeStateBucket = 'approved' | 'review' | 'completed' | 'active';
@ -11,25 +11,8 @@ interface TaskChangeStateLike {
kanbanColumn?: 'review' | 'approved' | null;
}
function normalizeReviewState(value: unknown): TeamReviewState {
return value === 'review' || value === 'needsFix' || value === 'approved' ? value : 'none';
}
function getEffectiveReviewState(task: TaskChangeStateLike): TeamReviewState {
if (Array.isArray(task.historyEvents) && task.historyEvents.length > 0) {
return getDerivedReviewState({ historyEvents: task.historyEvents as TaskHistoryEvent[] });
}
const explicit = normalizeReviewState(task.reviewState);
if (explicit !== 'none') {
return explicit;
}
if (task.kanbanColumn === 'review' || task.kanbanColumn === 'approved') {
return task.kanbanColumn;
}
return 'none';
return getReviewStateFromTask(task);
}
export function getTaskChangeStateBucket(task: TaskChangeStateLike): TaskChangeStateBucket {

View file

@ -155,7 +155,7 @@ describe('ListDashboardRecentProjectsUseCase', () => {
}),
],
});
expect(cache.set).toHaveBeenCalledWith('recent-projects:fresh', result, 1_500);
expect(cache.set).toHaveBeenCalledWith('recent-projects:fresh', result, 30_000);
expect(logger.warn).toHaveBeenCalledWith('recent-projects source failed', {
sourceId: 'source-1',
sourceIndex: 1,
@ -165,7 +165,7 @@ describe('ListDashboardRecentProjectsUseCase', () => {
cacheKey: 'recent-projects:fresh',
count: 1,
degradedSources: 1,
cacheTtlMs: 1_500,
cacheTtlMs: 30_000,
durationMs: 250,
});
});
@ -242,7 +242,7 @@ describe('ListDashboardRecentProjectsUseCase', () => {
expect(cache.set).toHaveBeenCalledWith(
'recent-projects:timeout',
{ ids: ['repo:fast'], sources: ['claude'] },
1_500
30_000
);
} finally {
vi.useRealTimers();
@ -311,13 +311,13 @@ describe('ListDashboardRecentProjectsUseCase', () => {
expect(cache.set).toHaveBeenCalledWith(
'recent-projects:stale',
{ ids: ['repo:fresh'], sources: ['claude'] },
1_500
30_000
);
expect(logger.info).toHaveBeenCalledWith('recent-projects loaded', {
cacheKey: 'recent-projects:stale',
count: 1,
degradedSources: 1,
cacheTtlMs: 1_500,
cacheTtlMs: 30_000,
durationMs: 200,
});
});
@ -364,11 +364,11 @@ describe('ListDashboardRecentProjectsUseCase', () => {
await expect(useCase.execute('recent-projects:stale')).resolves.toEqual(stale);
expect(output.present).not.toHaveBeenCalled();
expect(cache.set).toHaveBeenCalledWith('recent-projects:stale', stale, 1_500);
expect(cache.set).toHaveBeenCalledWith('recent-projects:stale', stale, 30_000);
expect(logger.info).toHaveBeenCalledWith('recent-projects served stale cache', {
cacheKey: 'recent-projects:stale',
degradedSources: 1,
cacheTtlMs: 1_500,
cacheTtlMs: 30_000,
durationMs: 200,
});
});
@ -431,13 +431,13 @@ describe('ListDashboardRecentProjectsUseCase', () => {
expect(cache.set).toHaveBeenCalledWith(
'recent-projects:explicit-degraded',
{ ids: ['repo:alpha'], sources: ['claude'] },
1_500
30_000
);
expect(logger.info).toHaveBeenCalledWith('recent-projects loaded', {
cacheKey: 'recent-projects:explicit-degraded',
count: 1,
degradedSources: 1,
cacheTtlMs: 1_500,
cacheTtlMs: 30_000,
durationMs: 0,
});
});

View file

@ -166,7 +166,18 @@ describe('CodexRecentProjectsSourceAdapter', () => {
candidates: [],
degraded: true,
});
await expect(adapter.list()).resolves.toEqual({
candidates: [],
degraded: true,
});
expect(appServerClient.listRecentThreads).toHaveBeenCalledTimes(1);
expect(appServerClient.listRecentLiveThreads).not.toHaveBeenCalled();
expect(logger.info).toHaveBeenCalledWith(
'codex recent-projects source cooldown active',
expect.objectContaining({
reason: 'codex app-server thread/list timed out after 8500ms',
})
);
});
it('drops Codex appstyle temp workspaces from dashboard candidates', async () => {
@ -209,4 +220,81 @@ describe('CodexRecentProjectsSourceAdapter', () => {
expect(identityResolver.resolve).not.toHaveBeenCalled();
});
it('serves stale Codex candidates during a later full thread-list failure', async () => {
const logger = createLogger();
const appServerClient = {
listRecentThreads: vi
.fn()
.mockResolvedValueOnce({
live: {
threads: [
{
id: 'thread-live',
cwd: '/Users/belief/dev/projects/headless',
source: 'cli',
updatedAt: 1_700_000_000,
gitInfo: { branch: 'main' },
},
],
},
archived: {
threads: [],
},
})
.mockResolvedValueOnce({
live: {
threads: [],
error: 'JSON-RPC request timed out: thread/list live',
},
archived: {
threads: [],
error: 'JSON-RPC request timed out: thread/list archived',
},
}),
listRecentLiveThreads: vi.fn(),
} as unknown as CodexAppServerClient;
const identityResolver = {
resolve: vi.fn().mockResolvedValue({
id: 'repo:headless',
name: 'headless',
}),
} as unknown as RecentProjectIdentityResolver;
const adapter = new CodexRecentProjectsSourceAdapter({
getActiveContext: () => ({ type: 'local', id: 'local-1' }) as never,
getLocalContext: () => ({ type: 'local', id: 'local-1' }) as never,
resolveBinary: vi.fn().mockResolvedValue('/usr/local/bin/codex'),
appServerClient,
identityResolver,
logger,
});
await expect(adapter.list()).resolves.toEqual({
candidates: [
expect.objectContaining({
identity: 'repo:headless',
primaryPath: '/Users/belief/dev/projects/headless',
}),
],
degraded: false,
});
await expect(adapter.list()).resolves.toEqual({
candidates: [
expect.objectContaining({
identity: 'repo:headless',
primaryPath: '/Users/belief/dev/projects/headless',
}),
],
degraded: true,
});
expect(identityResolver.resolve).toHaveBeenCalledTimes(1);
expect(logger.info).toHaveBeenCalledWith('codex recent-projects served stale candidates', {
count: 1,
reason:
'live: JSON-RPC request timed out: thread/list live; archived: JSON-RPC request timed out: thread/list archived',
});
});
});

View file

@ -21,8 +21,9 @@ function createSession(
describe('CodexAppServerClient', () => {
it('loads live and archived threads in a single app-server session', async () => {
const session = createSession(
vi.fn().mockImplementation((method: string, params?: { archived?: boolean }) => {
const request = vi
.fn()
.mockImplementation((method: string, params?: { archived?: boolean }) => {
if (method === 'initialize') {
return Promise.resolve({});
}
@ -40,8 +41,8 @@ describe('CodexAppServerClient', () => {
}
return Promise.reject(new Error(`Unexpected method: ${method}`));
})
);
});
const session = createSession(request);
const withSession = vi.fn().mockImplementation((_options, handler) => handler(session));
const client = new CodexAppServerClient({ withSession } as unknown as JsonRpcStdioClient);
@ -58,11 +59,23 @@ describe('CodexAppServerClient', () => {
expect.objectContaining({
binaryPath: '/usr/local/bin/codex',
requestTimeoutMs: 4500,
totalTimeoutMs: 12000,
totalTimeoutMs: 14500,
}),
expect.any(Function)
);
expect(session.notify).toHaveBeenCalledWith('initialized');
expect(request).toHaveBeenNthCalledWith(
2,
'thread/list',
expect.objectContaining({ archived: false }),
4500
);
expect(request).toHaveBeenNthCalledWith(
3,
'thread/list',
expect.objectContaining({ archived: true }),
2500
);
expect(result).toEqual({
live: {
threads: [{ id: 'live-1', cwd: '/Users/test/live-project', source: 'cli' }],
@ -113,7 +126,7 @@ describe('CodexAppServerClient', () => {
});
});
it('raises the session timeout budget above the longest request timeout', async () => {
it('raises the session timeout budget above sequential request timeouts', async () => {
const session = createSession(
vi.fn().mockImplementation((method: string, params?: { archived?: boolean }) => {
if (method === 'initialize') {
@ -140,7 +153,7 @@ describe('CodexAppServerClient', () => {
expect(withSession).toHaveBeenCalledWith(
expect.objectContaining({
totalTimeoutMs: 12000,
totalTimeoutMs: 14500,
}),
expect.any(Function)
);
@ -187,18 +200,20 @@ describe('CodexAppServerClient', () => {
});
it('uses the longer initialize timeout for app-server startup', async () => {
const request = vi.fn().mockImplementation((method: string, _params?: unknown, timeoutMs?: number) => {
if (method === 'initialize') {
expect(timeoutMs).toBe(6000);
return Promise.resolve({});
}
const request = vi
.fn()
.mockImplementation((method: string, _params?: unknown, timeoutMs?: number) => {
if (method === 'initialize') {
expect(timeoutMs).toBe(6000);
return Promise.resolve({});
}
if (method === 'thread/list') {
return Promise.resolve({ data: [] });
}
if (method === 'thread/list') {
return Promise.resolve({ data: [] });
}
return Promise.reject(new Error(`Unexpected method: ${method}`));
});
return Promise.reject(new Error(`Unexpected method: ${method}`));
});
const session = createSession(request);
const withSession = vi.fn().mockImplementation((_options, handler) => handler(session));

View file

@ -103,7 +103,7 @@ describe('recentProjectsClientCache', () => {
await expect(second).resolves.toEqual(payload('alpha'));
});
it('marks degraded payload snapshots stale faster than healthy payloads', async () => {
it('keeps degraded payload snapshots fresh long enough to avoid hot retry loops', async () => {
vi.useFakeTimers();
vi.setSystemTime(new Date('2026-04-14T12:00:00.000Z'));
@ -121,7 +121,13 @@ describe('recentProjectsClientCache', () => {
isStale: false,
});
vi.setSystemTime(new Date('2026-04-14T12:00:02.000Z'));
vi.setSystemTime(new Date('2026-04-14T12:00:20.000Z'));
expect(getRecentProjectsClientSnapshot()).toMatchObject({
payload: payload('alpha', { degraded: true }),
isStale: false,
});
vi.setSystemTime(new Date('2026-04-14T12:00:31.000Z'));
expect(getRecentProjectsClientSnapshot()).toMatchObject({
payload: payload('alpha', { degraded: true }),
isStale: true,
@ -129,7 +135,9 @@ describe('recentProjectsClientCache', () => {
});
it('normalizes legacy array responses from the loader during mixed-version dev reloads', async () => {
const loader = vi.fn<() => Promise<DashboardRecentProject[]>>().mockResolvedValue([project('alpha')]);
const loader = vi
.fn<() => Promise<DashboardRecentProject[]>>()
.mockResolvedValue([project('alpha')]);
await expect(loadRecentProjectsWithClientCache(loader)).resolves.toEqual(payload('alpha'));
expect(getRecentProjectsClientSnapshot()?.payload).toEqual(payload('alpha'));

View file

@ -0,0 +1,20 @@
import { describe, expect, it } from 'vitest';
import { parseRuntimeProcessTable } from '@features/tmux-installer/main';
describe('parseRuntimeProcessTable', () => {
it('parses pid, ppid and command rows', () => {
expect(
parseRuntimeProcessTable(' 10 1 /bin/zsh\n 11 10 node runtime --team-name demo')
).toEqual([
{ pid: 10, ppid: 1, command: '/bin/zsh' },
{ pid: 11, ppid: 10, command: 'node runtime --team-name demo' },
]);
});
it('skips malformed rows', () => {
expect(parseRuntimeProcessTable('bad\n 0 1 nope\n 12 0 /bin/node')).toEqual([
{ pid: 12, ppid: 0, command: '/bin/node' },
]);
});
});

View file

@ -0,0 +1,198 @@
import { constants as fsConstants, promises as fs } from 'node:fs';
import * as os from 'node:os';
import * as path from 'node:path';
import { afterEach, beforeEach, describe, expect, it } from 'vitest';
import { TeamProvisioningService } from '../../../../src/main/services/team/TeamProvisioningService';
import { setClaudeBasePathOverride } from '../../../../src/main/utils/pathDecoder';
import type {
TeamAgentRuntimeSnapshot,
TeamProvisioningProgress,
} from '../../../../src/shared/types';
const liveDescribe =
process.env.ANTHROPIC_RUNTIME_MEMORY_LIVE === '1' && process.env.ANTHROPIC_API_KEY?.trim()
? describe
: describe.skip;
const DEFAULT_ORCHESTRATOR_CLI = '/Users/belief/dev/projects/claude/agent_teams_orchestrator/cli';
const DEFAULT_MODEL = 'haiku';
liveDescribe('Anthropic runtime memory live e2e', () => {
let tempDir: string;
let tempClaudeRoot: string;
let previousCliPath: string | undefined;
let previousCliFlavor: string | undefined;
let previousDisableAppBootstrap: string | undefined;
let previousDisableRuntimeBootstrap: string | undefined;
let previousHome: string | undefined;
let previousUserProfile: string | undefined;
let svc: TeamProvisioningService | null;
let teamName: string | null;
beforeEach(async () => {
tempDir = await fs.mkdtemp(path.join(os.tmpdir(), 'anthropic-runtime-memory-live-'));
tempClaudeRoot = path.join(tempDir, '.claude');
const tempHome = path.join(tempDir, 'home');
await fs.mkdir(tempClaudeRoot, { recursive: true });
await fs.mkdir(tempHome, { recursive: true });
setClaudeBasePathOverride(tempClaudeRoot);
previousCliPath = process.env.CLAUDE_AGENT_TEAMS_ORCHESTRATOR_CLI_PATH;
previousCliFlavor = process.env.CLAUDE_TEAM_CLI_FLAVOR;
previousDisableAppBootstrap = process.env.CLAUDE_APP_DISABLE_DETERMINISTIC_TEAM_BOOTSTRAP;
previousDisableRuntimeBootstrap = process.env.CLAUDE_DISABLE_DETERMINISTIC_TEAM_BOOTSTRAP;
previousHome = process.env.HOME;
previousUserProfile = process.env.USERPROFILE;
process.env.HOME = tempHome;
process.env.USERPROFILE = tempHome;
process.env.CLAUDE_AGENT_TEAMS_ORCHESTRATOR_CLI_PATH =
process.env.CLAUDE_AGENT_TEAMS_ORCHESTRATOR_CLI_PATH?.trim() || DEFAULT_ORCHESTRATOR_CLI;
process.env.CLAUDE_TEAM_CLI_FLAVOR = 'agent_teams_orchestrator';
delete process.env.CLAUDE_APP_DISABLE_DETERMINISTIC_TEAM_BOOTSTRAP;
delete process.env.CLAUDE_DISABLE_DETERMINISTIC_TEAM_BOOTSTRAP;
svc = null;
teamName = null;
});
afterEach(async () => {
if (svc && teamName) {
await svc.stopTeam(teamName).catch(() => undefined);
}
setClaudeBasePathOverride(null);
restoreEnv('CLAUDE_AGENT_TEAMS_ORCHESTRATOR_CLI_PATH', previousCliPath);
restoreEnv('CLAUDE_TEAM_CLI_FLAVOR', previousCliFlavor);
restoreEnv('CLAUDE_APP_DISABLE_DETERMINISTIC_TEAM_BOOTSTRAP', previousDisableAppBootstrap);
restoreEnv('CLAUDE_DISABLE_DETERMINISTIC_TEAM_BOOTSTRAP', previousDisableRuntimeBootstrap);
restoreEnv('HOME', previousHome);
restoreEnv('USERPROFILE', previousUserProfile);
await fs.rm(tempDir, { recursive: true, force: true });
});
it('creates a real Anthropic team and reports teammate RSS in the runtime snapshot', async () => {
const orchestratorCli = process.env.CLAUDE_AGENT_TEAMS_ORCHESTRATOR_CLI_PATH?.trim();
expect(orchestratorCli).toBeTruthy();
await assertExecutable(orchestratorCli!);
const selectedModel = process.env.ANTHROPIC_RUNTIME_MEMORY_LIVE_MODEL?.trim() || DEFAULT_MODEL;
teamName = `anthropic-memory-live-${Date.now()}`;
const projectPath = path.join(tempDir, 'project');
await fs.mkdir(projectPath, { recursive: true });
await fs.writeFile(
path.join(projectPath, 'README.md'),
'# Anthropic runtime memory live e2e\n',
'utf8'
);
svc = new TeamProvisioningService();
const progressEvents: TeamProvisioningProgress[] = [];
await svc.createTeam(
{
teamName,
cwd: projectPath,
providerId: 'anthropic',
model: selectedModel,
skipPermissions: true,
prompt: 'Keep the team idle after bootstrap. Do not start extra work.',
members: [
{
name: 'alice',
role: 'Developer',
providerId: 'anthropic',
model: selectedModel,
},
],
},
(progress) => {
progressEvents.push(progress);
}
);
await waitUntil(async () => {
const last = progressEvents.at(-1);
if (last?.state === 'failed') {
throw new Error(formatProgressDump(progressEvents));
}
if (last?.state === 'ready') {
return true;
}
return false;
}, 240_000);
let snapshot: TeamAgentRuntimeSnapshot | null = null;
await waitUntil(async () => {
snapshot = await svc!.getTeamAgentRuntimeSnapshot(teamName!);
const alice = snapshot.members.alice;
return (
alice?.providerId === 'anthropic' &&
alice.pidSource === 'agent_process_table' &&
alice.livenessKind === 'runtime_process' &&
typeof alice.pid === 'number' &&
typeof alice.rssBytes === 'number' &&
alice.rssBytes > 0
);
}, 60_000);
expect(snapshot!.members.alice).toMatchObject({
alive: true,
providerId: 'anthropic',
pidSource: 'agent_process_table',
livenessKind: 'runtime_process',
runtimeModel: selectedModel,
});
expect(snapshot!.members.alice.rssBytes).toBeGreaterThan(0);
}, 300_000);
});
function restoreEnv(name: string, previous: string | undefined): void {
if (previous === undefined) {
delete process.env[name];
} else {
process.env[name] = previous;
}
}
async function assertExecutable(filePath: string): Promise<void> {
await fs.access(filePath, fsConstants.X_OK);
}
async function waitUntil(
predicate: () => Promise<boolean>,
timeoutMs: number,
pollMs = 1_000
): Promise<void> {
const deadline = Date.now() + timeoutMs;
let lastError: unknown;
while (Date.now() < deadline) {
try {
if (await predicate()) {
return;
}
} catch (error) {
lastError = error;
throw error;
}
await new Promise((resolve) => setTimeout(resolve, pollMs));
}
const suffix =
lastError instanceof Error && lastError.message ? ` Last error: ${lastError.message}` : '';
throw new Error(`Timed out after ${timeoutMs}ms waiting for condition.${suffix}`);
}
function formatProgressDump(progressEvents: TeamProvisioningProgress[]): string {
return progressEvents
.map((progress) =>
[
progress.state,
progress.message,
progress.messageSeverity,
progress.error,
progress.cliLogsTail,
]
.filter(Boolean)
.join(' | ')
)
.join('\n');
}

View file

@ -311,7 +311,7 @@ describe('OpenCodeTeamRuntimeAdapter', () => {
alice: {
providerId: 'opencode',
launchState: 'runtime_pending_bootstrap',
runtimeAlive: true,
runtimeAlive: false,
bootstrapConfirmed: false,
},
},
@ -501,8 +501,9 @@ describe('OpenCodeTeamRuntimeAdapter', () => {
providerId: 'opencode',
launchState: 'runtime_pending_permission',
pendingPermissionRequestIds: ['perm-1', 'perm-2'],
runtimeAlive: true,
runtimeAlive: false,
agentToolAccepted: true,
livenessKind: 'permission_blocked',
bootstrapConfirmed: false,
hardFailure: false,
},
@ -517,6 +518,116 @@ describe('OpenCodeTeamRuntimeAdapter', () => {
});
});
it('does not mark created bridge members without runtimePid as runtimeAlive', async () => {
const launchOpenCodeTeam = vi.fn(
async () =>
({
runId: 'run-1',
teamLaunchState: 'launching',
members: {
alice: {
sessionId: 'oc-session-1',
launchState: 'created',
model: 'openai/gpt-5.4-mini',
evidence: [],
},
},
warnings: [],
diagnostics: [],
}) satisfies OpenCodeLaunchTeamCommandData
);
const adapter = new OpenCodeTeamRuntimeAdapter(
bridgePort(readiness({ state: 'ready', launchAllowed: true }), {
launchOpenCodeTeam,
}),
{ launchMode: 'dogfood' }
);
const result = await adapter.launch(launchInput());
expect(result.members.alice).toMatchObject({
launchState: 'runtime_pending_bootstrap',
agentToolAccepted: true,
runtimeAlive: false,
livenessKind: 'runtime_process_candidate',
runtimeDiagnostic: 'OpenCode session exists without verified runtime pid',
});
});
it('keeps created bridge runtimePid provisional until local process verification', async () => {
const launchOpenCodeTeam = vi.fn(
async () =>
({
runId: 'run-1',
teamLaunchState: 'launching',
members: {
alice: {
sessionId: 'oc-session-1',
launchState: 'created',
runtimePid: 123,
model: 'openai/gpt-5.4-mini',
evidence: [],
},
},
warnings: [],
diagnostics: [],
}) satisfies OpenCodeLaunchTeamCommandData
);
const adapter = new OpenCodeTeamRuntimeAdapter(
bridgePort(readiness({ state: 'ready', launchAllowed: true }), {
launchOpenCodeTeam,
}),
{ launchMode: 'dogfood' }
);
const result = await adapter.launch(launchInput());
expect(result.members.alice).toMatchObject({
launchState: 'runtime_pending_bootstrap',
agentToolAccepted: true,
runtimeAlive: false,
livenessKind: 'runtime_process_candidate',
runtimePid: 123,
runtimeDiagnostic: 'OpenCode runtime pid reported by bridge without local process verification',
});
});
it('treats materialized bridge members without session or pid as accepted but not alive', async () => {
const launchOpenCodeTeam = vi.fn(
async () =>
({
runId: 'run-1',
teamLaunchState: 'launching',
members: {
alice: {
sessionId: '',
launchState: 'created',
model: 'openai/gpt-5.4-mini',
evidence: [],
},
},
warnings: [],
diagnostics: [],
}) satisfies OpenCodeLaunchTeamCommandData
);
const adapter = new OpenCodeTeamRuntimeAdapter(
bridgePort(readiness({ state: 'ready', launchAllowed: true }), {
launchOpenCodeTeam,
}),
{ launchMode: 'dogfood' }
);
const result = await adapter.launch(launchInput());
expect(result.members.alice).toMatchObject({
launchState: 'runtime_pending_bootstrap',
agentToolAccepted: true,
runtimeAlive: false,
livenessKind: 'runtime_process_candidate',
runtimeDiagnostic: 'OpenCode session exists without verified runtime pid',
});
});
it('keeps missing bridge members in bootstrap pending even when another member blocks on permission', async () => {
const launchOpenCodeTeam = vi.fn(
async () =>

View file

@ -211,9 +211,9 @@ describe('Team agent launch matrix safe e2e', () => {
const statuses = await svc.getMemberSpawnStatuses('permission-opencode-safe-e2e');
expect(statuses.teamLaunchState).toBe('partial_pending');
expect(statuses.statuses.alice).toMatchObject({
status: 'online',
status: 'waiting',
launchState: 'runtime_pending_permission',
runtimeAlive: true,
runtimeAlive: false,
pendingPermissionRequestIds: ['perm-alice'],
});
expect(statuses.summary?.pendingCount).toBe(1);
@ -255,9 +255,9 @@ describe('Team agent launch matrix safe e2e', () => {
bootstrapConfirmed: true,
});
expect(statuses.statuses.bob).toMatchObject({
status: 'online',
status: 'waiting',
launchState: 'runtime_pending_permission',
runtimeAlive: true,
runtimeAlive: false,
pendingPermissionRequestIds: ['perm-bob'],
});
expect(statuses.statuses.tom).toMatchObject({
@ -2300,7 +2300,7 @@ describe('Team agent launch matrix safe e2e', () => {
confirmedCount: 2,
pendingCount: 1,
failedCount: 0,
runtimeAlivePendingCount: 1,
runtimeAlivePendingCount: 0,
});
expect(statuses.statuses.alice).toMatchObject({
status: 'online',
@ -2313,9 +2313,9 @@ describe('Team agent launch matrix safe e2e', () => {
hardFailure: false,
});
expect(statuses.statuses.tom).toMatchObject({
status: 'online',
status: 'waiting',
launchState: 'runtime_pending_bootstrap',
runtimeAlive: true,
runtimeAlive: false,
bootstrapConfirmed: false,
hardFailure: false,
});
@ -2909,7 +2909,7 @@ describe('Team agent launch matrix safe e2e', () => {
confirmedCount: 1,
pendingCount: 1,
failedCount: 0,
runtimeAlivePendingCount: 1,
runtimeAlivePendingCount: 0,
});
expect(statuses.statuses.alice).toMatchObject({
status: 'online',
@ -2917,9 +2917,9 @@ describe('Team agent launch matrix safe e2e', () => {
hardFailure: false,
});
expect(statuses.statuses.bob).toMatchObject({
status: 'online',
status: 'waiting',
launchState: 'runtime_pending_bootstrap',
runtimeAlive: true,
runtimeAlive: false,
bootstrapConfirmed: false,
hardFailure: false,
});
@ -2972,12 +2972,12 @@ describe('Team agent launch matrix safe e2e', () => {
confirmedCount: 1,
pendingCount: 1,
failedCount: 0,
runtimeAlivePendingCount: 1,
runtimeAlivePendingCount: 0,
});
expect(statuses.statuses.bob).toMatchObject({
status: 'online',
status: 'waiting',
launchState: 'runtime_pending_permission',
runtimeAlive: true,
runtimeAlive: false,
pendingPermissionRequestIds: ['perm-bob'],
hardFailure: false,
});
@ -3301,13 +3301,13 @@ describe('Team agent launch matrix safe e2e', () => {
confirmedCount: 1,
pendingCount: 1,
failedCount: 0,
runtimeAlivePendingCount: 1,
runtimeAlivePendingCount: 0,
});
expect(statuses.statuses.bob).toMatchObject({
status: 'online',
status: 'waiting',
launchState: 'runtime_pending_bootstrap',
agentToolAccepted: true,
runtimeAlive: true,
runtimeAlive: false,
bootstrapConfirmed: false,
hardFailure: false,
});
@ -3361,13 +3361,13 @@ describe('Team agent launch matrix safe e2e', () => {
confirmedCount: 1,
pendingCount: 1,
failedCount: 0,
runtimeAlivePendingCount: 1,
runtimeAlivePendingCount: 0,
});
expect(statuses.statuses.bob).toMatchObject({
status: 'online',
status: 'waiting',
launchState: 'runtime_pending_permission',
agentToolAccepted: true,
runtimeAlive: true,
runtimeAlive: false,
pendingPermissionRequestIds: ['perm-bob'],
hardFailure: false,
});
@ -3469,7 +3469,7 @@ describe('Team agent launch matrix safe e2e', () => {
hardFailure: false,
});
expect(statuses.statuses.tom).toMatchObject({
status: 'online',
status: 'waiting',
launchState: 'runtime_pending_permission',
hardFailure: false,
pendingPermissionRequestIds: ['perm-tom'],
@ -3588,7 +3588,7 @@ describe('Team agent launch matrix safe e2e', () => {
hardFailure: false,
});
expect(statuses.statuses.tom).toMatchObject({
status: 'online',
status: 'waiting',
launchState: 'runtime_pending_permission',
hardFailure: false,
pendingPermissionRequestIds: ['perm-tom'],
@ -4129,6 +4129,7 @@ describe('Team agent launch matrix safe e2e', () => {
{
alive: true,
model: 'opencode/minimax-m2.5-free',
livenessKind: 'runtime_process',
},
],
]);
@ -4196,6 +4197,7 @@ describe('Team agent launch matrix safe e2e', () => {
{
alive: true,
model: 'opencode/minimax-m2.5-free',
livenessKind: 'runtime_process',
},
],
]);
@ -4364,9 +4366,9 @@ describe('Team agent launch matrix safe e2e', () => {
bootstrapConfirmed: true,
});
expect(statuses.statuses.tom).toMatchObject({
status: 'online',
status: 'waiting',
launchState: 'runtime_pending_permission',
runtimeAlive: true,
runtimeAlive: false,
pendingPermissionRequestIds: ['perm-tom'],
});
});
@ -4537,12 +4539,12 @@ describe('Team agent launch matrix safe e2e', () => {
confirmedCount: 2,
pendingCount: 1,
failedCount: 0,
runtimeAlivePendingCount: 1,
runtimeAlivePendingCount: 0,
});
expect(statuses.statuses.alice).toMatchObject({
status: 'online',
status: 'waiting',
launchState: 'runtime_pending_permission',
runtimeAlive: true,
runtimeAlive: false,
bootstrapConfirmed: false,
pendingPermissionRequestIds: ['perm-alice'],
hardFailure: false,
@ -4591,7 +4593,7 @@ describe('Team agent launch matrix safe e2e', () => {
confirmedCount: 2,
pendingCount: 1,
failedCount: 0,
runtimeAlivePendingCount: 1,
runtimeAlivePendingCount: 0,
});
expect(statuses.statuses.alice).toMatchObject({
status: 'online',
@ -4606,9 +4608,9 @@ describe('Team agent launch matrix safe e2e', () => {
hardFailure: false,
});
expect(statuses.statuses.tom).toMatchObject({
status: 'online',
status: 'waiting',
launchState: 'runtime_pending_bootstrap',
runtimeAlive: true,
runtimeAlive: false,
bootstrapConfirmed: false,
hardFailure: false,
});
@ -4629,7 +4631,7 @@ describe('Team agent launch matrix safe e2e', () => {
expect(runtimeSnapshot.members.tom).toMatchObject({
providerId: 'opencode',
laneKind: 'secondary',
alive: true,
alive: false,
runtimeModel: 'opencode/nemotron-3-super-free',
});
});
@ -4694,7 +4696,7 @@ describe('Team agent launch matrix safe e2e', () => {
confirmedCount: 2,
pendingCount: 1,
failedCount: 1,
runtimeAlivePendingCount: 1,
runtimeAlivePendingCount: 0,
});
expect(statuses.statuses.alice).toMatchObject({
status: 'online',
@ -4715,9 +4717,9 @@ describe('Team agent launch matrix safe e2e', () => {
hardFailure: false,
});
expect(statuses.statuses.tom).toMatchObject({
status: 'online',
status: 'waiting',
launchState: 'runtime_pending_bootstrap',
runtimeAlive: true,
runtimeAlive: false,
bootstrapConfirmed: false,
hardFailure: false,
});
@ -4744,7 +4746,7 @@ describe('Team agent launch matrix safe e2e', () => {
expect(runtimeSnapshot.members.tom).toMatchObject({
providerId: 'opencode',
laneKind: 'secondary',
alive: true,
alive: false,
runtimeModel: 'opencode/nemotron-3-super-free',
});
});
@ -4934,7 +4936,7 @@ describe('Team agent launch matrix safe e2e', () => {
hardFailure: false,
});
expect(statuses.statuses.tom).toMatchObject({
status: 'online',
status: 'waiting',
launchState: 'runtime_pending_permission',
hardFailure: false,
pendingPermissionRequestIds: ['perm-tom'],
@ -5392,8 +5394,8 @@ describe('Team agent launch matrix safe e2e', () => {
const svc = new TeamProvisioningService();
(svc as any).getLiveTeamAgentRuntimeMetadata = async () =>
new Map([
['alice', { alive: true, model: 'haiku' }],
['bob-2', { alive: true, model: 'sonnet' }],
['alice', { alive: true, model: 'haiku', livenessKind: 'confirmed_bootstrap' }],
['bob-2', { alive: true, model: 'sonnet', livenessKind: 'runtime_process' }],
]);
const statuses = await svc.getMemberSpawnStatuses(teamName);
@ -5458,8 +5460,15 @@ describe('Team agent launch matrix safe e2e', () => {
const svc = new TeamProvisioningService();
(svc as any).getLiveTeamAgentRuntimeMetadata = async () =>
new Map([
['alice', { alive: true, model: 'haiku' }],
['bob-2', { alive: true, model: 'opencode/minimax-m2.5-free' }],
['alice', { alive: true, model: 'haiku', livenessKind: 'confirmed_bootstrap' }],
[
'bob-2',
{
alive: true,
model: 'opencode/minimax-m2.5-free',
livenessKind: 'runtime_process',
},
],
]);
const statuses = await svc.getMemberSpawnStatuses(teamName);
@ -6832,8 +6841,8 @@ describe('Team agent launch matrix safe e2e', () => {
const svc = new TeamProvisioningService();
(svc as any).getLiveTeamAgentRuntimeMetadata = async () =>
new Map([
['alice', { alive: true, model: 'haiku' }],
['bob-2', { alive: true, model: 'sonnet' }],
['alice', { alive: true, model: 'haiku', livenessKind: 'confirmed_bootstrap' }],
['bob-2', { alive: true, model: 'sonnet', livenessKind: 'runtime_process' }],
]);
const statuses = await svc.getMemberSpawnStatuses(teamName);
@ -6966,7 +6975,7 @@ describe('Team agent launch matrix safe e2e', () => {
});
const svc = new TeamProvisioningService();
(svc as any).getLiveTeamAgentRuntimeMetadata = async () =>
new Map([['alice', { alive: true, model: 'haiku' }]]);
new Map([['alice', { alive: true, model: 'haiku', livenessKind: 'runtime_process' }]]);
const statuses = await svc.getMemberSpawnStatuses(teamName);
@ -13102,7 +13111,7 @@ describe('Team agent launch matrix safe e2e', () => {
hardFailureReason: 'Gemini pane failed to start',
});
expect(statuses.statuses.bob).toMatchObject({
status: 'online',
status: 'waiting',
launchState: 'runtime_pending_permission',
pendingPermissionRequestIds: ['perm-bob'],
hardFailure: false,
@ -13238,7 +13247,7 @@ describe('Team agent launch matrix safe e2e', () => {
launchState: 'confirmed_alive',
});
expect(statuses.statuses.bob).toMatchObject({
status: 'online',
status: 'waiting',
launchState: 'runtime_pending_permission',
pendingPermissionRequestIds: ['perm-bob'],
hardFailure: false,
@ -13291,7 +13300,7 @@ describe('Team agent launch matrix safe e2e', () => {
hardFailure: false,
});
expect(statuses.statuses.bob).toMatchObject({
status: 'online',
status: 'waiting',
launchState: 'runtime_pending_permission',
pendingPermissionRequestIds: ['perm-bob'],
hardFailure: false,
@ -13375,7 +13384,7 @@ describe('Team agent launch matrix safe e2e', () => {
});
expect(secondStatuses.teamLaunchState).toBe('partial_pending');
expect(secondStatuses.statuses.bob).toMatchObject({
status: 'online',
status: 'waiting',
launchState: 'runtime_pending_permission',
pendingPermissionRequestIds: ['perm-bob'],
hardFailure: false,
@ -15570,7 +15579,7 @@ describe('Team agent launch matrix safe e2e', () => {
hardFailure: false,
});
expect(statuses.statuses.tom).toMatchObject({
status: 'online',
status: 'waiting',
launchState: 'runtime_pending_permission',
hardFailure: false,
pendingPermissionRequestIds: ['perm-tom'],
@ -15622,7 +15631,7 @@ describe('Team agent launch matrix safe e2e', () => {
confirmedCount: 1,
pendingCount: 2,
failedCount: 0,
runtimeAlivePendingCount: 1,
runtimeAlivePendingCount: 0,
});
expect(statuses.statuses.bob).toMatchObject({
status: 'online',
@ -15630,9 +15639,9 @@ describe('Team agent launch matrix safe e2e', () => {
hardFailure: false,
});
expect(statuses.statuses.tom).toMatchObject({
status: 'online',
status: 'waiting',
launchState: 'runtime_pending_bootstrap',
runtimeAlive: true,
runtimeAlive: false,
bootstrapConfirmed: false,
hardFailure: false,
});
@ -15703,7 +15712,7 @@ describe('Team agent launch matrix safe e2e', () => {
hardFailure: false,
});
expect(statuses.statuses.tom).toMatchObject({
status: 'online',
status: 'waiting',
launchState: 'runtime_pending_permission',
hardFailure: false,
pendingPermissionRequestIds: ['perm-tom'],
@ -15763,7 +15772,7 @@ describe('Team agent launch matrix safe e2e', () => {
confirmedCount: 1,
pendingCount: 3,
failedCount: 0,
runtimeAlivePendingCount: 1,
runtimeAlivePendingCount: 0,
});
expect(statuses.expectedMembers).toEqual(
expect.arrayContaining(['alice', 'reviewer', 'bob', 'tom'])
@ -15776,9 +15785,9 @@ describe('Team agent launch matrix safe e2e', () => {
hardFailure: false,
});
expect(statuses.statuses.tom).toMatchObject({
status: 'online',
status: 'waiting',
launchState: 'runtime_pending_bootstrap',
runtimeAlive: true,
runtimeAlive: false,
bootstrapConfirmed: false,
hardFailure: false,
});
@ -16058,6 +16067,18 @@ class FakeOpenCodeRuntimeAdapter implements TeamLaunchRuntimeAdapter {
const failed = outcome === 'failed';
const permissionPending = outcome === 'permission';
const bootstrapPending = outcome === 'launching';
const livenessKind = failed
? 'not_found'
: permissionPending
? 'permission_blocked'
: bootstrapPending
? 'runtime_process_candidate'
: 'confirmed_bootstrap';
const runtimeDiagnostic = permissionPending
? 'OpenCode runtime is waiting for permission approval'
: bootstrapPending
? 'OpenCode runtime pid reported by bridge without local process verification'
: undefined;
return {
memberName: member.name,
providerId: 'opencode',
@ -16069,12 +16090,15 @@ class FakeOpenCodeRuntimeAdapter implements TeamLaunchRuntimeAdapter {
? 'runtime_pending_bootstrap'
: 'confirmed_alive',
agentToolAccepted: !failed,
runtimeAlive: !failed,
runtimeAlive: !failed && !permissionPending && !bootstrapPending,
bootstrapConfirmed: !failed && !permissionPending && !bootstrapPending,
hardFailure: failed,
hardFailureReason: failed ? 'fake_open_code_launch_failure' : undefined,
pendingPermissionRequestIds: permissionPending ? [`perm-${member.name}`] : undefined,
runtimePid: failed ? undefined : 10_000 + index,
livenessKind,
pidSource: failed ? undefined : 'opencode_bridge',
runtimeDiagnostic,
diagnostics: failed
? ['fake OpenCode launch failure']
: permissionPending

View file

@ -279,6 +279,39 @@ describe('TeamBootstrapStateReader', () => {
await expect(readBootstrapRuntimeState('demo')).resolves.toBeNull();
});
it('does not promote bootstrap-state runtime_alive to strict runtimeAlive', async () => {
hoisted.files.set('/mock/teams/demo/bootstrap-state.json', {
contents: JSON.stringify({
version: 1,
runId: 'run-123',
teamName: 'demo',
startedAt: 1700000000000,
updatedAt: 1700000000500,
phase: 'spawning_members',
members: [{ name: 'alice', status: 'runtime_alive', lastObservedAt: 1700000000400 }],
}),
});
await expect(readBootstrapLaunchSnapshot('demo')).resolves.toMatchObject({
launchPhase: 'active',
members: {
alice: {
launchState: 'runtime_pending_bootstrap',
agentToolAccepted: true,
runtimeAlive: false,
bootstrapConfirmed: false,
sources: {
configRegistered: true,
},
diagnostics: [
'runtime alive reported by bootstrap state',
'waiting for strict live verification',
],
},
},
});
});
it('reads persisted real-task submission state', async () => {
hoisted.files.set('/mock/teams/demo/bootstrap-state.json', {
contents: JSON.stringify({

View file

@ -242,10 +242,12 @@ function createForwardingJournalStore(initialEntries: Array<Record<string, unkno
const journal = {
exists: vi.fn(async () => true),
ensureFile: vi.fn(async () => undefined),
withEntries: vi.fn(async (_teamName: string, fn: (entries: unknown[]) => Promise<{ result: unknown }>) => {
const outcome = await fn(journalEntries);
return outcome.result;
}),
withEntries: vi.fn(
async (_teamName: string, fn: (entries: unknown[]) => Promise<{ result: unknown }>) => {
const outcome = await fn(journalEntries);
return outcome.result;
}
),
};
return { journalEntries, journal };
@ -262,7 +264,9 @@ function createTaskCommentForwardingService(options: {
};
members?: Array<{ name: string; role?: string }>;
}) {
const inboxWriter = options.inboxWriter ?? { sendMessage: vi.fn(async () => ({ deliveredToInbox: true, messageId: 'msg-1' })) };
const inboxWriter = options.inboxWriter ?? {
sendMessage: vi.fn(async () => ({ deliveredToInbox: true, messageId: 'msg-1' })),
};
const journal = options.journal ?? createForwardingJournalStore().journal;
const service = new TeamDataService(
@ -328,24 +332,26 @@ function buildDefaultTeamConfig(overrides: Partial<TeamConfig> = {}): TeamConfig
};
}
function createGetTeamDataHarness(options: {
config?: TeamConfig | null;
getTasks?: () => Promise<TeamTask[]>;
listInboxNames?: () => Promise<string[]>;
getMessages?: () => Promise<InboxMessage[]>;
getMembers?: () => Promise<TeamConfig['members']>;
getTeamMeta?: () => Promise<TeamMetaFile | null>;
getState?: () => Promise<KanbanState>;
readMessages?: () => Promise<InboxMessage[]>;
resolveMembers?: (
config: TeamConfig,
metaMembers: TeamConfig['members'],
inboxNames: string[],
tasks: TeamTaskWithKanban[]
) => ResolvedTeamMember[];
listProcesses?: () => TeamProcess[];
getMemberAdvisories?: () => Promise<Map<string, unknown>>;
} = {}) {
function createGetTeamDataHarness(
options: {
config?: TeamConfig | null;
getTasks?: () => Promise<TeamTask[]>;
listInboxNames?: () => Promise<string[]>;
getMessages?: () => Promise<InboxMessage[]>;
getMembers?: () => Promise<TeamConfig['members']>;
getTeamMeta?: () => Promise<TeamMetaFile | null>;
getState?: () => Promise<KanbanState>;
readMessages?: () => Promise<InboxMessage[]>;
resolveMembers?: (
config: TeamConfig,
metaMembers: TeamConfig['members'],
inboxNames: string[],
tasks: TeamTaskWithKanban[]
) => ResolvedTeamMember[];
listProcesses?: () => TeamProcess[];
getMemberAdvisories?: () => Promise<Map<string, unknown>>;
} = {}
) {
const getConfig = vi.fn(async () =>
options.config === undefined ? buildDefaultTeamConfig() : options.config
);
@ -486,7 +492,9 @@ describe('TeamDataService', () => {
{} as never,
{} as never,
{ resolveMembers: vi.fn(() => []) } as never,
{ getState: vi.fn(async () => ({ teamName: 'dup-team', reviewers: [], tasks: {} })) } as never,
{
getState: vi.fn(async () => ({ teamName: 'dup-team', reviewers: [], tasks: {} })),
} as never,
{} as never,
membersMetaStore,
{ readMessages: vi.fn(async () => []) } as never
@ -518,7 +526,9 @@ describe('TeamDataService', () => {
{} as never,
{} as never,
{ resolveMembers: vi.fn(() => []) } as never,
{ getState: vi.fn(async () => ({ teamName: 'dup-team', reviewers: [], tasks: {} })) } as never,
{
getState: vi.fn(async () => ({ teamName: 'dup-team', reviewers: [], tasks: {} })),
} as never,
{} as never,
membersMetaStore,
{ readMessages: vi.fn(async () => []) } as never
@ -1006,7 +1016,10 @@ describe('TeamDataService', () => {
const service = new TeamDataService(
{
listTeams: vi.fn(),
getConfig: vi.fn(async () => ({ name: 'My team', members: [{ name: 'team-lead', role: 'Lead' }] })),
getConfig: vi.fn(async () => ({
name: 'My team',
members: [{ name: 'team-lead', role: 'Lead' }],
})),
} as never,
{} as never,
{} as never,
@ -1134,7 +1147,10 @@ describe('TeamDataService', () => {
const service = new TeamDataService(
{
listTeams: vi.fn(),
getConfig: vi.fn(async () => ({ name: 'My team', members: [{ name: 'team-lead', role: 'Lead' }] })),
getConfig: vi.fn(async () => ({
name: 'My team',
members: [{ name: 'team-lead', role: 'Lead' }],
})),
} as never,
{
getTasks: vi.fn(async () => []),
@ -1305,7 +1321,9 @@ describe('TeamDataService', () => {
expect(createTaskMock).toHaveBeenCalledWith(
expect.objectContaining({ owner: 'alice', createdBy: 'user' })
);
expect(createTaskMock).not.toHaveBeenCalledWith(expect.objectContaining({ startImmediately: true }));
expect(createTaskMock).not.toHaveBeenCalledWith(
expect.objectContaining({ startImmediately: true })
);
});
it('creates task with explicit immediate start only when startImmediately is true', async () => {
@ -1362,7 +1380,9 @@ describe('TeamDataService', () => {
prompt: 'Begin immediately.',
})
);
expect(createTaskMock).not.toHaveBeenCalledWith(expect.objectContaining({ status: 'in_progress' }));
expect(createTaskMock).not.toHaveBeenCalledWith(
expect.objectContaining({ status: 'in_progress' })
);
});
it('persists explicit related task links when creating a task', async () => {
@ -1486,7 +1506,47 @@ describe('TeamDataService', () => {
await service.requestReview('my-team', 'task-1');
expect(requestReviewMock).toHaveBeenCalledWith('task-1', {
from: 'user',
from: 'lead',
leadSessionId: 'lead-1',
});
});
it('resolves the canonical lead instead of matching tech-lead role text', async () => {
const requestReviewMock = vi.fn();
const service = new TeamDataService(
{
listTeams: vi.fn(),
getConfig: vi.fn(async () => ({
name: 'My team',
members: [
{ name: 'alice', role: 'tech lead' },
{ name: 'team-lead', agentType: 'team-lead', role: 'lead' },
],
leadSessionId: 'lead-1',
})),
} as never,
{} as never,
{} as never,
{} as never,
{} as never,
{} as never,
{} as never,
{} as never,
{} as never,
{} as never,
() =>
({
review: {
requestReview: requestReviewMock,
},
}) as never
);
await service.requestReview('my-team', 'task-1');
expect(requestReviewMock).toHaveBeenCalledWith('task-1', {
from: 'team-lead',
leadSessionId: 'lead-1',
});
});
@ -1511,8 +1571,15 @@ describe('TeamDataService', () => {
subject: 'Legacy review task',
status: 'completed',
owner: 'bob',
reviewState: 'review',
historyEvents: [],
reviewState: 'none',
historyEvents: [
{
id: 'evt-created',
type: 'task_created',
status: 'completed',
timestamp: '2026-03-01T09:00:00.000Z',
},
],
},
]),
} as never,
@ -1550,6 +1617,129 @@ describe('TeamDataService', () => {
});
});
it('does not leak stale reviewer after review is reset to pending', async () => {
const service = new TeamDataService(
{
listTeams: vi.fn(),
getConfig: vi.fn(async () => ({
name: 'My team',
members: [
{ name: 'lead', role: 'team lead' },
{ name: 'bob', role: 'developer' },
{ name: 'carol', role: 'reviewer' },
],
})),
} as never,
{
getTasks: vi.fn(async () => [
{
id: 'task-reopened',
subject: 'Reopened task',
status: 'pending',
owner: 'bob',
reviewState: 'none',
historyEvents: [
{
id: 'evt-review',
type: 'review_requested',
from: 'none',
to: 'review',
reviewer: 'carol',
timestamp: '2026-03-01T10:00:00.000Z',
},
{
id: 'evt-pending',
type: 'status_changed',
from: 'completed',
to: 'pending',
timestamp: '2026-03-01T10:05:00.000Z',
},
],
},
]),
} as never,
{
listInboxNames: vi.fn(async () => []),
getMessages: vi.fn(async () => []),
} as never,
{} as never,
{} as never,
{
resolveMembers: vi.fn(() => []),
} as never,
{
getState: vi.fn(async () => ({ teamName: 'my-team', reviewers: [], tasks: {} })),
} as never
);
const data = await service.getTeamData('my-team');
expect(data.tasks[0]).toMatchObject({
id: 'task-reopened',
reviewState: 'none',
reviewer: null,
});
});
it('applies kanban overlay review state in global task projections', async () => {
const service = new TeamDataService(
{
listTeams: vi.fn(async () => [
{
teamName: 'my-team',
displayName: 'My team',
projectPath: '/repo',
},
]),
} as never,
{
getAllTasks: vi.fn(async () => [
{
id: 'task-global-review',
teamName: 'my-team',
subject: 'Global review task',
status: 'completed',
owner: 'bob',
reviewState: 'none',
historyEvents: [
{
id: 'evt-created',
type: 'task_created',
status: 'completed',
timestamp: '2026-03-01T09:00:00.000Z',
},
],
},
]),
} as never,
{} as never,
{} as never,
{} as never,
{} as never,
{
getState: vi.fn(async () => ({
teamName: 'my-team',
reviewers: [],
tasks: {
'task-global-review': {
column: 'review',
reviewer: 'carol',
movedAt: '2026-03-01T10:00:00.000Z',
},
},
})),
} as never
);
const tasks = await service.getAllTasks();
expect(tasks[0]).toMatchObject({
id: 'task-global-review',
reviewState: 'review',
kanbanColumn: 'review',
});
});
it('propagates leadSessionId for kanban-driven review transitions', async () => {
const requestReviewMock = vi.fn();
const approveReviewMock = vi.fn();
@ -1585,20 +1775,23 @@ describe('TeamDataService', () => {
await service.updateKanban('my-team', 'task-1', { op: 'set_column', column: 'review' });
await service.updateKanban('my-team', 'task-1', { op: 'set_column', column: 'approved' });
await service.updateKanban('my-team', 'task-1', { op: 'request_changes', comment: 'Needs fixes' });
await service.updateKanban('my-team', 'task-1', {
op: 'request_changes',
comment: 'Needs fixes',
});
expect(requestReviewMock).toHaveBeenCalledWith('task-1', {
from: 'user',
from: 'lead',
leadSessionId: 'lead-2',
});
expect(approveReviewMock).toHaveBeenCalledWith('task-1', {
from: 'user',
from: 'lead',
suppressTaskComment: true,
'notify-owner': true,
leadSessionId: 'lead-2',
});
expect(requestChangesMock).toHaveBeenCalledWith('task-1', {
from: 'user',
from: 'lead',
comment: 'Needs fixes',
leadSessionId: 'lead-2',
});
@ -1615,10 +1808,12 @@ describe('TeamDataService', () => {
ensureFile: vi.fn(async () => {
journalExists = true;
}),
withEntries: vi.fn(async (_teamName: string, fn: (entries: unknown[]) => Promise<{ result: unknown }>) => {
const outcome = await fn(journalEntries);
return outcome.result;
}),
withEntries: vi.fn(
async (_teamName: string, fn: (entries: unknown[]) => Promise<{ result: unknown }>) => {
const outcome = await fn(journalEntries);
return outcome.result;
}
),
};
try {
@ -1707,10 +1902,12 @@ describe('TeamDataService', () => {
const journal = {
exists: vi.fn(async () => true),
ensureFile: vi.fn(async () => undefined),
withEntries: vi.fn(async (_teamName: string, fn: (entries: unknown[]) => Promise<{ result: unknown }>) => {
const outcome = await fn(journalEntries);
return outcome.result;
}),
withEntries: vi.fn(
async (_teamName: string, fn: (entries: unknown[]) => Promise<{ result: unknown }>) => {
const outcome = await fn(journalEntries);
return outcome.result;
}
),
};
try {
@ -1776,10 +1973,9 @@ describe('TeamDataService', () => {
messageId: 'task-comment-forward:my-team:task-1:comment-1',
})
);
const firstSendRequest = (inboxWriter.sendMessage as unknown as { mock: { calls: unknown[][] } })
.mock.calls[0]?.[1] as
| { text?: string }
| undefined;
const firstSendRequest = (
inboxWriter.sendMessage as unknown as { mock: { calls: unknown[][] } }
).mock.calls[0]?.[1] as { text?: string } | undefined;
expect(String(firstSendRequest?.text ?? '')).not.toContain('<agent-block>');
const sentEntry = journalEntries.find((entry) => entry.key === 'task-1:comment-1');
expect(sentEntry).toMatchObject({
@ -1803,10 +1999,12 @@ describe('TeamDataService', () => {
ensureFile: vi.fn(async () => {
journalExists = true;
}),
withEntries: vi.fn(async (_teamName: string, fn: (entries: unknown[]) => Promise<{ result: unknown }>) => {
const outcome = await fn(journalEntries);
return outcome.result;
}),
withEntries: vi.fn(
async (_teamName: string, fn: (entries: unknown[]) => Promise<{ result: unknown }>) => {
const outcome = await fn(journalEntries);
return outcome.result;
}
),
};
try {
@ -1902,10 +2100,12 @@ describe('TeamDataService', () => {
const journal = {
exists: vi.fn(async () => true),
ensureFile: vi.fn(async () => undefined),
withEntries: vi.fn(async (_teamName: string, fn: (entries: unknown[]) => Promise<{ result: unknown }>) => {
const outcome = await fn(journalEntries);
return outcome.result;
}),
withEntries: vi.fn(
async (_teamName: string, fn: (entries: unknown[]) => Promise<{ result: unknown }>) => {
const outcome = await fn(journalEntries);
return outcome.result;
}
),
};
try {
@ -1983,10 +2183,12 @@ describe('TeamDataService', () => {
const journal = {
exists: vi.fn(async () => true),
ensureFile: vi.fn(async () => undefined),
withEntries: vi.fn(async (_teamName: string, fn: (entries: unknown[]) => Promise<{ result: unknown }>) => {
const outcome = await fn(journalEntries);
return outcome.result;
}),
withEntries: vi.fn(
async (_teamName: string, fn: (entries: unknown[]) => Promise<{ result: unknown }>) => {
const outcome = await fn(journalEntries);
return outcome.result;
}
),
};
try {
@ -2081,15 +2283,20 @@ describe('TeamDataService', () => {
},
];
const inboxWriter = {
sendMessage: vi.fn(async () => ({ deliveredToInbox: true, messageId: 'task-comment-forward:my-team:task-1:comment-1' })),
sendMessage: vi.fn(async () => ({
deliveredToInbox: true,
messageId: 'task-comment-forward:my-team:task-1:comment-1',
})),
};
const journal = {
exists: vi.fn(async () => true),
ensureFile: vi.fn(async () => undefined),
withEntries: vi.fn(async (_teamName: string, fn: (entries: unknown[]) => Promise<{ result: unknown }>) => {
const outcome = await fn(journalEntries);
return outcome.result;
}),
withEntries: vi.fn(
async (_teamName: string, fn: (entries: unknown[]) => Promise<{ result: unknown }>) => {
const outcome = await fn(journalEntries);
return outcome.result;
}
),
};
try {
@ -2183,10 +2390,12 @@ describe('TeamDataService', () => {
const journal = {
exists: vi.fn(async () => true),
ensureFile: vi.fn(async () => undefined),
withEntries: vi.fn(async (_teamName: string, fn: (entries: unknown[]) => Promise<{ result: unknown }>) => {
const outcome = await fn(journalEntries);
return outcome.result;
}),
withEntries: vi.fn(
async (_teamName: string, fn: (entries: unknown[]) => Promise<{ result: unknown }>) => {
const outcome = await fn(journalEntries);
return outcome.result;
}
),
};
try {
@ -2283,10 +2492,12 @@ describe('TeamDataService', () => {
const journal = {
exists: vi.fn(async () => true),
ensureFile: vi.fn(async () => undefined),
withEntries: vi.fn(async (_teamName: string, fn: (entries: unknown[]) => Promise<{ result: unknown }>) => {
const outcome = await fn(journalEntries);
return outcome.result;
}),
withEntries: vi.fn(
async (_teamName: string, fn: (entries: unknown[]) => Promise<{ result: unknown }>) => {
const outcome = await fn(journalEntries);
return outcome.result;
}
),
};
try {
@ -2368,10 +2579,12 @@ describe('TeamDataService', () => {
const journal = {
exists: vi.fn(async () => true),
ensureFile: vi.fn(async () => undefined),
withEntries: vi.fn(async (_teamName: string, fn: (entries: unknown[]) => Promise<{ result: unknown }>) => {
const outcome = await fn(journalEntries);
return outcome.result;
}),
withEntries: vi.fn(
async (_teamName: string, fn: (entries: unknown[]) => Promise<{ result: unknown }>) => {
const outcome = await fn(journalEntries);
return outcome.result;
}
),
};
try {
@ -2611,15 +2824,19 @@ describe('TeamDataService', () => {
const initGate = new Promise<void>((resolve) => {
releaseInit = () => resolve();
});
const inboxWriter = { sendMessage: vi.fn(async () => ({ deliveredToInbox: true, messageId: 'msg-1' })) };
const inboxWriter = {
sendMessage: vi.fn(async () => ({ deliveredToInbox: true, messageId: 'msg-1' })),
};
const journalEntries: Array<Record<string, unknown>> = [];
const journal = {
exists: vi.fn(async () => true),
ensureFile: vi.fn(async () => undefined),
withEntries: vi.fn(async (_teamName: string, fn: (entries: unknown[]) => Promise<{ result: unknown }>) => {
const outcome = await fn(journalEntries);
return outcome.result;
}),
withEntries: vi.fn(
async (_teamName: string, fn: (entries: unknown[]) => Promise<{ result: unknown }>) => {
const outcome = await fn(journalEntries);
return outcome.result;
}
),
};
try {
@ -3420,7 +3637,9 @@ describe('TeamDataService', () => {
});
const feed = await service.getMessageFeed('my-team');
const linked = feed.messages.find((message) => message.messageId === 'passive-user-summary-old-1');
const linked = feed.messages.find(
(message) => message.messageId === 'passive-user-summary-old-1'
);
expect(linked?.relayOfMessageId).toBeUndefined();
});
@ -3988,10 +4207,7 @@ describe('TeamDataService', () => {
),
]);
const firstSpy = vi.spyOn(
firstService as never,
'extractLeadAssistantTextsFromJsonl' as never
);
const firstSpy = vi.spyOn(firstService as never, 'extractLeadAssistantTextsFromJsonl' as never);
const secondSpy = vi.spyOn(
secondService as never,
'extractLeadAssistantTextsFromJsonl' as never
@ -4106,7 +4322,9 @@ describe('TeamDataService', () => {
const service = createResolverBackedService();
const page = await service.getMessagesPage(fixture.teamName, { limit: 20 });
const leadSessionMessages = page.messages.filter((message) => message.source === 'lead_session');
const leadSessionMessages = page.messages.filter(
(message) => message.source === 'lead_session'
);
expect(
leadSessionMessages.some((message) =>
@ -4187,12 +4405,7 @@ describe('TeamDataService', () => {
await flushMicrotasks();
expect(order).toEqual(
expect.arrayContaining([
'inboxNames:start',
'meta:start',
'kanban:start',
'tasks:start',
])
expect.arrayContaining(['inboxNames:start', 'meta:start', 'kanban:start', 'tasks:start'])
);
expect(order).not.toContain('processes:start');
expect(order).not.toContain('leadTexts:start');
@ -4453,7 +4666,11 @@ describe('TeamDataService', () => {
const feed = await harness.service.getMessageFeed('my-team');
expect(feed.messages.map((message) => message.messageId)).toEqual(['sent-1', 'lead-1', 'inbox-1']);
expect(feed.messages.map((message) => message.messageId)).toEqual([
'sent-1',
'lead-1',
'inbox-1',
]);
});
it('preserves assembled messages and resolver inputs when inbox messages fail', async () => {
@ -4571,10 +4788,12 @@ describe('TeamDataService', () => {
},
});
vi.spyOn(harness.service as never, 'extractLeadSessionTexts' as never).mockImplementation(() => {
order.push('leadTexts:start');
throw new Error('lead sync fail');
});
vi.spyOn(harness.service as never, 'extractLeadSessionTexts' as never).mockImplementation(
() => {
order.push('leadTexts:start');
throw new Error('lead sync fail');
}
);
const pending = harness.service.getTeamData('my-team');
await flushMicrotasks();
@ -4665,7 +4884,16 @@ describe('TeamDataService', () => {
});
describe('getMessagesPage', () => {
function createPaginationService(messages: Array<{ from: string; text: string; timestamp: string; messageId?: string; source?: string; leadSessionId?: string }>) {
function createPaginationService(
messages: Array<{
from: string;
text: string;
timestamp: string;
messageId?: string;
source?: string;
leadSessionId?: string;
}>
) {
return new TeamDataService(
{
listTeams: vi.fn(),
@ -4678,17 +4906,17 @@ describe('TeamDataService', () => {
{ getTasks: vi.fn(async () => []) } as never,
{
listInboxNames: vi.fn(async () => []),
getMessages: vi.fn(async () =>
messages.map((m) => ({ ...m, read: true }))
),
getMessages: vi.fn(async () => messages.map((m) => ({ ...m, read: true }))),
} as never,
{} as never,
{} as never,
{ resolveMembers: vi.fn(() => []) } as never,
{ getState: vi.fn(async () => ({ teamName: 'my-team', reviewers: [], tasks: {} })) } as never,
{
getState: vi.fn(async () => ({ teamName: 'my-team', reviewers: [], tasks: {} })),
} as never,
{} as never,
{} as never,
{ readMessages: vi.fn(async () => []) } as never,
{ readMessages: vi.fn(async () => []) } as never
);
}
@ -4788,7 +5016,9 @@ describe('TeamDataService', () => {
expect(page1.messages[0]?.messageId).toMatch(/^inbox-/);
expect(page1.nextCursor).toContain(page1.messages[0]!.messageId!);
expect(page2.messages.every((message) => Boolean(message.messageId))).toBe(true);
expect(new Set([...page1.messages, ...page2.messages].map((message) => message.messageId)).size).toBe(3);
expect(
new Set([...page1.messages, ...page2.messages].map((message) => message.messageId)).size
).toBe(3);
});
it('dedups newest-page live overlay against durable lead thoughts that already paged off the first page', async () => {
@ -4872,7 +5102,10 @@ describe('TeamDataService', () => {
cursor: page1.nextCursor!,
});
expect(page2.messages.map((message) => message.messageId)).toEqual(['durable-2', 'durable-1']);
expect(page2.messages.map((message) => message.messageId)).toEqual([
'durable-2',
'durable-1',
]);
});
});
});

View file

@ -1,6 +1,7 @@
import { describe, expect, it } from 'vitest';
import {
normalizePersistedLaunchSnapshot,
snapshotToMemberSpawnStatuses,
summarizePersistedLaunchMembers,
} from '../../../../src/main/services/team/TeamLaunchStateEvaluator';
@ -54,12 +55,13 @@ describe('TeamLaunchStateEvaluator', () => {
});
expect(statuses.bob).toMatchObject({
launchState: 'runtime_pending_permission',
status: 'online',
status: 'waiting',
runtimeAlive: false,
pendingPermissionRequestIds: ['req-1'],
});
});
it('counts persisted members in launch summary even when expectedMembers is stale', () => {
it('does not count weak persisted runtimeAlive without strong liveness evidence', () => {
const summary = summarizePersistedLaunchMembers(['alice'], {
alice: {
launchState: 'runtime_pending_bootstrap',
@ -75,7 +77,7 @@ describe('TeamLaunchStateEvaluator', () => {
confirmedCount: 0,
pendingCount: 2,
failedCount: 0,
runtimeAlivePendingCount: 1,
runtimeAlivePendingCount: 0,
shellOnlyPendingCount: 0,
runtimeProcessPendingCount: 0,
runtimeCandidatePendingCount: 0,
@ -83,4 +85,78 @@ describe('TeamLaunchStateEvaluator', () => {
permissionPendingCount: 1,
});
});
it('counts registered-only persisted liveness as no-runtime pending', () => {
const summary = summarizePersistedLaunchMembers(['alice'], {
alice: {
launchState: 'runtime_pending_bootstrap',
runtimeAlive: false,
livenessKind: 'registered_only',
},
} as any);
expect(summary).toMatchObject({
pendingCount: 1,
runtimeAlivePendingCount: 0,
noRuntimePendingCount: 1,
});
});
it('preserves persisted runtimeAlive only with strong liveness evidence', () => {
const summary = summarizePersistedLaunchMembers(['alice', 'bob', 'cara'], {
alice: {
launchState: 'runtime_pending_bootstrap',
runtimeAlive: true,
livenessKind: 'runtime_process',
},
bob: {
launchState: 'runtime_pending_bootstrap',
runtimeAlive: true,
bootstrapConfirmed: true,
},
cara: {
launchState: 'runtime_pending_bootstrap',
runtimeAlive: true,
livenessKind: 'runtime_process_candidate',
},
} as any);
expect(summary).toMatchObject({
pendingCount: 3,
runtimeAlivePendingCount: 2,
runtimeCandidatePendingCount: 1,
});
});
it('normalizes stale persisted runtimeAlive to false without strong liveness evidence', () => {
const snapshot = normalizePersistedLaunchSnapshot('demo', {
version: 2,
teamName: 'demo',
updatedAt: '2026-04-23T00:00:00.000Z',
launchPhase: 'active',
expectedMembers: ['alice'],
members: {
alice: {
name: 'alice',
launchState: 'runtime_pending_bootstrap',
agentToolAccepted: true,
runtimeAlive: true,
bootstrapConfirmed: false,
hardFailure: false,
livenessKind: 'runtime_process_candidate',
sources: {
processAlive: true,
},
lastEvaluatedAt: '2026-04-23T00:00:00.000Z',
},
},
});
expect(snapshot?.members.alice).toMatchObject({
launchState: 'runtime_pending_bootstrap',
runtimeAlive: false,
livenessKind: 'runtime_process_candidate',
});
expect(snapshot?.members.alice.sources?.processAlive).toBeUndefined();
});
});

View file

@ -1,28 +0,0 @@
import { describe, expect, it } from 'vitest';
import {
CLAUDE_TEAM_MEMBER_LIVENESS_MODE_ENV,
resolveTeamMemberLivenessModeFromEnv,
} from '@main/services/team/TeamMemberLivenessMode';
describe('resolveTeamMemberLivenessModeFromEnv', () => {
it('defaults to diagnostics', () => {
expect(resolveTeamMemberLivenessModeFromEnv({})).toBe('diagnostics');
});
it('enables strict mode explicitly', () => {
expect(
resolveTeamMemberLivenessModeFromEnv({
[CLAUDE_TEAM_MEMBER_LIVENESS_MODE_ENV]: 'strict',
})
).toBe('strict');
});
it('falls back to diagnostics for unknown values', () => {
expect(
resolveTeamMemberLivenessModeFromEnv({
[CLAUDE_TEAM_MEMBER_LIVENESS_MODE_ENV]: 'yes',
})
).toBe('diagnostics');
});
});

View file

@ -752,6 +752,71 @@ describe('TeamProvisioningService', () => {
});
});
it('keeps RSS visible for bootstrap-confirmed Anthropic teammates with a verified process', async () => {
const svc = new TeamProvisioningService();
(svc as any).configReader = {
getConfig: vi.fn(async () => ({
members: [
{ name: 'team-lead', agentType: 'team-lead' },
{ name: 'alice', providerId: 'anthropic', model: 'claude-sonnet-4-6' },
],
})),
};
(svc as any).readPersistedRuntimeMembers = vi.fn(() => [
{
name: 'alice',
agentId: 'alice@nice-team',
backendType: 'tmux',
},
]);
const run = createMemberSpawnRun({
teamName: 'nice-team',
expectedMembers: ['alice'],
memberSpawnStatuses: new Map([
[
'alice',
createMemberSpawnStatusEntry({
status: 'online',
launchState: 'confirmed_alive',
runtimeAlive: true,
bootstrapConfirmed: true,
lastHeartbeatAt: '2026-04-24T12:00:00.000Z',
}),
],
]),
});
run.child = { pid: 111 };
run.request = { model: 'claude-opus-4-6' };
run.processKilled = false;
run.cancelRequested = false;
(svc as any).aliveRunByTeam.set('nice-team', run.runId);
(svc as any).runs.set(run.runId, run);
vi.mocked(listRuntimeProcessesForCurrentTmuxPlatform).mockResolvedValueOnce([
{
pid: 333,
ppid: 1,
command:
'/Users/belief/.bun/bin/bun cli.js --agent-id alice@nice-team --agent-name alice --team-name nice-team --model claude-sonnet-4-6',
},
]);
vi.mocked(pidusage).mockResolvedValueOnce({
'111': createPidusageStat(111, 123_000_000),
'333': createPidusageStat(333, 456_000_000),
} as any);
const snapshot = await svc.getTeamAgentRuntimeSnapshot('nice-team');
expect(pidusage).toHaveBeenCalledWith([111, 333], { maxage: 0 });
expect(snapshot.members.alice).toMatchObject({
alive: true,
providerId: 'anthropic',
pid: 333,
pidSource: 'agent_process_table',
rssBytes: 456_000_000,
runtimeModel: 'claude-sonnet-4-6',
});
});
it('prefers the newest matching agent pid when multiple processes match the same teammate', async () => {
const svc = new TeamProvisioningService();
(svc as any).configReader = {
@ -836,8 +901,8 @@ describe('TeamProvisioningService', () => {
expect(snapshot.members.alice).toBeUndefined();
});
it('keeps pure OpenCode launch members alive from confirmed launch snapshot while runtime adapter is tracked', async () => {
const teamName = 'pure-opencode-runtime-team';
it('keeps historical bootstrap separate from current runtime liveness', async () => {
const teamName = 'pure-opencode-runtime-team-strict';
const projectPath = '/Users/test/project';
writeLaunchConfig(teamName, projectPath, 'lead-session', ['alice']);
writeLaunchState(teamName, 'lead-session', {
@ -864,12 +929,102 @@ describe('TeamProvisioningService', () => {
const snapshot = await svc.getTeamAgentRuntimeSnapshot(teamName);
expect(snapshot.members.alice).toMatchObject({
alive: true,
alive: false,
historicalBootstrapConfirmed: true,
providerId: 'opencode',
runtimeModel: 'opencode/big-pickle',
});
});
it('does not treat a reused OpenCode runtime pid as live', async () => {
const teamName = 'pure-opencode-reused-pid-team';
const projectPath = '/Users/test/project';
writeLaunchConfig(teamName, projectPath, 'lead-session', ['alice']);
writeLaunchState(teamName, 'lead-session', {
alice: {
providerId: 'opencode',
model: 'opencode/big-pickle',
launchState: 'runtime_pending_bootstrap',
agentToolAccepted: true,
runtimeAlive: true,
bootstrapConfirmed: false,
hardFailure: false,
hardFailureReason: undefined,
runtimePid: 333,
runtimeSessionId: 'session-alice',
},
});
vi.mocked(listRuntimeProcessesForCurrentTmuxPlatform).mockResolvedValueOnce([
{ pid: 333, ppid: 1, command: 'node unrelated-worker.js' },
]);
vi.mocked(pidusage).mockResolvedValueOnce({
'333': createPidusageStat(333, 456_000_000),
} as any);
const svc = new TeamProvisioningService();
(svc as any).runtimeAdapterRunByTeam.set(teamName, {
runId: 'opencode-runtime-run',
providerId: 'opencode',
cwd: projectPath,
});
(svc as any).aliveRunByTeam.set(teamName, 'opencode-runtime-run');
const snapshot = await svc.getTeamAgentRuntimeSnapshot(teamName);
expect(snapshot.members.alice).toMatchObject({
alive: false,
livenessKind: 'runtime_process_candidate',
pidSource: 'opencode_bridge',
runtimeDiagnostic: 'OpenCode runtime pid is alive, but process identity is unverified',
pid: 333,
providerId: 'opencode',
});
});
it('does not carry stale persisted runtimeAlive through launch-state reconcile', async () => {
const teamName = 'persisted-stale-runtime-status-team';
const projectPath = '/Users/test/project';
const acceptedAt = new Date(Date.now() - 120_000).toISOString();
writeLaunchConfig(teamName, projectPath, 'lead-session', ['alice']);
writeLaunchState(teamName, 'lead-session', {
alice: {
providerId: 'codex',
model: 'gpt-5.4',
launchState: 'runtime_pending_bootstrap',
agentToolAccepted: true,
runtimeAlive: true,
bootstrapConfirmed: false,
hardFailure: false,
hardFailureReason: undefined,
firstSpawnAcceptedAt: acceptedAt,
runtimePid: 333,
livenessKind: 'runtime_process',
pidSource: 'agent_process_table',
},
});
const svc = new TeamProvisioningService();
const result = await svc.getMemberSpawnStatuses(teamName);
const persisted = JSON.parse(fs.readFileSync(getTeamLaunchStatePath(teamName), 'utf8'));
expect(result.statuses.alice).toMatchObject({
status: 'error',
launchState: 'failed_to_start',
runtimeAlive: false,
livenessSource: undefined,
livenessKind: 'stale_metadata',
hardFailure: true,
hardFailureReason: 'Teammate did not join within the launch grace window.',
});
expect(result.summary).toMatchObject({
failedCount: 1,
runtimeAlivePendingCount: 0,
});
expect(persisted.members.alice.runtimeAlive).toBe(false);
expect(persisted.members.alice.sources?.processAlive).toBeUndefined();
});
it('excludes removed meta members from live runtime metadata resolution', async () => {
const svc = new TeamProvisioningService();
(svc as any).configReader = {
@ -1140,6 +1295,9 @@ describe('TeamProvisioningService', () => {
];
(svc as any).aliveRunByTeam.set('runtime-team', 'run-1');
(svc as any).runs.set('run-1', run);
vi.mocked(listRuntimeProcessesForCurrentTmuxPlatform).mockResolvedValue([
{ pid: 333, ppid: 1, command: 'opencode runtime host' },
]);
vi.mocked(pidusage).mockReset();
vi.mocked(pidusage).mockImplementation(
async (target: number | string | Array<number | string>) => {
@ -1216,6 +1374,9 @@ describe('TeamProvisioningService', () => {
),
};
vi.mocked(pidusage).mockReset();
vi.mocked(listRuntimeProcessesForCurrentTmuxPlatform).mockResolvedValue([
{ pid: 333, ppid: 1, command: 'opencode runtime host' },
]);
vi.mocked(pidusage).mockImplementation(
async (target: number | string | Array<number | string>) => {
if (Array.isArray(target)) {
@ -2238,6 +2399,7 @@ describe('TeamProvisioningService', () => {
pendingCount: 1,
failedCount: 0,
runtimeAlivePendingCount: 1,
runtimeProcessPendingCount: 1,
},
{
version: 2,
@ -2314,6 +2476,37 @@ describe('TeamProvisioningService', () => {
).toBe('Finishing launch — 1 teammate awaiting permission approval');
});
it('counts registered-only liveness as no-runtime pending in launch summaries', () => {
const svc = new TeamProvisioningService();
const run = createMemberSpawnRun({
expectedMembers: ['alice'],
memberSpawnStatuses: new Map([
[
'alice',
createMemberSpawnStatusEntry({
status: 'waiting',
launchState: 'runtime_pending_bootstrap',
agentToolAccepted: true,
runtimeAlive: false,
bootstrapConfirmed: false,
livenessKind: 'registered_only',
runtimeDiagnostic: 'registered runtime metadata without live process',
}),
],
]),
});
const launchSummary = (svc as any).getMemberLaunchSummary(run);
expect(launchSummary).toMatchObject({
pendingCount: 1,
noRuntimePendingCount: 1,
});
expect(
(svc as any).buildPendingBootstrapStatusMessage('Finishing launch', run, launchSummary)
).toContain('1 no runtime found');
});
it('trusts persisted snapshot permission state for pure teams when live run statuses are absent', () => {
const svc = new TeamProvisioningService();
const run = createMemberSpawnRun({
@ -2330,6 +2523,7 @@ describe('TeamProvisioningService', () => {
pendingCount: 1,
failedCount: 0,
runtimeAlivePendingCount: 1,
runtimeProcessPendingCount: 1,
},
{
version: 2,
@ -2383,6 +2577,7 @@ describe('TeamProvisioningService', () => {
pendingCount: 1,
failedCount: 0,
runtimeAlivePendingCount: 1,
runtimeProcessPendingCount: 1,
},
{
version: 2,
@ -2403,6 +2598,7 @@ describe('TeamProvisioningService', () => {
runtimeAlive: true,
bootstrapConfirmed: false,
hardFailure: false,
livenessKind: 'runtime_process',
lastEvaluatedAt: '2026-04-22T12:00:00.000Z',
},
},
@ -2411,6 +2607,7 @@ describe('TeamProvisioningService', () => {
pendingCount: 1,
failedCount: 0,
runtimeAlivePendingCount: 1,
runtimeProcessPendingCount: 1,
},
teamLaunchState: 'partial_pending',
}
@ -2420,6 +2617,24 @@ describe('TeamProvisioningService', () => {
expect(message).not.toContain('/0');
});
it('does not use legacy runtimeAlivePendingCount as online launch copy evidence', () => {
const svc = new TeamProvisioningService();
const run = createMemberSpawnRun({
teamName: 'pure-team',
expectedMembers: ['alice'],
memberSpawnStatuses: new Map(),
});
const message = (svc as any).buildAggregatePendingLaunchMessage('Finishing launch', run, {
confirmedCount: 0,
pendingCount: 1,
failedCount: 0,
runtimeAlivePendingCount: 1,
});
expect(message).toBe('Finishing launch — teammates are still starting');
});
it('uses the union of persisted expected members and persisted member entries for pending launch copy', () => {
const svc = new TeamProvisioningService();
const run = createMemberSpawnRun({
@ -2987,6 +3202,82 @@ describe('TeamProvisioningService', () => {
});
});
it('persists sanitized runtime tool metadata diagnostics on OpenCode liveness updates', async () => {
const svc = new TeamProvisioningService();
const previousSnapshot = {
version: 2 as const,
teamName: 'mixed-team',
updatedAt: '2026-04-22T12:00:00.000Z',
launchPhase: 'active' as const,
expectedMembers: ['bob'],
members: {
bob: {
name: 'bob',
providerId: 'opencode' as const,
laneId: 'secondary:opencode:bob',
laneKind: 'secondary' as const,
laneOwnerProviderId: 'opencode' as const,
launchState: 'runtime_pending_bootstrap' as const,
agentToolAccepted: true,
runtimeAlive: true,
bootstrapConfirmed: false,
hardFailure: false,
lastEvaluatedAt: '2026-04-22T12:00:00.000Z',
diagnostics: ['existing diagnostic'],
},
},
summary: {
confirmedCount: 0,
pendingCount: 1,
failedCount: 0,
runtimeAlivePendingCount: 1,
},
teamLaunchState: 'partial_pending' as const,
};
const write = vi.fn(async () => {});
(svc as any).launchStateStore = {
read: vi.fn(async () => previousSnapshot),
write,
};
await (svc as any).updateOpenCodeRuntimeMemberLiveness({
teamName: 'mixed-team',
runId: 'run-member-spawn-1',
memberName: 'bob',
runtimeSessionId: 'session-bob',
observedAt: '2026-04-22T12:05:00.000Z',
diagnostics: ['native heartbeat'],
metadata: {
runtimePid: 4321,
processCommand: 'opencode runtime --token super-secret --safe ok',
runtimeVersion: '1.2.3',
hostPid: 987,
cwd: '/tmp/project',
},
reason: 'OpenCode runtime heartbeat accepted',
});
expect(write).toHaveBeenCalledTimes(1);
const writtenSnapshot = (
write.mock.calls[0] as unknown as [string, Record<string, unknown>] | undefined
)?.[1] as { members?: Record<string, { diagnostics?: string[] }> } | undefined;
const diagnostics = writtenSnapshot?.members?.bob?.diagnostics ?? [];
expect(diagnostics).toEqual(
expect.arrayContaining([
'existing diagnostic',
'native heartbeat',
'runtime pid: 4321',
'runtime process command: opencode runtime --token [redacted] --safe ok',
'runtime version: 1.2.3',
'runtime host pid: 987',
'runtime cwd: /tmp/project',
'OpenCode runtime heartbeat accepted',
])
);
expect(diagnostics.join('\n')).not.toContain('super-secret');
});
it('preserves richer persisted expectedMembers when OpenCode runtime liveness updates a stale snapshot', async () => {
const svc = new TeamProvisioningService();
const previousSnapshot = {
@ -4356,6 +4647,107 @@ describe('TeamProvisioningService', () => {
});
expect(run.pendingMemberRestarts.has('bob')).toBe(false);
});
it('does not let stale runtimeAlive bypass launch timeout when live metadata is weak', async () => {
const svc = new TeamProvisioningService();
const run = createMemberSpawnRun({
teamName: 'codex-team',
expectedMembers: ['bob'],
memberSpawnStatuses: new Map([
[
'bob',
createMemberSpawnStatusEntry({
status: 'online',
launchState: 'runtime_pending_bootstrap',
agentToolAccepted: true,
runtimeAlive: true,
livenessSource: 'process',
bootstrapConfirmed: false,
firstSpawnAcceptedAt: new Date(Date.now() - 120_000).toISOString(),
}),
],
]),
});
(svc as any).refreshMemberSpawnStatusesFromLeadInbox = vi.fn(async () => {});
(svc as any).maybeAuditMemberSpawnStatuses = vi.fn(async () => {});
(svc as any).getLiveTeamAgentRuntimeMetadata = vi.fn(
async () =>
new Map([
[
'bob',
{
alive: false,
livenessKind: 'shell_only',
runtimeDiagnostic: 'tmux pane foreground command is zsh',
runtimeDiagnosticSeverity: 'warning',
},
],
])
);
await (svc as any).reevaluateMemberLaunchStatus(run, 'bob');
expect(run.memberSpawnStatuses.get('bob')).toMatchObject({
status: 'error',
launchState: 'failed_to_start',
runtimeAlive: false,
livenessSource: undefined,
bootstrapConfirmed: false,
livenessKind: 'shell_only',
runtimeDiagnostic: 'tmux pane foreground command is zsh',
error: 'tmux pane foreground command is zsh',
});
});
it('keeps verified runtime pending with a warning after the bootstrap stall window', async () => {
const svc = new TeamProvisioningService();
const run = createMemberSpawnRun({
teamName: 'codex-team',
expectedMembers: ['bob'],
memberSpawnStatuses: new Map([
[
'bob',
createMemberSpawnStatusEntry({
status: 'waiting',
launchState: 'runtime_pending_bootstrap',
agentToolAccepted: true,
runtimeAlive: false,
bootstrapConfirmed: false,
firstSpawnAcceptedAt: new Date(Date.now() - 6 * 60_000).toISOString(),
}),
],
]),
});
(svc as any).refreshMemberSpawnStatusesFromLeadInbox = vi.fn(async () => {});
(svc as any).maybeAuditMemberSpawnStatuses = vi.fn(async () => {});
(svc as any).getLiveTeamAgentRuntimeMetadata = vi.fn(
async () =>
new Map([
[
'bob',
{
alive: true,
livenessKind: 'runtime_process',
runtimeDiagnostic: 'verified runtime process detected',
},
],
])
);
await (svc as any).reevaluateMemberLaunchStatus(run, 'bob');
expect(run.memberSpawnStatuses.get('bob')).toMatchObject({
status: 'online',
launchState: 'runtime_pending_bootstrap',
runtimeAlive: true,
bootstrapConfirmed: false,
livenessSource: 'process',
livenessKind: 'runtime_process',
runtimeDiagnostic: 'Runtime process is alive, but no bootstrap check-in after 5 min.',
runtimeDiagnosticSeverity: 'warning',
hardFailure: false,
});
});
});
it('removes generated MCP config when createTeam spawn fails synchronously', async () => {
@ -5753,7 +6145,19 @@ describe('TeamProvisioningService', () => {
);
const svc = new TeamProvisioningService();
(svc as any).getLiveTeamAgentNames = vi.fn(() => new Set(['alice']));
(svc as any).getLiveTeamAgentRuntimeMetadata = vi.fn(
async () =>
new Map([
[
'alice',
{
alive: true,
livenessKind: 'runtime_process',
runtimeDiagnostic: 'verified runtime process detected',
},
],
])
);
const result = await svc.getMemberSpawnStatuses(teamName);
@ -6098,14 +6502,12 @@ describe('TeamProvisioningService', () => {
);
const svc = new TeamProvisioningService();
(svc as any).getLiveTeamAgentNames = vi.fn(() => new Set(['jack']));
const result = await svc.getMemberSpawnStatuses(teamName);
expect(result.statuses.jack).toMatchObject({
status: 'error',
launchState: 'failed_to_start',
runtimeAlive: true,
});
expect(result.statuses.jack?.error).toContain('requested model is not available');
expect(result.statuses.jack?.hardFailureReason).toContain('requested model is not available');
@ -6449,7 +6851,7 @@ describe('TeamProvisioningService', () => {
expect(run.memberSpawnStatuses.get('alice')).toBe(existingEntry);
});
it('treats duplicate_skipped already_running as process-confirmed online', () => {
it('keeps duplicate_skipped already_running pending without strong evidence', () => {
const run = createMemberSpawnRun();
run.activeToolCalls.set('tool-agent-1', {
memberName: 'alice',
@ -6477,10 +6879,9 @@ describe('TeamProvisioningService', () => {
);
expect(run.memberSpawnStatuses.get('alice')).toMatchObject({
status: 'online',
status: 'waiting',
launchState: 'runtime_pending_bootstrap',
runtimeAlive: true,
livenessSource: 'process',
runtimeAlive: false,
hardFailure: false,
});
});
@ -6629,6 +7030,7 @@ describe('TeamProvisioningService', () => {
{
alive: true,
model: 'gpt-5.2',
livenessKind: 'runtime_process',
},
],
])
@ -6666,6 +7068,7 @@ describe('TeamProvisioningService', () => {
{
alive: true,
model: 'gpt-5.2',
livenessKind: 'runtime_process',
},
],
])
@ -6693,6 +7096,130 @@ describe('TeamProvisioningService', () => {
});
});
it('downgrades stale process liveness to pending when live metadata is weak', async () => {
const svc = new TeamProvisioningService();
(svc as any).getLiveTeamAgentRuntimeMetadata = vi.fn(
async () =>
new Map([
[
'bob',
{
alive: false,
livenessKind: 'runtime_process_candidate',
runtimeDiagnostic: 'OpenCode runtime pid is alive, but process identity is unverified',
runtimeDiagnosticSeverity: 'warning',
},
],
])
);
const result = await (svc as any).attachLiveRuntimeMetadataToStatuses('beacon-desk-4', {
bob: createMemberSpawnStatusEntry({
status: 'online',
launchState: 'runtime_pending_bootstrap',
agentToolAccepted: true,
runtimeAlive: true,
livenessSource: 'process',
bootstrapConfirmed: false,
hardFailure: false,
}),
});
expect(result.bob).toMatchObject({
status: 'waiting',
launchState: 'runtime_pending_bootstrap',
runtimeAlive: false,
livenessSource: undefined,
livenessKind: 'runtime_process_candidate',
runtimeDiagnostic: 'OpenCode runtime pid is alive, but process identity is unverified',
runtimeDiagnosticSeverity: 'warning',
});
});
it('keeps process table diagnostics visible when live metadata has no primary diagnostic', async () => {
const svc = new TeamProvisioningService();
(svc as any).getLiveTeamAgentRuntimeMetadata = vi.fn(
async () =>
new Map([
[
'bob',
{
alive: false,
livenessKind: 'not_found',
runtimeDiagnosticSeverity: 'warning',
diagnostics: ['process table is unavailable'],
},
],
])
);
const result = await (svc as any).attachLiveRuntimeMetadataToStatuses('beacon-desk-4', {
bob: createMemberSpawnStatusEntry({
status: 'waiting',
launchState: 'runtime_pending_bootstrap',
agentToolAccepted: true,
runtimeAlive: false,
bootstrapConfirmed: false,
hardFailure: false,
}),
});
expect(result.bob).toMatchObject({
launchState: 'runtime_pending_bootstrap',
runtimeAlive: false,
livenessKind: 'not_found',
runtimeDiagnostic: 'process table unavailable',
runtimeDiagnosticSeverity: 'warning',
});
});
it('classifies process table unavailable launch diagnostics with natural wording', () => {
const svc = new TeamProvisioningService();
const onProgress = vi.fn();
const run = createMemberSpawnRun({
expectedMembers: ['bob'],
memberSpawnStatuses: new Map([
[
'bob',
createMemberSpawnStatusEntry({
status: 'waiting',
launchState: 'runtime_pending_bootstrap',
livenessKind: 'shell_only',
runtimeDiagnostic: 'tmux pane foreground command is zsh; process table is unavailable',
}),
],
]),
});
run.isLaunch = true;
run.progress = {
runId: run.runId,
teamName: run.teamName,
status: 'running',
updatedAt: '2026-04-22T12:00:00.000Z',
};
run.onProgress = onProgress;
(svc as any).setMemberSpawnStatus(run, 'bob', 'online', undefined, 'process');
expect(run.progress.launchDiagnostics).toEqual(
expect.arrayContaining([
expect.objectContaining({
memberName: 'bob',
code: 'process_table_unavailable',
severity: 'warning',
detail: 'tmux pane foreground command is zsh; process table is unavailable',
}),
])
);
expect(onProgress).toHaveBeenCalledWith(
expect.objectContaining({
launchDiagnostics: expect.arrayContaining([
expect.objectContaining({ code: 'process_table_unavailable' }),
]),
})
);
});
it('does not clear an explicit restart failure just because the old runtime is still alive', async () => {
const svc = new TeamProvisioningService();
(svc as any).getLiveTeamAgentRuntimeMetadata = vi.fn(
@ -6803,7 +7330,6 @@ describe('TeamProvisioningService', () => {
expect(result.bob).toMatchObject({
status: 'error',
launchState: 'failed_to_start',
runtimeAlive: true,
hardFailure: true,
hardFailureReason: 'Teammate did not join within the launch grace window.',
error: 'Teammate did not join within the launch grace window.',
@ -7022,7 +7548,7 @@ describe('TeamProvisioningService', () => {
});
});
it('treats suffixed live runtime names as alive during persisted launch reconcile', async () => {
it('keeps suffixed weak runtime metadata pending during persisted launch reconcile', async () => {
const teamName = 'suffixed-live-runtime-team';
const leadSessionId = 'lead-session';
writeLaunchConfig(teamName, '/Users/test/proj', leadSessionId, ['alice']);
@ -7039,14 +7565,26 @@ describe('TeamProvisioningService', () => {
});
const svc = new TeamProvisioningService();
(svc as any).getLiveTeamAgentNames = vi.fn(async () => new Set(['alice-2']));
(svc as any).getLiveTeamAgentRuntimeMetadata = vi.fn(
async () =>
new Map([
[
'alice-2',
{
alive: false,
livenessKind: 'registered_only',
runtimeDiagnostic: 'registered runtime metadata without live process',
},
],
])
);
const result = await svc.getMemberSpawnStatuses(teamName);
expect(result.statuses.alice).toMatchObject({
status: 'online',
status: 'waiting',
launchState: 'runtime_pending_bootstrap',
runtimeAlive: true,
runtimeAlive: false,
});
});
@ -7105,7 +7643,7 @@ describe('TeamProvisioningService', () => {
2
)
);
(svc as any).getLiveTeamAgentNames = vi.fn(async () => new Set<string>());
(svc as any).getLiveTeamAgentRuntimeMetadata = vi.fn(async () => new Map());
const result = await (svc as any).reconcilePersistedLaunchState(teamName);

View file

@ -0,0 +1,181 @@
import { describe, expect, it } from 'vitest';
import {
resolveTeamMemberRuntimeLiveness,
sanitizeProcessCommandForDiagnostics,
} from '@main/services/team/TeamRuntimeLivenessResolver';
const NOW = '2026-04-24T12:00:00.000Z';
describe('resolveTeamMemberRuntimeLiveness', () => {
it('classifies tmux shell panes as weak shell-only evidence', () => {
const result = resolveTeamMemberRuntimeLiveness({
teamName: 'demo',
memberName: 'bob',
agentId: 'agent-bob',
backendType: 'tmux',
tmuxPaneId: '%1',
pane: { paneId: '%1', panePid: 100, currentCommand: 'zsh' },
processRows: [{ pid: 100, ppid: 1, command: 'zsh' }],
processTableAvailable: true,
nowIso: NOW,
});
expect(result.alive).toBe(false);
expect(result.livenessKind).toBe('shell_only');
expect(result.pidSource).toBe('tmux_pane');
expect(result.pid).toBe(100);
});
it('promotes a verified team and agent process to strong runtime evidence', () => {
const result = resolveTeamMemberRuntimeLiveness({
teamName: 'demo',
memberName: 'alice',
agentId: 'agent-alice',
backendType: 'tmux',
processRows: [
{
pid: 222,
ppid: 1,
command: 'node runtime --team-name demo --agent-id agent-alice',
},
],
processTableAvailable: true,
nowIso: NOW,
});
expect(result.alive).toBe(true);
expect(result.livenessKind).toBe('runtime_process');
expect(result.pidSource).toBe('agent_process_table');
expect(result.pid).toBe(222);
});
it('keeps a verified process pid visible after bootstrap is confirmed', () => {
const result = resolveTeamMemberRuntimeLiveness({
teamName: 'demo',
memberName: 'alice',
agentId: 'agent-alice',
backendType: 'tmux',
trackedSpawnStatus: {
status: 'online',
launchState: 'confirmed_alive',
agentToolAccepted: true,
runtimeAlive: true,
bootstrapConfirmed: true,
hardFailure: false,
updatedAt: NOW,
},
processRows: [
{
pid: 222,
ppid: 1,
command: 'node runtime --team-name demo --agent-id agent-alice',
},
],
processTableAvailable: true,
nowIso: NOW,
});
expect(result.alive).toBe(true);
expect(result.livenessKind).toBe('runtime_process');
expect(result.pidSource).toBe('agent_process_table');
expect(result.pid).toBe(222);
});
it('keeps a non-shell tmux descendant without identity as a candidate', () => {
const result = resolveTeamMemberRuntimeLiveness({
teamName: 'demo',
memberName: 'jack',
agentId: 'agent-jack',
backendType: 'tmux',
tmuxPaneId: '%2',
pane: { paneId: '%2', panePid: 300, currentCommand: 'zsh' },
processRows: [
{ pid: 300, ppid: 1, command: 'zsh' },
{ pid: 301, ppid: 300, command: 'node helper.js' },
],
processTableAvailable: true,
nowIso: NOW,
});
expect(result.alive).toBe(false);
expect(result.livenessKind).toBe('runtime_process_candidate');
expect(result.pidSource).toBe('tmux_child');
expect(result.pid).toBe(301);
});
it('promotes a live OpenCode runtime pid only when process identity matches', () => {
const result = resolveTeamMemberRuntimeLiveness({
teamName: 'demo',
memberName: 'bob',
providerId: 'opencode',
persistedRuntimePid: 404,
persistedRuntimeSessionId: 'session-bob',
processRows: [{ pid: 404, ppid: 1, command: 'opencode runtime host' }],
processTableAvailable: true,
nowIso: NOW,
});
expect(result.alive).toBe(true);
expect(result.livenessKind).toBe('runtime_process');
expect(result.pidSource).toBe('opencode_bridge');
expect(result.pid).toBe(404);
});
it('does not trust an OpenCode runtime pid reused by an unrelated process', () => {
const result = resolveTeamMemberRuntimeLiveness({
teamName: 'demo',
memberName: 'bob',
providerId: 'opencode',
persistedRuntimePid: 404,
persistedRuntimeSessionId: 'session-bob',
processRows: [{ pid: 404, ppid: 1, command: 'node unrelated-worker.js' }],
processTableAvailable: true,
nowIso: NOW,
});
expect(result.alive).toBe(false);
expect(result.livenessKind).toBe('runtime_process_candidate');
expect(result.pidSource).toBe('opencode_bridge');
expect(result.runtimeDiagnostic).toBe(
'OpenCode runtime pid is alive, but process identity is unverified'
);
});
it('does not trust a stale persisted pid without current process identity', () => {
const result = resolveTeamMemberRuntimeLiveness({
teamName: 'demo',
memberName: 'tom',
persistedRuntimePid: 444,
processRows: [{ pid: 555, ppid: 1, command: 'node other.js' }],
processTableAvailable: true,
nowIso: NOW,
});
expect(result.alive).toBe(false);
expect(result.livenessKind).toBe('stale_metadata');
expect(result.pidSource).toBe('persisted_metadata');
});
it('does not treat a persisted pid as stale when the process table is unavailable', () => {
const result = resolveTeamMemberRuntimeLiveness({
teamName: 'demo',
memberName: 'tom',
persistedRuntimePid: 444,
processRows: [],
processTableAvailable: false,
nowIso: NOW,
});
expect(result.alive).toBe(false);
expect(result.livenessKind).toBe('registered_only');
expect(result.pidSource).toBe('persisted_metadata');
expect(result.diagnostics).toContain('process table unavailable');
});
it('redacts common secret flags in diagnostics commands', () => {
expect(
sanitizeProcessCommandForDiagnostics('node runtime --api-key sk-123 --token=abc --safe ok')
).toBe('node runtime --api-key [redacted] --token=[redacted] --safe ok');
});
});

View file

@ -0,0 +1,303 @@
import { spawn, type ChildProcess } from 'node:child_process';
import { existsSync } from 'node:fs';
import { promises as fs } from 'node:fs';
import * as os from 'node:os';
import * as path from 'node:path';
import { afterEach, beforeEach, describe, expect, it } from 'vitest';
import { TeamProvisioningService } from '../../../../src/main/services/team/TeamProvisioningService';
import { setClaudeBasePathOverride } from '../../../../src/main/utils/pathDecoder';
describe('Team runtime memory safe e2e', () => {
let tempDir: string;
let child: ChildProcess | null;
beforeEach(async () => {
tempDir = await fs.mkdtemp(path.join(os.tmpdir(), 'team-runtime-memory-e2e-'));
await fs.mkdir(path.join(tempDir, '.claude'), { recursive: true });
setClaudeBasePathOverride(path.join(tempDir, '.claude'));
child = null;
});
afterEach(async () => {
if (child?.pid) {
child.kill('SIGTERM');
await waitForExit(child, 2_000).catch(() => {
if (child?.pid) child.kill('SIGKILL');
});
}
setClaudeBasePathOverride(null);
await fs.rm(tempDir, { recursive: true, force: true });
});
it('reports RSS for a bootstrap-confirmed Anthropic teammate discovered from the real process table', async () => {
const teamName = `anthropic-rss-${process.pid}`;
const memberName = 'alice';
const agentId = `${memberName}@${teamName}`;
const projectPath = path.join(tempDir, 'project');
const runtimeScriptPath = path.join(tempDir, 'anthropic-runtime-fixture.mjs');
await fs.mkdir(projectPath, { recursive: true });
await fs.writeFile(
runtimeScriptPath,
[
'const keepAlive = setInterval(() => {}, 1000);',
"process.on('SIGTERM', () => { clearInterval(keepAlive); process.exit(0); });",
].join('\n'),
'utf8'
);
await writeTeamFixture({
tempDir,
teamName,
projectPath,
memberName,
agentId,
});
child = spawn(
process.execPath,
[
runtimeScriptPath,
'--agent-id',
agentId,
'--agent-name',
memberName,
'--team-name',
teamName,
'--model',
'claude-sonnet-4-6',
],
{
cwd: projectPath,
stdio: 'ignore',
}
);
expect(child.pid).toEqual(expect.any(Number));
await waitForProcessCommand(child.pid!, agentId, teamName);
const snapshot = await new TeamProvisioningService().getTeamAgentRuntimeSnapshot(teamName);
expect(snapshot.members[memberName]).toMatchObject({
alive: true,
providerId: 'anthropic',
pid: child.pid,
pidSource: 'agent_process_table',
livenessKind: 'runtime_process',
runtimeModel: 'claude-sonnet-4-6',
historicalBootstrapConfirmed: true,
});
expect(snapshot.members[memberName]?.rssBytes).toEqual(expect.any(Number));
expect(snapshot.members[memberName]?.rssBytes).toBeGreaterThan(0);
});
const cliSmokeIt =
process.env.ANTHROPIC_RUNTIME_MEMORY_CLI_SMOKE === '1' &&
process.env.CLAUDE_AGENT_TEAMS_ORCHESTRATOR_CLI_PATH?.trim() &&
existsSync(process.env.CLAUDE_AGENT_TEAMS_ORCHESTRATOR_CLI_PATH.trim())
? it
: it.skip;
cliSmokeIt('reports RSS for a real Anthropic teammate CLI process', async () => {
const teamName = `anthropic-cli-rss-${process.pid}`;
const memberName = 'alice';
const agentId = `${memberName}@${teamName}`;
const projectPath = path.join(tempDir, 'project');
const cliPath = process.env.CLAUDE_AGENT_TEAMS_ORCHESTRATOR_CLI_PATH!.trim();
await fs.mkdir(projectPath, { recursive: true });
await writeTeamFixture({
tempDir,
teamName,
projectPath,
memberName,
agentId,
});
let stderrTail = '';
child = spawn(
cliPath,
[
'--agent-id',
agentId,
'--agent-name',
memberName,
'--team-name',
teamName,
'--model',
'claude-sonnet-4-6',
],
{
cwd: projectPath,
env: {
...process.env,
CLAUDE_CODE_EXPERIMENTAL_AGENT_TEAMS: '1',
NO_UPDATE_NOTIFIER: '1',
},
stdio: ['pipe', 'ignore', 'pipe'],
}
);
child.stderr?.on('data', (chunk) => {
stderrTail = `${stderrTail}${String(chunk)}`.slice(-4_000);
});
expect(child.pid).toEqual(expect.any(Number));
await waitForProcessCommand(child.pid!, agentId, teamName, () => stderrTail);
const snapshot = await new TeamProvisioningService().getTeamAgentRuntimeSnapshot(teamName);
expect(snapshot.members[memberName]).toMatchObject({
alive: true,
providerId: 'anthropic',
pid: child.pid,
pidSource: 'agent_process_table',
livenessKind: 'runtime_process',
runtimeModel: 'claude-sonnet-4-6',
historicalBootstrapConfirmed: true,
});
expect(snapshot.members[memberName]?.rssBytes).toEqual(expect.any(Number));
expect(snapshot.members[memberName]?.rssBytes).toBeGreaterThan(0);
});
});
async function writeTeamFixture(params: {
tempDir: string;
teamName: string;
projectPath: string;
memberName: string;
agentId: string;
}): Promise<void> {
const teamDir = path.join(params.tempDir, '.claude', 'teams', params.teamName);
await fs.mkdir(teamDir, { recursive: true });
await fs.writeFile(
path.join(teamDir, 'config.json'),
`${JSON.stringify(
{
name: params.teamName,
projectPath: params.projectPath,
leadSessionId: 'lead-session',
members: [
{
name: 'team-lead',
agentType: 'team-lead',
role: 'Lead',
providerId: 'anthropic',
},
{
name: params.memberName,
role: 'Developer',
providerId: 'anthropic',
model: 'claude-sonnet-4-6',
agentId: params.agentId,
backendType: 'tmux',
},
],
},
null,
2
)}\n`,
'utf8'
);
await fs.writeFile(
path.join(teamDir, 'launch-state.json'),
`${JSON.stringify(
{
version: 2,
teamName: params.teamName,
updatedAt: '2026-04-24T12:00:00.000Z',
leadSessionId: 'lead-session',
launchPhase: 'active',
expectedMembers: [params.memberName],
members: {
[params.memberName]: {
name: params.memberName,
providerId: 'anthropic',
model: 'claude-sonnet-4-6',
launchState: 'confirmed_alive',
agentToolAccepted: true,
runtimeAlive: true,
bootstrapConfirmed: true,
hardFailure: false,
lastHeartbeatAt: '2026-04-24T12:00:00.000Z',
lastEvaluatedAt: '2026-04-24T12:00:00.000Z',
},
},
summary: {
confirmedCount: 1,
pendingCount: 0,
failedCount: 0,
runtimeAlivePendingCount: 0,
},
teamLaunchState: 'clean_success',
},
null,
2
)}\n`,
'utf8'
);
}
async function waitForProcessCommand(
pid: number,
agentId: string,
teamName: string,
getDebugTail: () => string = () => ''
): Promise<void> {
const deadline = Date.now() + 5_000;
while (Date.now() < deadline) {
const output = await readProcessCommand(pid).catch(() => '');
if (output.includes(agentId) && output.includes(teamName)) {
return;
}
await sleep(100);
}
const debugTail = getDebugTail().trim();
throw new Error(
`Process ${pid} did not appear in ps with expected team identity${
debugTail ? `\nCLI stderr tail:\n${debugTail}` : ''
}`
);
}
function readProcessCommand(pid: number): Promise<string> {
return new Promise((resolve, reject) => {
const ps = spawn('ps', ['-p', String(pid), '-o', 'command='], {
stdio: ['ignore', 'pipe', 'pipe'],
});
let stdout = '';
let stderr = '';
ps.stdout?.on('data', (chunk) => {
stdout += String(chunk);
});
ps.stderr?.on('data', (chunk) => {
stderr += String(chunk);
});
ps.on('error', reject);
ps.on('close', (code) => {
if (code === 0) {
resolve(stdout.trim());
} else {
reject(new Error(stderr.trim() || `ps exited with ${code}`));
}
});
});
}
function waitForExit(child: ChildProcess, timeoutMs: number): Promise<void> {
return new Promise((resolve, reject) => {
if (child.exitCode != null || child.signalCode != null) {
resolve();
return;
}
const timeout = setTimeout(() => {
child.off('exit', onExit);
reject(new Error('Timed out waiting for process exit'));
}, timeoutMs);
const onExit = (): void => {
clearTimeout(timeout);
resolve();
};
child.once('exit', onExit);
});
}
function sleep(ms: number): Promise<void> {
return new Promise((resolve) => setTimeout(resolve, ms));
}

View file

@ -1,14 +1,6 @@
// @vitest-environment node
import { afterEach, describe, expect, it, vi } from 'vitest';
const getConfigMock = vi.fn();
vi.mock('@main/services/infrastructure/ConfigManager', () => ({
configManager: {
getConfig: () => getConfigMock(),
},
}));
describe('cliFlavor', () => {
afterEach(() => {
delete process.env.CLAUDE_TEAM_CLI_FLAVOR;
@ -16,37 +8,20 @@ describe('cliFlavor', () => {
vi.clearAllMocks();
});
it('uses multimodel runtime by default when config enables it', async () => {
getConfigMock.mockReturnValue({
general: {
multimodelEnabled: true,
},
});
it('uses multimodel runtime by default', async () => {
const { getConfiguredCliFlavor } = await import('@main/services/team/cliFlavor');
expect(getConfiguredCliFlavor()).toBe('agent_teams_orchestrator');
});
it('uses claude runtime when multimodel is disabled in config', async () => {
getConfigMock.mockReturnValue({
general: {
multimodelEnabled: false,
},
});
it('ignores the legacy persisted multimodel flag', async () => {
const { getConfiguredCliFlavor } = await import('@main/services/team/cliFlavor');
expect(getConfiguredCliFlavor()).toBe('claude');
expect(getConfiguredCliFlavor()).toBe('agent_teams_orchestrator');
});
it('lets env override the persisted config', async () => {
it('lets env override the default runtime', async () => {
process.env.CLAUDE_TEAM_CLI_FLAVOR = 'claude';
getConfigMock.mockReturnValue({
general: {
multimodelEnabled: true,
},
});
const { getConfiguredCliFlavor } = await import('@main/services/team/cliFlavor');

View file

@ -3,6 +3,7 @@ import { describe, expect, it } from 'vitest';
import {
PROGRESS_LOG_TAIL_LINES,
PROGRESS_OUTPUT_TAIL_PARTS,
boundLaunchDiagnostics,
buildProgressAssistantOutput,
buildProgressLogsTail,
} from '../../../../src/main/services/team/progressPayload';
@ -75,3 +76,32 @@ describe('buildProgressAssistantOutput', () => {
expect(result!.split('\n\n')).toHaveLength(PROGRESS_OUTPUT_TAIL_PARTS);
});
});
describe('boundLaunchDiagnostics', () => {
it('redacts secret CLI flags and caps diagnostic payload size', () => {
const longDetail = `node runtime --token super-secret ${'x'.repeat(800)}`;
const result = boundLaunchDiagnostics([
{
id: 'bob:tmux_shell_only',
memberName: 'bob',
severity: 'warning',
code: 'tmux_shell_only',
label: 'bob - shell only --api-key abc123',
detail: longDetail,
observedAt: '2026-04-24T12:00:00.000Z',
},
]);
expect(result).toBeDefined();
expect(result).toHaveLength(1);
const first = result?.[0];
expect(first).toBeDefined();
if (!first) {
throw new Error('Expected one bounded launch diagnostic');
}
expect(first.label).toContain('--api-key [redacted]');
expect(first.detail).toContain('--token [redacted]');
expect(first.detail).not.toContain('super-secret');
expect(first.detail?.length).toBeLessThanOrEqual(500);
});
});

View file

@ -52,7 +52,7 @@ describe('pathDecoder', () => {
});
it('should encode a Windows-style absolute path', () => {
expect(encodePath('C:\\Users\\username\\projectname')).toBe('-C:-Users-username-projectname');
expect(encodePath('C:\\Users\\username\\projectname')).toBe('C--Users-username-projectname');
});
it('should handle empty string', () => {
@ -177,6 +177,10 @@ describe('pathDecoder', () => {
});
it('should return true for valid Windows-style encoded path', () => {
expect(isValidEncodedPath('C--Users-username-projectname')).toBe(true);
});
it('should return true for old colon Windows-style encoded path', () => {
expect(isValidEncodedPath('-C:-Users-username-projectname')).toBe(true);
});

View file

@ -0,0 +1,36 @@
import { afterEach, describe, expect, it, vi } from 'vitest';
import { HttpAPIClient } from '../../../src/renderer/api/httpClient';
class MockEventSource {
onopen: (() => void) | null = null;
onerror: (() => void) | null = null;
addEventListener(): void {
// noop browser-mode stub
}
close(): void {
// noop browser-mode stub
}
}
describe('HttpAPIClient team runtime browser fallback', () => {
afterEach(() => {
vi.unstubAllGlobals();
vi.restoreAllMocks();
});
it('returns valid member spawn and runtime snapshots when diagnostic fields are absent', async () => {
vi.stubGlobal('EventSource', MockEventSource);
const client = new HttpAPIClient('http://localhost:9999');
await expect(client.teams.getMemberSpawnStatuses('demo-team')).resolves.toEqual({
statuses: {},
runId: null,
});
await expect(client.teams.getTeamAgentRuntime('demo-team')).resolves.toMatchObject({
teamName: 'demo-team',
runId: null,
members: {},
});
});
});

View file

@ -325,7 +325,7 @@ describe('CLI status visibility during completed install state', () => {
window.localStorage.clear();
});
it('keeps the Multimodel toggle visible and enabled on the dashboard while login is still required', async () => {
it('shows multimodel status without exposing the legacy runtime toggle', async () => {
vi.stubGlobal('IS_REACT_ACT_ENVIRONMENT', true);
const host = document.createElement('div');
document.body.appendChild(host);
@ -340,8 +340,7 @@ describe('CLI status visibility during completed install state', () => {
expect(host.textContent).toContain('Login');
const toggle = host.querySelector('[data-testid="multimodel-toggle"]');
expect(toggle).not.toBeNull();
expect(toggle?.hasAttribute('disabled')).toBe(false);
expect(toggle).toBeNull();
await act(async () => {
root.unmount();

View file

@ -3,13 +3,8 @@ import { createRoot } from 'react-dom/client';
import { afterEach, describe, expect, it, vi } from 'vitest';
vi.mock('@renderer/components/ui/button', () => ({
Button: ({
children,
onClick,
}: {
children: React.ReactNode;
onClick?: () => void;
}) => React.createElement('button', { type: 'button', onClick }, children),
Button: ({ children, onClick }: { children: React.ReactNode; onClick?: () => void }) =>
React.createElement('button', { type: 'button', onClick }, children),
}));
vi.mock('@renderer/components/chat/viewers/MarkdownViewer', () => ({
@ -77,4 +72,117 @@ describe('ProvisioningProgressBlock', () => {
await Promise.resolve();
});
});
it('renders bounded launch diagnostics without opening CLI logs', async () => {
vi.stubGlobal('IS_REACT_ACT_ENVIRONMENT', true);
const host = document.createElement('div');
document.body.appendChild(host);
const root = createRoot(host);
await act(async () => {
root.render(
React.createElement(ProvisioningProgressBlock, {
title: 'Launching team',
currentStepIndex: 2,
loading: true,
defaultLiveOutputOpen: false,
cliLogsTail: 'tail line',
launchDiagnostics: [
{
id: 'bob:tmux_shell_only',
memberName: 'bob',
severity: 'warning',
code: 'tmux_shell_only',
label: 'bob - shell only',
detail: 'tmux pane foreground command is zsh',
observedAt: '2026-04-24T12:00:00.000Z',
},
{
id: 'tom:runtime_not_found',
memberName: 'tom',
severity: 'warning',
code: 'runtime_not_found',
label: 'tom - no runtime found',
detail: 'registered runtime metadata without live process',
observedAt: '2026-04-24T12:00:01.000Z',
},
{
id: 'jack:process_table_unavailable',
memberName: 'jack',
severity: 'warning',
code: 'process_table_unavailable',
label: 'jack - process table unavailable',
detail: 'runtime pid could not be verified because process table is unavailable',
observedAt: '2026-04-24T12:00:02.000Z',
},
],
})
);
await Promise.resolve();
});
expect(host.textContent).toContain('Diagnostics');
expect(host.textContent).not.toContain('logs:tail line');
const button = Array.from(host.querySelectorAll('button')).find((candidate) =>
candidate.textContent?.includes('Diagnostics')
);
expect(button).toBeTruthy();
await act(async () => {
button?.dispatchEvent(new MouseEvent('click', { bubbles: true }));
await Promise.resolve();
});
expect(host.textContent).toContain('bob - shell only');
expect(host.textContent).toContain('tmux pane foreground command is zsh');
expect(host.textContent).toContain('tom - no runtime found');
expect(host.textContent).toContain('registered runtime metadata without live process');
expect(host.textContent).toContain('jack - process table unavailable');
expect(host.textContent).toContain(
'runtime pid could not be verified because process table is unavailable'
);
await act(async () => {
root.unmount();
await Promise.resolve();
});
});
it('hides launch diagnostics when all entries are informational', async () => {
vi.stubGlobal('IS_REACT_ACT_ENVIRONMENT', true);
const host = document.createElement('div');
document.body.appendChild(host);
const root = createRoot(host);
await act(async () => {
root.render(
React.createElement(ProvisioningProgressBlock, {
title: 'Launching team',
currentStepIndex: 2,
loading: true,
defaultLiveOutputOpen: false,
launchDiagnostics: [
{
id: 'alice:bootstrap_confirmed',
memberName: 'alice',
severity: 'info',
code: 'bootstrap_confirmed',
label: 'alice - bootstrap confirmed',
observedAt: '2026-04-24T12:00:00.000Z',
},
],
})
);
await Promise.resolve();
});
expect(host.textContent).not.toContain('Diagnostics');
expect(host.textContent).not.toContain('alice - bootstrap confirmed');
await act(async () => {
root.unmount();
await Promise.resolve();
});
});
});

View file

@ -228,7 +228,7 @@ describe('MemberCard starting-state visuals', () => {
});
expect(host.textContent).not.toContain('online');
expect(host.querySelector('[aria-label="connecting"]')).not.toBeNull();
expect(host.querySelector('[aria-label="waiting for bootstrap"]')).not.toBeNull();
await act(async () => {
root.unmount();
@ -302,7 +302,7 @@ describe('MemberCard starting-state visuals', () => {
});
});
it('shows a connecting badge while runtime bootstrap is still pending after the process comes online', async () => {
it('shows a waiting-for-bootstrap badge while runtime bootstrap is still pending after the process comes online', async () => {
vi.stubGlobal('IS_REACT_ACT_ENVIRONMENT', true);
const host = document.createElement('div');
document.body.appendChild(host);
@ -324,9 +324,9 @@ describe('MemberCard starting-state visuals', () => {
await Promise.resolve();
});
expect(host.textContent).toContain('connecting');
expect(host.textContent).toContain('waiting for bootstrap');
expect(host.textContent).not.toContain('ready');
expect(host.querySelector('[aria-label="connecting"]')).not.toBeNull();
expect(host.querySelector('[aria-label="waiting for bootstrap"]')).not.toBeNull();
await act(async () => {
root.unmount();
@ -430,4 +430,156 @@ describe('MemberCard starting-state visuals', () => {
await Promise.resolve();
});
});
it('labels shared OpenCode host memory instead of member-owned runtime memory', async () => {
vi.stubGlobal('IS_REACT_ACT_ENVIRONMENT', true);
const host = document.createElement('div');
document.body.appendChild(host);
const root = createRoot(host);
await act(async () => {
root.render(
React.createElement(MemberCard, {
member,
memberColor: 'blue',
runtimeSummary: 'minimax · via OpenCode · 183.9 MB',
runtimeEntry: {
memberName: 'alice',
alive: true,
restartable: false,
providerId: 'opencode',
pid: 333,
pidSource: 'opencode_bridge',
rssBytes: 183.9 * 1024 * 1024,
updatedAt: '2026-04-24T12:00:00.000Z',
},
isTeamAlive: true,
isTeamProvisioning: false,
})
);
await Promise.resolve();
});
expect(host.querySelector('[title="RSS source: shared OpenCode host"]')).not.toBeNull();
await act(async () => {
root.unmount();
await Promise.resolve();
});
});
it('copies bounded launch diagnostics only for launch errors', async () => {
vi.stubGlobal('IS_REACT_ACT_ENVIRONMENT', true);
const writeText = vi.fn().mockResolvedValue(undefined);
Object.defineProperty(navigator, 'clipboard', {
configurable: true,
value: { writeText },
});
const host = document.createElement('div');
document.body.appendChild(host);
const root = createRoot(host);
await act(async () => {
root.render(
React.createElement(MemberCard, {
member,
memberColor: 'blue',
runtimeRunId: 'run-42',
isTeamAlive: true,
isTeamProvisioning: false,
spawnStatus: 'waiting',
spawnLaunchState: 'runtime_pending_bootstrap',
spawnRuntimeAlive: false,
spawnEntry: {
status: 'waiting',
launchState: 'runtime_pending_bootstrap',
runtimeAlive: false,
bootstrapConfirmed: false,
hardFailure: false,
agentToolAccepted: true,
livenessKind: 'shell_only',
runtimeDiagnostic: 'tmux pane foreground command is zsh',
runtimeDiagnosticSeverity: 'warning',
updatedAt: '2026-04-24T12:00:00.000Z',
},
runtimeEntry: {
memberName: 'alice',
alive: false,
restartable: true,
pid: 26676,
pidSource: 'tmux_pane',
paneCurrentCommand: 'zsh',
processCommand: 'node runtime --token super-secret',
updatedAt: '2026-04-24T12:00:01.000Z',
},
})
);
await Promise.resolve();
});
expect(host.querySelector('[aria-label="Copy diagnostics"]')).toBeNull();
await act(async () => {
root.render(
React.createElement(MemberCard, {
member,
memberColor: 'blue',
runtimeRunId: 'run-42',
isTeamAlive: true,
isTeamProvisioning: false,
spawnStatus: 'error',
spawnLaunchState: 'failed_to_start',
spawnRuntimeAlive: false,
spawnError: 'spawn failed',
spawnEntry: {
status: 'error',
launchState: 'failed_to_start',
runtimeAlive: false,
bootstrapConfirmed: false,
hardFailure: true,
hardFailureReason: 'spawn failed',
agentToolAccepted: false,
livenessKind: 'not_found',
runtimeDiagnostic: 'spawn failed',
runtimeDiagnosticSeverity: 'error',
updatedAt: '2026-04-24T12:00:00.000Z',
},
runtimeEntry: {
memberName: 'alice',
alive: false,
restartable: true,
pid: 26676,
pidSource: 'tmux_pane',
paneCurrentCommand: 'zsh',
processCommand: 'node runtime --token super-secret',
updatedAt: '2026-04-24T12:00:01.000Z',
},
})
);
await Promise.resolve();
});
const button = host.querySelector('[aria-label="Copy diagnostics"]') as HTMLButtonElement;
expect(button).not.toBeNull();
await act(async () => {
button.click();
await Promise.resolve();
});
expect(writeText).toHaveBeenCalledTimes(1);
const payload = JSON.parse(writeText.mock.calls[0][0] as string) as {
runId?: string;
livenessKind?: string;
processCommand?: string;
};
expect(payload.runId).toBe('run-42');
expect(payload.livenessKind).toBe('not_found');
expect(payload.processCommand).toContain('--token [redacted]');
await act(async () => {
root.unmount();
await Promise.resolve();
});
});
});

View file

@ -15,13 +15,7 @@ vi.mock('@renderer/hooks/useMemberStats', () => ({
}));
vi.mock('@renderer/components/ui/button', () => ({
Button: ({
children,
onClick,
}: {
children: React.ReactNode;
onClick?: () => void;
}) =>
Button: ({ children, onClick }: { children: React.ReactNode; onClick?: () => void }) =>
React.createElement(
'button',
{
@ -33,7 +27,8 @@ vi.mock('@renderer/components/ui/button', () => ({
}));
vi.mock('@renderer/components/ui/dialog', () => ({
Dialog: ({ children }: { children: React.ReactNode }) => React.createElement('div', null, children),
Dialog: ({ children }: { children: React.ReactNode }) =>
React.createElement('div', null, children),
DialogContent: ({ children }: { children: React.ReactNode }) =>
React.createElement('div', null, children),
DialogFooter: ({ children }: { children: React.ReactNode }) =>
@ -42,6 +37,15 @@ vi.mock('@renderer/components/ui/dialog', () => ({
React.createElement('div', null, children),
}));
vi.mock('@renderer/components/ui/tooltip', () => ({
Tooltip: ({ children }: { children: React.ReactNode }) =>
React.createElement(React.Fragment, null, children),
TooltipTrigger: ({ children }: { children: React.ReactNode }) =>
React.createElement(React.Fragment, null, children),
TooltipContent: ({ children }: { children: React.ReactNode }) =>
React.createElement('div', null, children),
}));
vi.mock('@renderer/components/ui/tabs', () => {
let currentValue = '';
let currentOnValueChange: ((value: string) => void) | null = null;
@ -60,14 +64,9 @@ vi.mock('@renderer/components/ui/tabs', () => {
currentOnValueChange = onValueChange ?? null;
return React.createElement('div', { 'data-tabs-value': value }, children);
},
TabsList: ({ children }: { children: React.ReactNode }) => React.createElement('div', null, children),
TabsTrigger: ({
children,
value,
}: {
children: React.ReactNode;
value: string;
}) =>
TabsList: ({ children }: { children: React.ReactNode }) =>
React.createElement('div', null, children),
TabsTrigger: ({ children, value }: { children: React.ReactNode; value: string }) =>
React.createElement(
'button',
{
@ -77,13 +76,8 @@ vi.mock('@renderer/components/ui/tabs', () => {
},
children
),
TabsContent: ({
children,
value,
}: {
children: React.ReactNode;
value: string;
}) => (currentValue === value ? React.createElement('div', null, children) : null),
TabsContent: ({ children, value }: { children: React.ReactNode; value: string }) =>
currentValue === value ? React.createElement('div', null, children) : null,
};
});
@ -93,7 +87,11 @@ vi.mock('@renderer/components/team/members/MemberDetailHeader', () => ({
vi.mock('@renderer/components/team/members/MemberDetailStats', () => ({
MemberDetailStats: ({ activityCount }: { activityCount: number }) =>
React.createElement('div', { 'data-testid': 'member-detail-stats' }, `activity-count:${activityCount}`),
React.createElement(
'div',
{ 'data-testid': 'member-detail-stats' },
`activity-count:${activityCount}`
),
}));
vi.mock('@renderer/components/team/members/MemberTasksTab', () => ({
@ -210,4 +208,132 @@ describe('MemberDetailDialog activity count', () => {
await Promise.resolve();
});
});
it('copies launch diagnostics from the detail footer only for launch errors', async () => {
const writeText = vi.fn().mockResolvedValue(undefined);
Object.defineProperty(navigator, 'clipboard', {
configurable: true,
value: { writeText },
});
const member: ResolvedTeamMember = {
name: 'jack',
status: 'active',
currentTaskId: null,
taskCount: 0,
lastActiveAt: null,
messageCount: 0,
};
const host = document.createElement('div');
document.body.appendChild(host);
const root = createRoot(host);
await act(async () => {
root.render(
React.createElement(MemberDetailDialog, {
open: true,
member,
teamName: 'demo-team',
runtimeRunId: 'run-42',
members: [member],
tasks: [],
spawnEntry: {
status: 'waiting',
launchState: 'runtime_pending_bootstrap',
runtimeAlive: false,
bootstrapConfirmed: false,
hardFailure: false,
agentToolAccepted: true,
livenessKind: 'runtime_process_candidate',
runtimeDiagnostic: 'runtime process candidate detected',
runtimeDiagnosticSeverity: 'warning',
updatedAt: '2026-04-24T12:00:00.000Z',
},
runtimeEntry: {
memberName: 'jack',
alive: false,
restartable: true,
pid: 4242,
pidSource: 'tmux_child',
processCommand: 'node runtime --api-key abc123',
updatedAt: '2026-04-24T12:00:01.000Z',
},
onClose: () => undefined,
onSendMessage: () => undefined,
onAssignTask: () => undefined,
onTaskClick: () => undefined,
})
);
await Promise.resolve();
});
let copyButton = Array.from(host.querySelectorAll('button')).find((button) =>
button.textContent?.includes('Copy diagnostics')
);
expect(copyButton).toBeUndefined();
await act(async () => {
root.render(
React.createElement(MemberDetailDialog, {
open: true,
member,
teamName: 'demo-team',
runtimeRunId: 'run-42',
members: [member],
tasks: [],
spawnEntry: {
status: 'error',
launchState: 'failed_to_start',
runtimeAlive: false,
bootstrapConfirmed: false,
hardFailure: true,
hardFailureReason: 'runtime process failed',
agentToolAccepted: false,
livenessKind: 'not_found',
runtimeDiagnostic: 'runtime process failed',
runtimeDiagnosticSeverity: 'error',
updatedAt: '2026-04-24T12:00:00.000Z',
},
runtimeEntry: {
memberName: 'jack',
alive: false,
restartable: true,
pid: 4242,
pidSource: 'tmux_child',
processCommand: 'node runtime --api-key abc123',
updatedAt: '2026-04-24T12:00:01.000Z',
},
onClose: () => undefined,
onSendMessage: () => undefined,
onAssignTask: () => undefined,
onTaskClick: () => undefined,
})
);
await Promise.resolve();
});
copyButton = Array.from(host.querySelectorAll('button')).find((button) =>
button.textContent?.includes('Copy diagnostics')
);
expect(copyButton).not.toBeUndefined();
await act(async () => {
copyButton?.click();
await Promise.resolve();
await Promise.resolve();
});
const payload = JSON.parse(writeText.mock.calls[0][0] as string) as {
runId?: string;
livenessKind?: string;
processCommand?: string;
};
expect(payload.runId).toBe('run-42');
expect(payload.livenessKind).toBe('not_found');
expect(payload.processCommand).toContain('--api-key [redacted]');
await act(async () => {
root.unmount();
await Promise.resolve();
});
});
});

View file

@ -10,7 +10,8 @@ vi.mock('@renderer/components/ui/badge', () => ({
}));
vi.mock('@renderer/components/ui/dialog', () => ({
DialogTitle: ({ children }: { children: React.ReactNode }) => React.createElement('div', null, children),
DialogTitle: ({ children }: { children: React.ReactNode }) =>
React.createElement('div', null, children),
DialogDescription: ({ children }: { children: React.ReactNode }) =>
React.createElement('div', null, children),
}));
@ -100,7 +101,7 @@ describe('MemberDetailHeader spawn-aware presence', () => {
});
});
it('shows connecting while the runtime is online but bootstrap is still pending', async () => {
it('shows waiting for bootstrap while the runtime is online but bootstrap is still pending', async () => {
vi.stubGlobal('IS_REACT_ACT_ENVIRONMENT', true);
const host = document.createElement('div');
document.body.appendChild(host);
@ -121,9 +122,9 @@ describe('MemberDetailHeader spawn-aware presence', () => {
await Promise.resolve();
});
expect(host.textContent).toContain('connecting');
expect(host.textContent).toContain('waiting for bootstrap');
expect(host.textContent).not.toContain('online');
expect(host.querySelector('[aria-label="connecting"]')).not.toBeNull();
expect(host.querySelector('[aria-label="waiting for bootstrap"]')).not.toBeNull();
await act(async () => {
root.unmount();

View file

@ -45,6 +45,12 @@ const storeState = {
updatedAt: string;
runtimeAlive: boolean;
livenessSource?: string;
livenessKind?: string;
runtimeDiagnostic?: string;
runtimeDiagnosticSeverity?: string;
error?: string;
hardFailure?: boolean;
hardFailureReason?: string;
}
>
>,
@ -52,6 +58,13 @@ const storeState = {
'northstar-core': undefined,
} as Record<string, unknown>,
leadActivityByTeam: {},
teamAgentRuntimeByTeam: {} as Record<
string,
{
runId: string | null;
members: Record<string, Record<string, unknown>>;
}
>,
openMemberProfile: vi.fn(),
};
@ -61,7 +74,11 @@ vi.mock('@renderer/store', () => ({
vi.mock('@renderer/store/slices/teamSlice', () => ({
getCurrentProvisioningProgressForTeam: () => storeState.progress,
selectResolvedMemberForTeamName: (state: typeof storeState, teamName: string, memberName: string) =>
selectResolvedMemberForTeamName: (
state: typeof storeState,
teamName: string,
memberName: string
) =>
(state.selectedTeamName === teamName ? state.selectedTeamData : null)?.members.find(
(candidate) => candidate.name === memberName
) ?? null,
@ -91,6 +108,15 @@ vi.mock('@renderer/components/ui/hover-card', () => ({
React.createElement('div', null, children),
}));
vi.mock('@renderer/components/ui/tooltip', () => ({
Tooltip: ({ children }: { children: React.ReactNode }) =>
React.createElement(React.Fragment, null, children),
TooltipTrigger: ({ children }: { children: React.ReactNode }) =>
React.createElement(React.Fragment, null, children),
TooltipContent: ({ children }: { children: React.ReactNode }) =>
React.createElement('div', null, children),
}));
vi.mock('@renderer/components/team/members/CurrentTaskIndicator', () => ({
CurrentTaskIndicator: () => null,
}));
@ -116,6 +142,7 @@ describe('MemberHoverCard spawn-aware presence', () => {
runtimeAlive: false,
};
storeState.memberSpawnSnapshotsByTeam['northstar-core'] = undefined;
storeState.teamAgentRuntimeByTeam = {};
storeState.openMemberProfile.mockReset();
});
@ -144,7 +171,7 @@ describe('MemberHoverCard spawn-aware presence', () => {
});
});
it('shows connecting for runtime-pending members while launch is still settling', async () => {
it('shows starting for runtime-pending members while launch is still settling', async () => {
vi.stubGlobal('IS_REACT_ACT_ENVIRONMENT', true);
storeState.progress = {
runId: 'run-1',
@ -188,9 +215,9 @@ describe('MemberHoverCard spawn-aware presence', () => {
await Promise.resolve();
});
expect(host.textContent).toContain('connecting');
expect(host.textContent).toContain('starting');
expect(host.textContent).not.toContain('online');
expect(host.querySelector('[aria-label="connecting"]')).not.toBeNull();
expect(host.querySelector('[aria-label="starting"]')).not.toBeNull();
await act(async () => {
root.unmount();
@ -198,7 +225,7 @@ describe('MemberHoverCard spawn-aware presence', () => {
});
});
it('shows connecting while runtime is online but bootstrap is still pending outside launch settling', async () => {
it('shows waiting for bootstrap while runtime is online but bootstrap is still pending outside launch settling', async () => {
vi.stubGlobal('IS_REACT_ACT_ENVIRONMENT', true);
storeState.progress = null;
storeState.memberSpawnStatusesByTeam['northstar-core'].alice = {
@ -224,9 +251,9 @@ describe('MemberHoverCard spawn-aware presence', () => {
await Promise.resolve();
});
expect(host.textContent).toContain('connecting');
expect(host.textContent).toContain('waiting for bootstrap');
expect(host.textContent).not.toContain('online');
expect(host.querySelector('[aria-label="connecting"]')).not.toBeNull();
expect(host.querySelector('[aria-label="waiting for bootstrap"]')).not.toBeNull();
await act(async () => {
root.unmount();
@ -279,4 +306,96 @@ describe('MemberHoverCard spawn-aware presence', () => {
await Promise.resolve();
});
});
it('copies launch diagnostics with the active runtime run id only for launch errors', async () => {
vi.stubGlobal('IS_REACT_ACT_ENVIRONMENT', true);
const writeText = vi.fn().mockResolvedValue(undefined);
Object.defineProperty(navigator, 'clipboard', {
configurable: true,
value: { writeText },
});
storeState.memberSpawnStatusesByTeam['northstar-core'].alice = {
status: 'waiting',
launchState: 'runtime_pending_bootstrap',
updatedAt: '2026-04-09T10:00:00.000Z',
runtimeAlive: false,
livenessKind: 'shell_only',
runtimeDiagnostic: 'tmux pane foreground command is zsh',
runtimeDiagnosticSeverity: 'warning',
};
storeState.teamAgentRuntimeByTeam['northstar-core'] = {
runId: 'runtime-run-1',
members: {
alice: {
memberName: 'alice',
alive: false,
restartable: true,
livenessKind: 'shell_only',
pidSource: 'tmux_pane',
paneCurrentCommand: 'zsh',
processCommand: 'node runtime --token secret',
updatedAt: '2026-04-09T10:00:01.000Z',
},
},
};
const host = document.createElement('div');
document.body.appendChild(host);
const root = createRoot(host);
await act(async () => {
root.render(
React.createElement(MemberHoverCard, {
name: 'alice',
children: React.createElement('button', { type: 'button' }, 'alice'),
})
);
await Promise.resolve();
});
expect(host.querySelector('[aria-label="Copy diagnostics"]')).toBeNull();
storeState.memberSpawnStatusesByTeam['northstar-core'].alice = {
status: 'error',
launchState: 'failed_to_start',
updatedAt: '2026-04-09T10:00:00.000Z',
runtimeAlive: false,
livenessKind: 'not_found',
runtimeDiagnostic: 'spawn failed',
runtimeDiagnosticSeverity: 'error',
error: 'spawn failed',
hardFailure: true,
hardFailureReason: 'spawn failed',
};
await act(async () => {
root.render(
React.createElement(MemberHoverCard, {
name: 'alice',
children: React.createElement('button', { type: 'button' }, 'alice'),
})
);
await Promise.resolve();
});
const button = host.querySelector('[aria-label="Copy diagnostics"]') as HTMLButtonElement;
expect(button).not.toBeNull();
await act(async () => {
button.click();
await Promise.resolve();
});
const payload = JSON.parse(writeText.mock.calls[0][0] as string) as {
runId?: string;
processCommand?: string;
};
expect(payload.runId).toBe('runtime-run-1');
expect(payload.processCommand).toContain('--token [redacted]');
await act(async () => {
root.unmount();
await Promise.resolve();
});
});
});

View file

@ -0,0 +1,107 @@
import { describe, expect, it } from 'vitest';
import { getLaunchJoinMilestonesFromMembers } from '@renderer/components/team/provisioningSteps';
const members = [{ name: 'alice' }, { name: 'bob' }, { name: 'tom' }, { name: 'jane' }];
describe('getLaunchJoinMilestonesFromMembers', () => {
it('does not count shell-only liveness as process alive', () => {
const milestones = getLaunchJoinMilestonesFromMembers({
members,
memberSpawnStatuses: {
alice: {
status: 'online',
launchState: 'runtime_pending_bootstrap',
runtimeAlive: true,
livenessSource: 'process',
livenessKind: 'shell_only',
updatedAt: '2026-04-24T12:00:00.000Z',
},
bob: {
status: 'online',
launchState: 'runtime_pending_bootstrap',
runtimeAlive: true,
livenessSource: 'process',
livenessKind: 'runtime_process',
updatedAt: '2026-04-24T12:00:00.000Z',
},
tom: {
status: 'online',
launchState: 'runtime_pending_bootstrap',
runtimeAlive: true,
livenessSource: 'process',
livenessKind: 'runtime_process_candidate',
updatedAt: '2026-04-24T12:00:00.000Z',
},
jane: {
status: 'online',
launchState: 'runtime_pending_bootstrap',
runtimeAlive: true,
livenessSource: 'process',
updatedAt: '2026-04-24T12:00:00.000Z',
},
},
});
expect(milestones.processOnlyAliveCount).toBe(1);
expect(milestones.pendingSpawnCount).toBe(3);
});
it('does not count missing liveness kind as process alive', () => {
const milestones = getLaunchJoinMilestonesFromMembers({
members,
memberSpawnStatuses: {
alice: {
status: 'online',
launchState: 'runtime_pending_bootstrap',
runtimeAlive: true,
livenessSource: 'process',
updatedAt: '2026-04-24T12:00:00.000Z',
},
},
});
expect(milestones.processOnlyAliveCount).toBe(0);
expect(milestones.pendingSpawnCount).toBe(4);
});
it('uses runtimeProcessPendingCount instead of legacy runtimeAlivePendingCount for snapshot pending math', () => {
const milestones = getLaunchJoinMilestonesFromMembers({
members,
memberSpawnSnapshot: {
expectedMembers: ['alice', 'bob', 'tom', 'jane'],
summary: {
confirmedCount: 0,
pendingCount: 4,
failedCount: 0,
runtimeAlivePendingCount: 3,
runtimeProcessPendingCount: 1,
shellOnlyPendingCount: 1,
runtimeCandidatePendingCount: 1,
permissionPendingCount: 1,
},
},
});
expect(milestones.processOnlyAliveCount).toBe(1);
expect(milestones.pendingSpawnCount).toBe(3);
});
it('does not trust legacy runtimeAlivePendingCount without runtime process count', () => {
const milestones = getLaunchJoinMilestonesFromMembers({
members,
memberSpawnSnapshot: {
expectedMembers: ['alice', 'bob', 'tom', 'jane'],
summary: {
confirmedCount: 0,
pendingCount: 4,
failedCount: 0,
runtimeAlivePendingCount: 3,
},
},
});
expect(milestones.processOnlyAliveCount).toBe(0);
expect(milestones.pendingSpawnCount).toBe(4);
});
});

View file

@ -60,13 +60,16 @@ function makeOverflowNode(): GraphNode {
}
describe('GraphNodePopover spawn badge labels', () => {
afterEach(() => {
afterEach(async () => {
await act(async () => {
useStore.setState({
selectedTeamName: null,
selectedTeamData: null,
teamDataCacheByName: {},
} as never);
await Promise.resolve();
});
document.body.innerHTML = '';
useStore.setState({
selectedTeamName: null,
selectedTeamData: null,
teamDataCacheByName: {},
} as never);
vi.unstubAllGlobals();
});
@ -138,45 +141,48 @@ describe('GraphNodePopover spawn badge labels', () => {
it('reuses launch-aware presence semantics from cached team data', async () => {
vi.stubGlobal('IS_REACT_ACT_ENVIRONMENT', true);
useStore.setState({
teamDataCacheByName: {
'northstar-core': {
teamName: 'northstar-core',
config: { name: 'Northstar', members: [], projectPath: '/repo' },
members: [
{
name: 'alice',
status: 'active',
currentTaskId: null,
taskCount: 0,
lastActiveAt: null,
messageCount: 0,
agentType: 'reviewer',
providerId: 'codex',
},
],
tasks: [],
messages: [],
kanbanState: { teamName: 'northstar-core', reviewers: [], tasks: {} },
processes: [],
isAlive: true,
},
},
memberSpawnStatusesByTeam: {
'northstar-core': {
alice: {
status: 'online',
launchState: 'runtime_pending_bootstrap',
livenessSource: 'process',
runtimeAlive: true,
await act(async () => {
useStore.setState({
teamDataCacheByName: {
'northstar-core': {
teamName: 'northstar-core',
config: { name: 'Northstar', members: [], projectPath: '/repo' },
members: [
{
name: 'alice',
status: 'active',
currentTaskId: null,
taskCount: 0,
lastActiveAt: null,
messageCount: 0,
agentType: 'reviewer',
providerId: 'codex',
},
],
tasks: [],
messages: [],
kanbanState: { teamName: 'northstar-core', reviewers: [], tasks: {} },
processes: [],
isAlive: true,
},
},
},
memberSpawnSnapshotsByTeam: {},
currentProvisioningRunIdByTeam: {},
provisioningRuns: {},
leadActivityByTeam: {},
} as never);
memberSpawnStatusesByTeam: {
'northstar-core': {
alice: {
status: 'online',
launchState: 'runtime_pending_bootstrap',
livenessSource: 'process',
runtimeAlive: true,
},
},
},
memberSpawnSnapshotsByTeam: {},
currentProvisioningRunIdByTeam: {},
provisioningRuns: {},
leadActivityByTeam: {},
} as never);
await Promise.resolve();
});
const host = document.createElement('div');
document.body.appendChild(host);
@ -193,7 +199,7 @@ describe('GraphNodePopover spawn badge labels', () => {
await Promise.resolve();
});
expect(host.textContent).toContain('connecting');
expect(host.textContent).toContain('waiting for bootstrap');
expect(host.textContent).not.toContain('Idle');
await act(async () => {
@ -204,48 +210,10 @@ describe('GraphNodePopover spawn badge labels', () => {
it('renders overflow stack contents instead of the task card and opens task detail from the list', async () => {
vi.stubGlobal('IS_REACT_ACT_ENVIRONMENT', true);
useStore.setState({
selectedTeamName: 'northstar-core',
selectedTeamData: {
teamName: 'northstar-core',
config: { name: 'Northstar', members: [], projectPath: '/repo' },
tasks: [
{
id: 'task-1',
displayId: '#1',
subject: 'Tighten rollout checklist',
owner: 'alice',
reviewer: 'bob',
status: 'in_progress',
reviewState: 'review',
kanbanColumn: 'review',
},
{
id: 'task-2',
displayId: '#2',
subject: 'Patch release notes',
owner: 'alice',
status: 'pending',
reviewState: 'none',
},
],
members: [],
messages: [],
kanbanState: {
teamName: 'northstar-core',
reviewers: [],
tasks: {
'task-1': {
column: 'review',
reviewer: 'bob',
movedAt: '2026-04-12T18:00:00.000Z',
},
},
},
processes: [],
},
teamDataCacheByName: {
'northstar-core': {
await act(async () => {
useStore.setState({
selectedTeamName: 'northstar-core',
selectedTeamData: {
teamName: 'northstar-core',
config: { name: 'Northstar', members: [], projectPath: '/repo' },
tasks: [
@ -283,8 +251,49 @@ describe('GraphNodePopover spawn badge labels', () => {
},
processes: [],
},
},
} as never);
teamDataCacheByName: {
'northstar-core': {
teamName: 'northstar-core',
config: { name: 'Northstar', members: [], projectPath: '/repo' },
tasks: [
{
id: 'task-1',
displayId: '#1',
subject: 'Tighten rollout checklist',
owner: 'alice',
reviewer: 'bob',
status: 'in_progress',
reviewState: 'review',
kanbanColumn: 'review',
},
{
id: 'task-2',
displayId: '#2',
subject: 'Patch release notes',
owner: 'alice',
status: 'pending',
reviewState: 'none',
},
],
members: [],
messages: [],
kanbanState: {
teamName: 'northstar-core',
reviewers: [],
tasks: {
'task-1': {
column: 'review',
reviewer: 'bob',
movedAt: '2026-04-12T18:00:00.000Z',
},
},
},
processes: [],
},
},
} as never);
await Promise.resolve();
});
const onOpenTaskDetail = vi.fn();
const host = document.createElement('div');

View file

@ -1136,8 +1136,8 @@ describe('TeamGraphAdapter particles', () => {
);
expect(findNode(graph, 'member:my-team:alice')).toMatchObject({
launchVisualState: 'runtime_pending',
launchStatusLabel: 'connecting',
launchVisualState: 'waiting',
launchStatusLabel: 'waiting to start',
});
});

Some files were not shown because too many files have changed in this diff Show more