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) { 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) { 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(); 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'); const metaPath = path.join(paths.teamDir, 'members.meta.json');
try { try {
const raw = JSON.parse(fs.readFileSync(metaPath, 'utf8')); const raw = JSON.parse(fs.readFileSync(metaPath, 'utf8'));
const members = normalizeMetaMembers(raw && raw.members); const members = normalizeMetaMembers(raw && raw.members);
if (members.length > 0) { if (members.length > 0) {
const metaLead = members.find( const metaLead = members.find((m) => runtimeHelpers.isCanonicalLeadMember(m));
(m) => m.agentType === 'team-lead' || m.name === 'team-lead'
);
if (metaLead && metaLead.name) return metaLead.name; if (metaLead && metaLead.name) return metaLead.name;
return members[0].name; return members[0].name;
} }
@ -74,13 +68,8 @@ function resolveTargetLead(paths, config) {
/* ENOENT or parse error */ /* ENOENT or parse error */
} }
// 4. role-based (legacy compat) // 3. First configured member
if (config && config.members && config.members.length) { 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(); 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) { function sendCrossTeamMessage(context, flags) {
const fromTeam = context.teamName; const fromTeam = context.teamName;
const toTeam = typeof flags.toTeam === 'string' ? flags.toTeam.trim() : ''; 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 = const replyToConversationId =
typeof flags.replyToConversationId === 'string' ? flags.replyToConversationId.trim() : ''; typeof flags.replyToConversationId === 'string' ? flags.replyToConversationId.trim() : '';
const conversationId = const conversationId =
@ -156,6 +145,10 @@ function sendCrossTeamMessage(context, flags) {
if (!TEAM_NAME_PATTERN.test(fromTeam)) { if (!TEAM_NAME_PATTERN.test(fromTeam)) {
throw new Error(`Invalid fromTeam: ${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)) { if (!TEAM_NAME_PATTERN.test(toTeam)) {
throw new Error(`Invalid toTeam: ${toTeam}`); throw new Error(`Invalid toTeam: ${toTeam}`);
} }
@ -165,6 +158,11 @@ function sendCrossTeamMessage(context, flags) {
if (!text || text.trim().length === 0) { if (!text || text.trim().length === 0) {
throw new Error('Message text is required'); 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 // Target context + config
const targetContext = createTargetContext(context, toTeam); const targetContext = createTargetContext(context, toTeam);

View file

@ -101,12 +101,12 @@ function listReviewers(context) {
function addReviewer(context, reviewer) { function addReviewer(context, reviewer) {
return withTeamBoardLock(context.paths, () => { return withTeamBoardLock(context.paths, () => {
runtimeHelpers.assertExplicitTeamMemberName(context.paths, reviewer, 'reviewer', { const resolvedReviewer = runtimeHelpers.assertExplicitTeamMemberName(context.paths, reviewer, 'reviewer', {
allowLeadAliases: true, allowLeadAliases: true,
}); });
const state = getKanbanState(context); const state = getKanbanState(context);
const next = new Set(state.reviewers); const next = new Set(state.reviewers);
next.add(String(reviewer)); next.add(String(resolvedReviewer));
kanbanStore.writeKanbanState(context.paths, context.teamName, { kanbanStore.writeKanbanState(context.paths, context.teamName, {
...state, ...state,
reviewers: [...next], reviewers: [...next],
@ -118,7 +118,13 @@ function addReviewer(context, reviewer) {
function removeReviewer(context, reviewer) { function removeReviewer(context, reviewer) {
return withTeamBoardLock(context.paths, () => { return withTeamBoardLock(context.paths, () => {
const state = getKanbanState(context); 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, { kanbanStore.writeKanbanState(context.paths, context.teamName, {
...state, ...state,
reviewers: next, reviewers: next,

View file

@ -69,10 +69,9 @@ function normalizeActorKey(value) {
function resolveKnownActorName(context, value, label) { function resolveKnownActorName(context, value, label) {
const actor = typeof value === 'string' && value.trim() ? value.trim() : ''; const actor = typeof value === 'string' && value.trim() ? value.trim() : '';
if (!actor) return null; if (!actor) return null;
runtimeHelpers.assertExplicitTeamMemberName(context.paths, actor, label, { return runtimeHelpers.assertExplicitTeamMemberName(context.paths, actor, label, {
allowLeadAliases: true, allowLeadAliases: true,
}); });
return actor;
} }
function tryResolveKnownActorName(context, value, label) { function tryResolveKnownActorName(context, value, label) {
@ -301,9 +300,10 @@ function requestReview(context, taskId, flags = {}) {
} }
const nextFrom = 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 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); const prevReviewState = getEffectiveReviewState(context, currentTask);
if (prevReviewState === 'approved') { if (prevReviewState === 'approved') {
throw new Error(`Task #${currentTask.displayId || currentTask.id} is already approved; reopen work before requesting another review`); 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; 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); const persisted = normalizeReviewState(task && task.reviewState);
if (persisted !== 'none') { const persistedFallback = normalizeFallback(persisted, 'task_review_state');
return { if (persistedFallback) {
state: persisted, return persistedFallback;
source: 'task_review_state',
};
} }
if (kanbanEntry && REVIEW_COLUMNS.has(kanbanEntry.column)) { if (kanbanEntry && REVIEW_COLUMNS.has(kanbanEntry.column)) {
return { const kanbanFallback = normalizeFallback(kanbanEntry.column, 'kanban_column');
state: normalizeReviewState(kanbanEntry.column), if (kanbanFallback) {
source: 'kanban_column', return kanbanFallback;
}; }
} }
return { return {

View file

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

View file

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

View file

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

View file

@ -385,6 +385,31 @@ describe('agent-teams-controller API', () => {
expect(briefing).toContain('Counters: actionable=4, awareness=3'); 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', () => { it('reconciles stale kanban rows and linked inbox comments idempotently', () => {
const claudeDir = makeClaudeDir(); const claudeDir = makeClaudeDir();
const controller = createController({ teamName: 'my-team', claudeDir }); const controller = createController({ teamName: 'my-team', claudeDir });
@ -887,7 +912,7 @@ describe('agent-teams-controller API', () => {
text: 'Need your decision here.', 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')); const rows = JSON.parse(fs.readFileSync(inboxPath, 'utf8'));
expect(rows).toHaveLength(1); expect(rows).toHaveLength(1);
expect(rows[0].from).toBe('bob'); expect(rows[0].from).toBe('bob');
@ -1114,6 +1139,82 @@ describe('agent-teams-controller API', () => {
expect(leadBriefing).not.toContain(`#${task.displayId}`); 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 () => { it('rejects task_briefing for unknown members', async () => {
const claudeDir = makeClaudeDir(); const claudeDir = makeClaudeDir();
const controller = createController({ teamName: 'my-team', claudeDir }); const controller = createController({ teamName: 'my-team', claudeDir });
@ -1305,6 +1406,22 @@ describe('agent-teams-controller API', () => {
expect(restored.reviewState).toBe('none'); 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', () => { it('uses actual kanban overlay for kanbanColumn inventory filters', () => {
const claudeDir = makeClaudeDir(); const claudeDir = makeClaudeDir();
const controller = createController({ teamName: 'my-team', claudeDir }); const controller = createController({ teamName: 'my-team', claudeDir });

View file

@ -59,8 +59,8 @@ describe('crossTeam module', () => {
const inbox = JSON.parse(fs.readFileSync(inboxPath, 'utf8')); const inbox = JSON.parse(fs.readFileSync(inboxPath, 'utf8'));
expect(inbox).toHaveLength(1); expect(inbox).toHaveLength(1);
expect(inbox[0].source).toBe(CROSS_TEAM_SOURCE); expect(inbox[0].source).toBe(CROSS_TEAM_SOURCE);
expect(inbox[0].from).toBe('team-a.lead'); expect(inbox[0].from).toBe('team-a.team-lead');
expect(inbox[0].text).toContain(`<${CROSS_TEAM_TAG_NAME} from="team-a.lead" depth="0"`); expect(inbox[0].text).toContain(`<${CROSS_TEAM_TAG_NAME} from="team-a.team-lead" depth="0"`);
expect(inbox[0].conversationId).toBeTruthy(); expect(inbox[0].conversationId).toBeTruthy();
expect(inbox[0].text).toContain(`conversationId="${inbox[0].conversationId}"`); expect(inbox[0].text).toContain(`conversationId="${inbox[0].conversationId}"`);
}); });
@ -314,6 +314,108 @@ describe('crossTeam module', () => {
expect(fs.existsSync(inboxPath)).toBe(true); 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', () => { it('resolves lead by name fallback', () => {
const claudeDir = makeClaudeDir({ const claudeDir = makeClaudeDir({
'team-a': { 'team-a': {

View file

@ -32,23 +32,23 @@
- `mcp-server/src/tools/runtimeTools.ts` уже содержит `runtime_bootstrap_checkin` и `runtime_heartbeat`. Это сильный сигнал, его надо сделать главным источником подтверждения. - `mcp-server/src/tools/runtimeTools.ts` уже содержит `runtime_bootstrap_checkin` и `runtime_heartbeat`. Это сильный сигнал, его надо сделать главным источником подтверждения.
- `agent-teams-controller/src/internal/runtime.js` уже прокидывает `runtimeBootstrapCheckin()` в desktop runtime. - `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`. - `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. - `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` выглядит логично. - Для tmux раньше читался только `#{pane_id}\t#{pane_pid}` через `listTmuxPanePidsForCurrentPlatform()`. `pane_pid` часто является shell (`zsh`, `bash`, `sh`), поэтому `2 MB` выглядело логично.
- `attachLiveRuntimeMetadataToStatuses()` превращает `metadata.alive` в `runtimeAlive: true` и `livenessSource: "process"`. - `attachLiveRuntimeMetadataToStatuses()` теперь повышает member до `runtimeAlive: true` только через strong evidence: `confirmed_bootstrap` или `runtime_process`.
- `reevaluateMemberLaunchStatus()` не fail-ит member после grace timeout, если `runtimeAlive === true`. - `reevaluateMemberLaunchStatus()` больше не доверяет старому `runtimeAlive === true` без live metadata.
- `OpenCodeTeamRuntimeAdapter.mapBridgeMemberToRuntimeEvidence()` сейчас может выставить `runtimeAlive: true`, если bridge просто вернул member в состоянии `created` или `permission_blocked`. Это полезный материализационный сигнал, но он слабее реального `runtimePid` и слабее bootstrap. - `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 уже есть, надо не дать слабым сигналам выглядеть как он. - `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, который надо сохранить. - `OpenCodeLaunchTransactionStore.canMarkOpenCodeRunReady()` уже требует `member_session_recorded`, `required_tools_proven` и `bootstrap_confirmed`. Это strict readiness precedent, который надо сохранить.
- Renderer уже получает оба источника: `memberSpawnStatuses` и `teamAgentRuntimeByTeam`. Но `MemberCard` сейчас получает только `runtimeSummary` строкой, а не сам `TeamAgentRuntimeEntry`. - Renderer уже получает оба источника: `memberSpawnStatuses` и `teamAgentRuntimeByTeam`. Но `MemberCard` сейчас получает только `runtimeSummary` строкой, а не сам `TeamAgentRuntimeEntry`.
- `teamSlice.areTeamAgentRuntimeEntriesEqual()` сейчас сравнивает только `memberName`, `alive`, `restartable`, `backendType`, `pid`, `runtimeModel`, `rssBytes`. Если добавить `livenessKind`, `pidSource`, `diagnostics`, но не обновить comparator, UI может не перерендериться. - `teamSlice.areTeamAgentRuntimeEntriesEqual()` должен сравнивать `livenessKind`, `pidSource` и diagnostic fields, иначе UI может не перерендериться при смене strict evidence.
- `teamSlice.areMemberSpawnStatusEntriesEqual()` сейчас намеренно игнорирует timing fields и сравнивает только visible spawn fields. Если добавить `livenessKind/runtimeDiagnostic`, comparator тоже надо обновить. - `teamSlice.areMemberSpawnStatusEntriesEqual()` должен сравнивать visible liveness fields (`livenessKind/runtimeDiagnostic`) и продолжать игнорировать timing-only fields.
- `areLaunchSummaryCountsEqual()` сейчас знает только `confirmedCount`, `pendingCount`, `failedCount`, `runtimeAlivePendingCount`. Новые aggregate diagnostic counts не будут обновлять UI без расширения comparator. - `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. - `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. - 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`, эта информация теряется. - 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-семантику. - `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. - `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. - `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. - `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 ## Rollout mode
Строгая модель меняет поведение launch timeout, поэтому ее надо включать контролируемо. Строгая модель меняет поведение launch timeout, поэтому изначальный план рассматривал rollout через отдельный флаг.
Текущая реализация после hardening включает strict liveness по умолчанию и не содержит старый переключатель режима.
Топ 3 rollout вариантов: Актуальное поведение:
1. Diagnostics-only default, strict behind env flag | Area | Strict-only behavior |
🎯 9 🛡️ 9 🧠 5 Примерно 80-140 строк. | ------------------------------ | ---------------------------------------- |
По умолчанию UI получает новые diagnostics, но `runtimeAlive` behavior остается старым. Strict включается через env для dogfood. Это самый безопасный путь для первого PR. | `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 сразу Operational rollback должен быть отдельным code revert или follow-up setting, а не скрытым env-флагом.
🎯 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.
## Structured launch diagnostics ## Structured launch diagnostics
@ -1251,7 +1214,7 @@ if (
Файл: `src/main/services/team/TeamProvisioningService.ts` Файл: `src/main/services/team/TeamProvisioningService.ts`
`handleMemberSpawnToolResult()` сейчас содержит shortcut: `handleMemberSpawnToolResult()` раньше содержал shortcut:
```ts ```ts
if (parsedStatus.reason === 'already_running') { 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 процессом. В strict liveness модели это опасно: `already_running` доказывает, что runtime/CLI отказался дублировать spawn, но не доказывает, что нужный teammate сейчас прошел bootstrap или что текущий pane PID является runtime процессом.
Новая логика: Итоговая логика:
```ts ```ts
if (parsedStatus.reason === 'already_running') { if (parsedStatus.reason === 'already_running') {
this.agentRuntimeSnapshotCache.delete(run.teamName); this.agentRuntimeSnapshotCache.delete(run.teamName);
this.liveTeamAgentRuntimeMetadataCache.delete(run.teamName); this.liveTeamAgentRuntimeMetadataCache.delete(run.teamName);
const runtime = await this.findStrongRuntimeEvidenceForMember(run.teamName, spawnedMemberName); this.setMemberSpawnStatus(run, spawnedMemberName, 'waiting');
if (isStrongRuntimeEvidence(runtime)) { this.appendMemberBootstrapDiagnostic(
this.setMemberSpawnStatus(run, spawnedMemberName, 'online', undefined, 'process'); run,
} else { spawnedMemberName,
this.setMemberSpawnStatus(run, spawnedMemberName, 'waiting'); 'already_running requires strong runtime verification'
this.setMemberRuntimeDiagnostic(run, spawnedMemberName, { );
livenessKind: runtime?.livenessKind ?? 'registered_only', void this.reevaluateMemberLaunchStatus(run, spawnedMemberName);
message: 'Runtime reported already running, but no verified member process was found yet.',
severity: 'warning',
});
}
} }
``` ```
@ -1825,6 +1784,7 @@ export interface PersistedTeamLaunchSummary {
confirmedCount: number; confirmedCount: number;
pendingCount: number; pendingCount: number;
failedCount: number; failedCount: number;
// Compatibility aggregate only. Do not use as process evidence in UI.
runtimeAlivePendingCount: number; runtimeAlivePendingCount: number;
shellOnlyPendingCount?: number; shellOnlyPendingCount?: number;
runtimeProcessPendingCount?: number; runtimeProcessPendingCount?: number;
@ -1905,11 +1865,6 @@ Backend/shared:
- добавить компактные diagnostic fields в `MemberSpawnStatusEntry`. - добавить компактные diagnostic fields в `MemberSpawnStatusEntry`.
- добавить bounded `TeamLaunchDiagnosticItem` и `TeamProvisioningProgress.launchDiagnostics`. - добавить 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` - `src/main/services/team/TeamRuntimeLivenessResolver.ts`
- вынести pure liveness classification; - вынести pure liveness classification;
- принимать tmux/process/OpenCode/persisted facts; - принимать tmux/process/OpenCode/persisted facts;
@ -1928,7 +1883,6 @@ Backend/shared:
- расширить `LiveTeamAgentRuntimeMetadata`; - расширить `LiveTeamAgentRuntimeMetadata`;
- parse sanitized runtime tool `metadata`; - parse sanitized runtime tool `metadata`;
- добавить strict evidence helpers; - добавить strict evidence helpers;
- подключить `TeamMemberLivenessMode`;
- использовать `TeamRuntimeLivenessResolver`; - использовать `TeamRuntimeLivenessResolver`;
- обновить `updateProgress()` extras для `launchDiagnostics`; - обновить `updateProgress()` extras для `launchDiagnostics`;
- переписать tmux/process resolution; - переписать tmux/process resolution;
@ -2005,11 +1959,6 @@ Renderer:
Backend: 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` - `TeamRuntimeLivenessResolver.test.ts`
- tmux foreground shell + no child -> `shell_only`; - tmux foreground shell + no child -> `shell_only`;
- verified process row by `--team-name` + `--agent-id` -> `runtime_process`; - verified process row by `--team-name` + `--agent-id` -> `runtime_process`;
@ -2088,9 +2037,9 @@ Renderer:
Add: Add:
- `TeamMemberLivenessMode` with default `diagnostics`;
- `TeamRuntimeLivenessResolver` pure tests; - `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: Verification:
@ -2103,14 +2052,11 @@ pnpm exec vitest run test/main/features/tmux-installer test/main/services/team/T
🎯 9 🛡️ 9 🧠 7 Примерно 220-320 строк. 🎯 9 🛡️ 9 🧠 7 Примерно 220-320 строк.
Переключить `attachLiveRuntimeMetadataToStatuses()` на strong evidence only when `CLAUDE_TEAM_MEMBER_LIVENESS_MODE=strict`. Shell/pane/candidate больше не выставляют `runtimeAlive` в strict mode. Переключить `attachLiveRuntimeMetadataToStatuses()` на strong evidence only. Shell/pane/candidate больше не выставляют `runtimeAlive`.
Keep diagnostics mode as rollback until manual launch scenarios pass.
Verification: Verification:
```bash ```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 pnpm exec vitest run test/main/services/team/TeamProvisioningService.test.ts
``` ```
@ -2204,9 +2150,9 @@ Scenarios:
- `member-spawn` event refreshes runtime snapshot. - `member-spawn` event refreshes runtime snapshot.
9. Rollout безопасен: 9. Rollout безопасен:
- default `diagnostics` mode не меняет hard timeout behavior до включения strict; - strict behavior включен по умолчанию;
- `CLAUDE_TEAM_MEMBER_LIVENESS_MODE=strict` включает strong-only behavior; - diagnostics UI остается доступным без отдельного mode flag;
- `CLAUDE_TEAM_MEMBER_LIVENESS_MODE=diagnostics` работает как rollback без удаления UI diagnostics. - rollback требует явного code revert или отдельного follow-up setting.
10. Provider failures не создают ложный ready: 10. Provider failures не создают ложный ready:
@ -2282,22 +2228,21 @@ Mitigation:
## Minimal safe patch order ## Minimal safe patch order
1. Добавить типы и optional fields. 1. Добавить типы и optional fields.
2. Добавить `TeamMemberLivenessMode` default `diagnostics`. 2. Добавить sanitized runtime tool metadata parser.
3. Добавить sanitized runtime tool metadata parser. 3. Добавить tmux `listPaneRuntimeInfo()` и сохранить wrapper `listPanePids()`.
4. Добавить tmux `listPaneRuntimeInfo()` и сохранить wrapper `listPanePids()`. 4. Добавить process table provider/parser с `ppid`.
5. Добавить process table provider/parser с `ppid`. 5. Вынести `TeamRuntimeLivenessResolver`.
6. Вынести `TeamRuntimeLivenessResolver`. 6. Заполнить `livenessKind`.
7. Заполнить `livenessKind` без behavior change. 7. Написать backend tests на shell-only, verified runtime, stale event, metadata PID.
8. Написать backend tests на shell-only, verified runtime, stale event, metadata PID. 8. Переключить `attachLiveRuntimeMetadataToStatuses()` на strong evidence.
9. Переключить `attachLiveRuntimeMetadataToStatuses()` на strong evidence behind strict mode. 9. Исправить `already_running` shortcut.
10. Исправить `already_running` shortcut behind strict mode. 10. Переключить timeout/self-heal logic на strong evidence.
11. Переключить timeout/self-heal logic behind strict mode. 11. Исправить OpenCode bridge mapping.
12. Исправить OpenCode bridge mapping. 12. Обновить persisted summary diagnostics и store equality.
13. Обновить persisted summary diagnostics и store equality. 13. Добавить `launchDiagnostics` в progress payload и UI disclosure.
14. Добавить `launchDiagnostics` в progress payload и UI disclosure. 14. Добавить renderer labels/tooltips/banner.
15. Добавить renderer labels/tooltips/banner. 15. Добавить copy diagnostics.
16. Добавить copy diagnostics. 16. Manual validation: создать команду, проверить pending names, runtime diagnostics и отсутствие false-ready shell-only процесса.
17. После manual validation включить strict default или оставить env rollback на один release.
## Expected UX ## Expected UX

View file

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

View file

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

View file

@ -2,6 +2,7 @@ import type { FastMCP } from 'fastmcp';
import { z } from 'zod'; import { z } from 'zod';
import { getController } from '../controller'; import { getController } from '../controller';
import { assertConfiguredTeam } from '../utils/teamConfig';
const toolContextSchema = { const toolContextSchema = {
teamName: z.string().min(1), teamName: z.string().min(1),
@ -20,13 +21,16 @@ export function registerLeadTools(server: Pick<FastMCP, 'addTool'>) {
parameters: z.object({ parameters: z.object({
...toolContextSchema, ...toolContextSchema,
}), }),
execute: async ({ teamName, claudeDir }) => ({ execute: async ({ teamName, claudeDir }) => {
content: [ assertConfiguredTeam(teamName, claudeDir);
{ return {
type: 'text' as const, content: [
text: await getController(teamName, claudeDir).tasks.leadBriefing(), {
}, 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 { z } from 'zod';
import { getController } from '../controller'; import { getController } from '../controller';
import { assertConfiguredTeam } from '../utils/teamConfig';
import { jsonTextContent } from '../utils/format'; import { jsonTextContent } from '../utils/format';
const toolContextSchema = { const toolContextSchema = {
@ -42,8 +43,9 @@ export function registerMessageTools(server: Pick<FastMCP, 'addTool'>) {
source, source,
leadSessionId, leadSessionId,
attachments, attachments,
}) => }) => {
await Promise.resolve( assertConfiguredTeam(teamName, claudeDir);
return await Promise.resolve(
jsonTextContent( jsonTextContent(
getController(teamName, claudeDir).messages.sendMessage({ getController(teamName, claudeDir).messages.sendMessage({
to, to,
@ -55,6 +57,7 @@ export function registerMessageTools(server: Pick<FastMCP, 'addTool'>) {
...(attachments?.length ? { attachments } : {}), ...(attachments?.length ? { attachments } : {}),
}) })
) )
), );
},
}); });
} }

View file

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

View file

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

View file

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

View file

@ -1,9 +1,8 @@
import type { FastMCP } from 'fastmcp'; import type { FastMCP } from 'fastmcp';
import fs from 'node:fs';
import path from 'node:path';
import { z } from 'zod'; import { z } from 'zod';
import { agentBlocks, getController } from '../controller'; import { agentBlocks, getController } from '../controller';
import { assertConfiguredTeam } from '../utils/teamConfig';
import { jsonTextContent, taskWriteResult, slimTask } from '../utils/format'; import { jsonTextContent, taskWriteResult, slimTask } from '../utils/format';
/** stripAgentBlocks from canonical agentBlocks module — single source of truth for the tag 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'>) { export function registerTaskTools(server: Pick<FastMCP, 'addTool'>) {
server.addTool({ server.addTool({
name: 'task_create', name: 'task_create',
@ -288,8 +251,12 @@ export function registerTaskTools(server: Pick<FastMCP, 'addTool'>) {
...toolContextSchema, ...toolContextSchema,
taskId: z.string().min(1), taskId: z.string().min(1),
}), }),
execute: async ({ teamName, claudeDir, taskId }) => execute: async ({ teamName, claudeDir, taskId }) => {
await Promise.resolve(jsonTextContent(getController(teamName, claudeDir).tasks.getTask(taskId))), assertConfiguredTeam(teamName, claudeDir);
return await Promise.resolve(
jsonTextContent(getController(teamName, claudeDir).tasks.getTask(taskId))
);
},
}); });
server.addTool({ server.addTool({
@ -301,12 +268,12 @@ export function registerTaskTools(server: Pick<FastMCP, 'addTool'>) {
taskId: z.string().min(1), taskId: z.string().min(1),
commentId: z.string().min(1), commentId: z.string().min(1),
}), }),
execute: async ({ teamName, claudeDir, taskId, commentId }) => execute: async ({ teamName, claudeDir, taskId, commentId }) => {
await Promise.resolve( assertConfiguredTeam(teamName, claudeDir);
jsonTextContent( return await Promise.resolve(
getController(teamName, claudeDir).tasks.getTaskComment(taskId, commentId) jsonTextContent(getController(teamName, claudeDir).tasks.getTaskComment(taskId, commentId))
) );
), },
}); });
server.addTool({ server.addTool({
@ -333,8 +300,9 @@ export function registerTaskTools(server: Pick<FastMCP, 'addTool'>) {
relatedTo, relatedTo,
blockedBy, blockedBy,
limit, limit,
}) => }) => {
await Promise.resolve( assertConfiguredTeam(teamName, claudeDir);
return await Promise.resolve(
jsonTextContent( jsonTextContent(
getController(teamName, claudeDir).tasks.listTaskInventory({ getController(teamName, claudeDir).tasks.listTaskInventory({
...(owner ? { owner } : {}), ...(owner ? { owner } : {}),
@ -346,7 +314,8 @@ export function registerTaskTools(server: Pick<FastMCP, 'addTool'>) {
limit: normalizeTaskListLimit(limit), limit: normalizeTaskListLimit(limit),
}) })
) )
), );
},
}); });
server.addTool({ server.addTool({
@ -358,10 +327,42 @@ export function registerTaskTools(server: Pick<FastMCP, 'addTool'>) {
status: z.enum(['pending', 'in_progress', 'completed', 'deleted']), status: z.enum(['pending', 'in_progress', 'completed', 'deleted']),
actor: z.string().optional(), actor: z.string().optional(),
}), }),
execute: async ({ teamName, claudeDir, taskId, status, actor }) => execute: async ({ teamName, claudeDir, taskId, status, actor }) => {
await Promise.resolve( assertConfiguredTeam(teamName, claudeDir);
jsonTextContent(slimTask(getController(teamName, claudeDir).tasks.setTaskStatus(taskId, status, actor) as Record<string, unknown>)) 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({ server.addTool({
@ -372,8 +373,19 @@ export function registerTaskTools(server: Pick<FastMCP, 'addTool'>) {
taskId: z.string().min(1), taskId: z.string().min(1),
actor: z.string().optional(), actor: z.string().optional(),
}), }),
execute: async ({ teamName, claudeDir, taskId, actor }) => execute: async ({ teamName, claudeDir, taskId, actor }) => {
await Promise.resolve(jsonTextContent(slimTask(getController(teamName, claudeDir).tasks.startTask(taskId, actor) as Record<string, unknown>))), assertConfiguredTeam(teamName, claudeDir);
return await Promise.resolve(
jsonTextContent(
slimTask(
getController(teamName, claudeDir).tasks.startTask(taskId, actor) as Record<
string,
unknown
>
)
)
);
},
}); });
server.addTool({ server.addTool({
@ -384,10 +396,19 @@ export function registerTaskTools(server: Pick<FastMCP, 'addTool'>) {
taskId: z.string().min(1), taskId: z.string().min(1),
actor: z.string().optional(), actor: z.string().optional(),
}), }),
execute: async ({ teamName, claudeDir, taskId, actor }) => execute: async ({ teamName, claudeDir, taskId, actor }) => {
await Promise.resolve( assertConfiguredTeam(teamName, claudeDir);
jsonTextContent(slimTask(getController(teamName, claudeDir).tasks.completeTask(taskId, actor) as Record<string, unknown>)) return await Promise.resolve(
), jsonTextContent(
slimTask(
getController(teamName, claudeDir).tasks.completeTask(taskId, actor) as Record<
string,
unknown
>
)
)
);
},
}); });
server.addTool({ server.addTool({
@ -398,10 +419,19 @@ export function registerTaskTools(server: Pick<FastMCP, 'addTool'>) {
taskId: z.string().min(1), taskId: z.string().min(1),
owner: z.string().nullable(), owner: z.string().nullable(),
}), }),
execute: async ({ teamName, claudeDir, taskId, owner }) => execute: async ({ teamName, claudeDir, taskId, owner }) => {
await Promise.resolve( assertConfiguredTeam(teamName, claudeDir);
jsonTextContent(slimTask(getController(teamName, claudeDir).tasks.setTaskOwner(taskId, owner) as Record<string, unknown>)) return await Promise.resolve(
), jsonTextContent(
slimTask(
getController(teamName, claudeDir).tasks.setTaskOwner(taskId, owner) as Record<
string,
unknown
>
)
)
);
},
}); });
server.addTool({ server.addTool({
@ -413,17 +443,19 @@ export function registerTaskTools(server: Pick<FastMCP, 'addTool'>) {
text: z.string().min(1), text: z.string().min(1),
from: z.string().optional(), from: z.string().optional(),
}), }),
execute: async ({ teamName, claudeDir, taskId, text, from }) => execute: async ({ teamName, claudeDir, taskId, text, from }) => {
await Promise.resolve( assertConfiguredTeam(teamName, claudeDir);
return await Promise.resolve(
jsonTextContent( jsonTextContent(
taskWriteResult( taskWriteResult(
getController(teamName, claudeDir).tasks.addTaskComment(taskId, { getController(teamName, claudeDir).tasks.addTaskComment(taskId, {
text, text,
...(from ? { from } : {}), ...(from ? { from } : {}),
}) as Record<string, unknown> }) as Record<string, unknown>
) )
) )
), );
},
}); });
server.addTool({ server.addTool({
@ -448,20 +480,22 @@ export function registerTaskTools(server: Pick<FastMCP, 'addTool'>) {
filename, filename,
mimeType, mimeType,
noFallback, noFallback,
}) => }) => {
await Promise.resolve( assertConfiguredTeam(teamName, claudeDir);
return await Promise.resolve(
jsonTextContent( jsonTextContent(
taskWriteResult( taskWriteResult(
getController(teamName, claudeDir).tasks.attachTaskFile(taskId, { getController(teamName, claudeDir).tasks.attachTaskFile(taskId, {
file: filePath, file: filePath,
...(mode ? { mode } : {}), ...(mode ? { mode } : {}),
...(filename ? { filename } : {}), ...(filename ? { filename } : {}),
...(mimeType ? { 'mime-type': mimeType } : {}), ...(mimeType ? { 'mime-type': mimeType } : {}),
...(noFallback ? { 'no-fallback': true } : {}), ...(noFallback ? { 'no-fallback': true } : {}),
}) as Record<string, unknown> }) as Record<string, unknown>
) )
) )
), );
},
}); });
server.addTool({ server.addTool({
@ -488,20 +522,22 @@ export function registerTaskTools(server: Pick<FastMCP, 'addTool'>) {
filename, filename,
mimeType, mimeType,
noFallback, noFallback,
}) => }) => {
await Promise.resolve( assertConfiguredTeam(teamName, claudeDir);
return await Promise.resolve(
jsonTextContent( jsonTextContent(
taskWriteResult( taskWriteResult(
getController(teamName, claudeDir).tasks.attachCommentFile(taskId, commentId, { getController(teamName, claudeDir).tasks.attachCommentFile(taskId, commentId, {
file: filePath, file: filePath,
...(mode ? { mode } : {}), ...(mode ? { mode } : {}),
...(filename ? { filename } : {}), ...(filename ? { filename } : {}),
...(mimeType ? { 'mime-type': mimeType } : {}), ...(mimeType ? { 'mime-type': mimeType } : {}),
...(noFallback ? { 'no-fallback': true } : {}), ...(noFallback ? { 'no-fallback': true } : {}),
}) as Record<string, unknown> }) as Record<string, unknown>
) )
) )
), );
},
}); });
server.addTool({ server.addTool({
@ -512,17 +548,19 @@ export function registerTaskTools(server: Pick<FastMCP, 'addTool'>) {
taskId: z.string().min(1), taskId: z.string().min(1),
value: z.enum(['lead', 'user', 'clear']), value: z.enum(['lead', 'user', 'clear']),
}), }),
execute: async ({ teamName, claudeDir, taskId, value }) => execute: async ({ teamName, claudeDir, taskId, value }) => {
await Promise.resolve( assertConfiguredTeam(teamName, claudeDir);
return await Promise.resolve(
jsonTextContent( jsonTextContent(
slimTask( slimTask(
getController(teamName, claudeDir).tasks.setNeedsClarification( getController(teamName, claudeDir).tasks.setNeedsClarification(
taskId, taskId,
value === 'clear' ? null : value value === 'clear' ? null : value
) as Record<string, unknown> ) as Record<string, unknown>
) )
) )
), );
},
}); });
server.addTool({ server.addTool({
@ -534,10 +572,20 @@ export function registerTaskTools(server: Pick<FastMCP, 'addTool'>) {
targetId: z.string().min(1), targetId: z.string().min(1),
relationship: relationshipTypeSchema, relationship: relationshipTypeSchema,
}), }),
execute: async ({ teamName, claudeDir, taskId, targetId, relationship }) => execute: async ({ teamName, claudeDir, taskId, targetId, relationship }) => {
await Promise.resolve( assertConfiguredTeam(teamName, claudeDir);
jsonTextContent(slimTask(getController(teamName, claudeDir).tasks.linkTask(taskId, targetId, relationship) as Record<string, unknown>)) return await Promise.resolve(
), jsonTextContent(
slimTask(
getController(teamName, claudeDir).tasks.linkTask(
taskId,
targetId,
relationship
) as Record<string, unknown>
)
)
);
},
}); });
server.addTool({ server.addTool({
@ -549,12 +597,20 @@ export function registerTaskTools(server: Pick<FastMCP, 'addTool'>) {
targetId: z.string().min(1), targetId: z.string().min(1),
relationship: relationshipTypeSchema, relationship: relationshipTypeSchema,
}), }),
execute: async ({ teamName, claudeDir, taskId, targetId, relationship }) => execute: async ({ teamName, claudeDir, taskId, targetId, relationship }) => {
await Promise.resolve( assertConfiguredTeam(teamName, claudeDir);
return await Promise.resolve(
jsonTextContent( 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({ server.addTool({
@ -565,14 +621,17 @@ export function registerTaskTools(server: Pick<FastMCP, 'addTool'>) {
...toolContextSchema, ...toolContextSchema,
memberName: z.string().min(1), memberName: z.string().min(1),
}), }),
execute: async ({ teamName, claudeDir, memberName }) => ({ execute: async ({ teamName, claudeDir, memberName }) => {
content: [ assertConfiguredTeam(teamName, claudeDir);
{ return {
type: 'text' as const, content: [
text: await getController(teamName, claudeDir).tasks.memberBriefing(memberName), {
}, type: 'text' as const,
], text: await getController(teamName, claudeDir).tasks.memberBriefing(memberName),
}), },
],
};
},
}); });
server.addTool({ server.addTool({
@ -582,13 +641,16 @@ export function registerTaskTools(server: Pick<FastMCP, 'addTool'>) {
...toolContextSchema, ...toolContextSchema,
memberName: z.string().min(1), memberName: z.string().min(1),
}), }),
execute: async ({ teamName, claudeDir, memberName }) => ({ execute: async ({ teamName, claudeDir, memberName }) => {
content: [ assertConfiguredTeam(teamName, claudeDir);
{ return {
type: 'text' as const, content: [
text: await getController(teamName, claudeDir).tasks.taskBriefing(memberName), {
}, 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 () => { it('caps high-volume task_list inventory over stdio and keeps rows compact', async () => {
await writeTeamConfig(claudeDir, 'bulk-inventory-team'); await writeTeamConfig(claudeDir, 'bulk-inventory-team');
await writeBulkTaskRows(claudeDir, 'bulk-inventory-team', 225); await writeBulkTaskRows(claudeDir, 'bulk-inventory-team', 225);
@ -1909,6 +1955,38 @@ describe('agent-teams-mcp stdio e2e', () => {
const startDeletedErrorText = const startDeletedErrorText =
startDeletedResponse.error?.message ?? (startDeletedResponse.result?.content?.[0]?.text ?? ''); startDeletedResponse.error?.message ?? (startDeletedResponse.result?.content?.[0]?.text ?? '');
expect(startDeletedErrorText).toContain('use task_restore before starting work'); 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 { } finally {
await client.close(); await client.close();
} }

View file

@ -88,7 +88,9 @@ describe('agent-teams-mcp tools', () => {
res.end(JSON.stringify(result.body)); res.end(JSON.stringify(result.body));
} catch (error) { } catch (error) {
res.writeHead(500, { 'content-type': 'application/json' }); 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 () => { 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 calls: Array<{ method?: string; url?: string; body?: unknown }> = [];
const server = await startControlServer(async ({ method, url, body }) => { const server = await startControlServer(async ({ method, url, body }) => {
calls.push({ method, url, body }); calls.push({ method, url, body });
@ -171,6 +177,7 @@ describe('agent-teams-mcp tools', () => {
try { try {
const launched = parseJsonToolResult( const launched = parseJsonToolResult(
await getTool('team_launch').execute({ await getTool('team_launch').execute({
claudeDir,
teamName: 'alpha', teamName: 'alpha',
cwd: '/tmp/project', cwd: '/tmp/project',
controlUrl: server.baseUrl, controlUrl: server.baseUrl,
@ -182,6 +189,7 @@ describe('agent-teams-mcp tools', () => {
const stopped = parseJsonToolResult( const stopped = parseJsonToolResult(
await getTool('team_stop').execute({ await getTool('team_stop').execute({
claudeDir,
teamName: 'alpha', teamName: 'alpha',
controlUrl: server.baseUrl, controlUrl: server.baseUrl,
}) })
@ -216,6 +224,13 @@ describe('agent-teams-mcp tools', () => {
}); });
it('forwards OpenCode runtime MCP tools through the runtime control bridge', async () => { 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 calls: Array<{ method?: string; url?: string; body?: unknown }> = [];
const server = await startControlServer(async ({ method, url, body }) => { const server = await startControlServer(async ({ method, url, body }) => {
calls.push({ method, url, body }); calls.push({ method, url, body });
@ -224,6 +239,7 @@ describe('agent-teams-mcp tools', () => {
try { try {
await getTool('runtime_bootstrap_checkin').execute({ await getTool('runtime_bootstrap_checkin').execute({
claudeDir,
teamName: 'alpha', teamName: 'alpha',
controlUrl: server.baseUrl, controlUrl: server.baseUrl,
runId: 'run-oc', runId: 'run-oc',
@ -231,6 +247,7 @@ describe('agent-teams-mcp tools', () => {
runtimeSessionId: 'ses-1', runtimeSessionId: 'ses-1',
}); });
await getTool('runtime_deliver_message').execute({ await getTool('runtime_deliver_message').execute({
claudeDir,
teamName: 'alpha', teamName: 'alpha',
controlUrl: server.baseUrl, controlUrl: server.baseUrl,
idempotencyKey: 'idem-1', idempotencyKey: 'idem-1',
@ -241,6 +258,7 @@ describe('agent-teams-mcp tools', () => {
text: 'hello', text: 'hello',
}); });
await getTool('runtime_task_event').execute({ await getTool('runtime_task_event').execute({
claudeDir,
teamName: 'alpha', teamName: 'alpha',
controlUrl: server.baseUrl, controlUrl: server.baseUrl,
idempotencyKey: 'idem-task-1', idempotencyKey: 'idem-task-1',
@ -251,6 +269,7 @@ describe('agent-teams-mcp tools', () => {
event: 'started', event: 'started',
}); });
await getTool('runtime_heartbeat').execute({ await getTool('runtime_heartbeat').execute({
claudeDir,
teamName: 'alpha', teamName: 'alpha',
controlUrl: server.baseUrl, controlUrl: server.baseUrl,
runId: 'run-oc', runId: 'run-oc',
@ -280,6 +299,9 @@ describe('agent-teams-mcp tools', () => {
it('discovers the control endpoint from the published state file', async () => { it('discovers the control endpoint from the published state file', async () => {
const claudeDir = makeClaudeDir(); const claudeDir = makeClaudeDir();
writeTeamConfig(claudeDir, 'alpha', {
members: [{ name: 'lead', role: 'team-lead' }],
});
const statePath = path.join(claudeDir, 'team-control-api.json'); const statePath = path.join(claudeDir, 'team-control-api.json');
const server = await startControlServer(async ({ method, url }) => { 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_start');
expect(ownerInbox[0].text).toContain('task_add_comment'); expect(ownerInbox[0].text).toContain('task_add_comment');
expect(ownerInbox[0].text).toContain('Read the plan before starting.'); 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( expect(ownerInbox[0].text).toContain(
'If you are busy, blocked, or still need more context, immediately add a short task comment' '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].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'); expect(ownerInbox[3].text).toContain('task_add_comment');
const briefing = (await getTool('task_briefing').execute({ const briefing = (await getTool('task_briefing').execute({
@ -695,14 +721,22 @@ describe('agent-teams-mcp tools', () => {
expect(memberBriefingText).toContain( 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.' '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('Task briefing for alice:');
expect(memberBriefingText).toContain(`#${activeTask.displayId}`); expect(memberBriefingText).toContain(`#${activeTask.displayId}`);
fs.mkdirSync(path.join(claudeDir, 'teams', teamName, 'inboxes'), { recursive: true }); 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', 'carol.json'), '[]');
fs.writeFileSync(path.join(claudeDir, 'teams', teamName, 'inboxes', 'cross_team_send.json'), '[]'); fs.writeFileSync(
fs.writeFileSync(path.join(claudeDir, 'teams', teamName, 'inboxes', 'other-team.alice.json'), '[]'); 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({ const inboxResolvedBriefing = (await getTool('member_briefing').execute({
claudeDir, claudeDir,
@ -710,7 +744,9 @@ describe('agent-teams-mcp tools', () => {
memberName: 'carol', memberName: 'carol',
})) as { content: Array<{ text: string }> }; })) as { content: Array<{ text: string }> };
const inboxResolvedBriefingText = inboxResolvedBriefing.content[0]?.text ?? ''; 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.'); expect(inboxResolvedBriefingText).toContain('Role: team member.');
await expect( await expect(
@ -897,9 +933,9 @@ describe('agent-teams-mcp tools', () => {
teamName, teamName,
}) })
); );
expect(listedTasks.find((task: { id: string }) => task.id === createdTask.id)?.reviewState).toBe( expect(
'needsFix' listedTasks.find((task: { id: string }) => task.id === createdTask.id)?.reviewState
); ).toBe('needsFix');
const kanbanCleared = parseJsonToolResult( const kanbanCleared = parseJsonToolResult(
await getTool('kanban_clear').execute({ await getTool('kanban_clear').execute({
@ -1044,6 +1080,26 @@ describe('agent-teams-mcp tools', () => {
); );
expect(kanbanState.tasks[reviewTask.id]).toBeUndefined(); expect(kanbanState.tasks[reviewTask.id]).toBeUndefined();
expect(JSON.stringify(kanbanState.columnOrder ?? {})).not.toContain(reviewTask.id); 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 () => { 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 () => { it('persists full message metadata through message_send', async () => {
const claudeDir = makeClaudeDir(); const claudeDir = makeClaudeDir();
const teamName = 'gamma'; const teamName = 'gamma';
writeTeamConfig(claudeDir, teamName, {
members: [
{ name: 'lead', role: 'team-lead' },
{ name: 'alice', role: 'developer' },
],
});
const sent = parseJsonToolResult( const sent = parseJsonToolResult(
await getTool('message_send').execute({ await getTool('message_send').execute({
@ -1155,6 +1217,41 @@ describe('agent-teams-mcp tools', () => {
expect(rows[0].attachments[0].filename).toBe('note.txt'); 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', () => { it('exposes zod schemas that reject obviously invalid payloads', () => {
expect( expect(
getTool('task_create').parameters?.safeParse({ getTool('task_create').parameters?.safeParse({
@ -1303,9 +1400,7 @@ describe('agent-teams-mcp tools', () => {
expect(completed.comments).toBeUndefined(); expect(completed.comments).toBeUndefined();
// task_list: explicit inventory shape only // task_list: explicit inventory shape only
const listed = parseJsonToolResult( const listed = parseJsonToolResult(await getTool('task_list').execute({ claudeDir, teamName }));
await getTool('task_list').execute({ claudeDir, teamName })
);
const listedTask = listed.find((t: { id: string }) => t.id === task.id); const listedTask = listed.find((t: { id: string }) => t.id === task.id);
expect(listedTask).toBeDefined(); expect(listedTask).toBeDefined();
expect(listedTask).toEqual({ expect(listedTask).toEqual({
@ -1345,9 +1440,7 @@ describe('agent-teams-mcp tools', () => {
const sentPath = path.join(claudeDir, 'teams', teamName, 'sentMessages.json'); const sentPath = path.join(claudeDir, 'teams', teamName, 'sentMessages.json');
const teamDir = path.join(claudeDir, 'teams', teamName); const teamDir = path.join(claudeDir, 'teams', teamName);
fs.mkdirSync(teamDir, { recursive: true }); fs.mkdirSync(teamDir, { recursive: true });
const existing = fs.existsSync(sentPath) const existing = fs.existsSync(sentPath) ? JSON.parse(fs.readFileSync(sentPath, 'utf8')) : [];
? JSON.parse(fs.readFileSync(sentPath, 'utf8'))
: [];
existing.push(message); existing.push(message);
fs.writeFileSync(sentPath, JSON.stringify(existing, null, 2)); fs.writeFileSync(sentPath, JSON.stringify(existing, null, 2));
} }
@ -1693,9 +1786,7 @@ describe('agent-teams-mcp tools', () => {
text: 'Roundtrip test message', text: 'Roundtrip test message',
timestamp: '2026-03-15T16:00:00.000Z', timestamp: '2026-03-15T16:00:00.000Z',
source: 'user_sent', source: 'user_sent',
attachments: [ attachments: [{ id: 'att-rt', filename: 'data.csv', mimeType: 'text/csv', size: 1024 }],
{ id: 'att-rt', filename: 'data.csv', mimeType: 'text/csv', size: 1024 },
],
}); });
const created = parseJsonToolResult( 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.' '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'; } from '../ports/RecentProjectsSourcePort';
const DEFAULT_CACHE_TTL_MS = 10_000; 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 { interface SourceLoadResult {
candidates: RecentProjectCandidate[]; candidates: RecentProjectCandidate[];
@ -99,9 +99,7 @@ export class ListDashboardRecentProjectsUseCase<TViewModel> {
} }
const viewModel = this.deps.output.present(response); const viewModel = this.deps.output.present(response);
const cacheTtlMs = hasDegradedSources const cacheTtlMs = hasDegradedSources ? this.#degradedCacheTtlMs : this.#cacheTtlMs;
? Math.min(this.#cacheTtlMs, this.#degradedCacheTtlMs)
: this.#cacheTtlMs;
await this.deps.cache.set(cacheKey, viewModel, cacheTtlMs); await this.deps.cache.set(cacheKey, viewModel, cacheTtlMs);
this.deps.logger.info('recent-projects loaded', { 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 { RecentProjectIdentityResolver } from '@features/recent-projects/main/infrastructure/identity/RecentProjectIdentityResolver';
import type { ServiceContext } from '@main/services'; 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_INITIALIZE_TIMEOUT_MS = 6_000;
const CODEX_LIVE_FETCH_TIMEOUT_MS = 4_500; const CODEX_LIVE_FETCH_TIMEOUT_MS = 12_000;
const CODEX_ARCHIVED_FETCH_TIMEOUT_MS = 2_500; const CODEX_ARCHIVED_FETCH_TIMEOUT_MS = 4_000;
const CODEX_SESSION_OVERHEAD_TIMEOUT_MS = 1_500; const CODEX_SESSION_OVERHEAD_TIMEOUT_MS = 1_500;
const CODEX_TOTAL_FETCH_TIMEOUT_MS = 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_SOURCE_TIMEOUT_MS = CODEX_TOTAL_FETCH_TIMEOUT_MS + 500;
const CODEX_LIVE_ONLY_FALLBACK_TOTAL_TIMEOUT_MS = const CODEX_LIVE_ONLY_FALLBACK_TOTAL_TIMEOUT_MS =
CODEX_INITIALIZE_TIMEOUT_MS + CODEX_LIVE_FETCH_TIMEOUT_MS + CODEX_SESSION_OVERHEAD_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 { function isInteractiveSource(source: unknown): boolean {
return source === 'vscode' || source === 'cli'; return source === 'vscode' || source === 'cli';
@ -42,9 +52,24 @@ function isDegradedThreadResult(result: CodexRecentThreadsResult): boolean {
return Boolean(result.live.error || result.archived.error); 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 { export class CodexRecentProjectsSourceAdapter implements RecentProjectsSourcePort {
readonly sourceId = 'codex'; readonly sourceId = 'codex';
readonly timeoutMs = CODEX_SOURCE_TIMEOUT_MS; readonly timeoutMs = CODEX_SOURCE_TIMEOUT_MS;
#staleCandidatesSnapshot: StaleCodexCandidatesSnapshot | null = null;
#fullFailureCooldownUntil = 0;
#fullFailureCooldownReason: string | null = null;
constructor( constructor(
private readonly deps: { 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 threadSegments = await this.#listRecentThreadsSafe(binaryPath);
const degraded = isDegradedThreadResult(threadSegments); const degraded = isDegradedThreadResult(threadSegments);
const fullFailureReason = getFullFailureReason(threadSegments);
this.#updateFullFailureCooldown(fullFailureReason);
this.#logSegmentFailure(threadSegments, 'live'); this.#logSegmentFailure(threadSegments, 'live');
this.#logSegmentFailure(threadSegments, 'archived'); this.#logSegmentFailure(threadSegments, 'archived');
const liveThreads = threadSegments.live.threads; const liveThreads = threadSegments.live.threads;
@ -92,6 +128,25 @@ export class CodexRecentProjectsSourceAdapter implements RecentProjectsSourcePor
await Promise.all(interactiveThreads.map((thread) => this.#toCandidate(thread))) await Promise.all(interactiveThreads.map((thread) => this.#toCandidate(thread)))
).filter((candidate): candidate is RecentProjectCandidate => candidate !== null); ).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', { this.deps.logger.info('codex recent-projects source loaded', {
count: candidates.length, count: candidates.length,
degraded, 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> { async #listRecentThreads(binaryPath: string): Promise<CodexRecentThreadsResult> {
const result = await this.deps.appServerClient.listRecentThreads(binaryPath, { const result = await this.deps.appServerClient.listRecentThreads(binaryPath, {
limit: CODEX_THREAD_LIMIT, limit: CODEX_THREAD_LIMIT,

View file

@ -74,10 +74,7 @@ export class CodexAppServerClient {
} }
): Promise<CodexThreadSegmentResult> { ): Promise<CodexThreadSegmentResult> {
const requestTimeoutMs = options.requestTimeoutMs ?? DEFAULT_REQUEST_TIMEOUT_MS; const requestTimeoutMs = options.requestTimeoutMs ?? DEFAULT_REQUEST_TIMEOUT_MS;
const initializeTimeoutMs = Math.max( const initializeTimeoutMs = options.initializeTimeoutMs ?? DEFAULT_INITIALIZE_TIMEOUT_MS;
options.initializeTimeoutMs ?? DEFAULT_INITIALIZE_TIMEOUT_MS,
requestTimeoutMs
);
const totalTimeoutMs = Math.max( const totalTimeoutMs = Math.max(
options.totalTimeoutMs ?? DEFAULT_TOTAL_TIMEOUT_MS, options.totalTimeoutMs ?? DEFAULT_TOTAL_TIMEOUT_MS,
initializeTimeoutMs + requestTimeoutMs + MIN_SESSION_OVERHEAD_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 liveRequestTimeoutMs = options.liveRequestTimeoutMs ?? DEFAULT_REQUEST_TIMEOUT_MS;
const archivedRequestTimeoutMs = options.archivedRequestTimeoutMs ?? DEFAULT_REQUEST_TIMEOUT_MS; const archivedRequestTimeoutMs = options.archivedRequestTimeoutMs ?? DEFAULT_REQUEST_TIMEOUT_MS;
const sessionRequestTimeoutMs = Math.max(liveRequestTimeoutMs, archivedRequestTimeoutMs); const sessionRequestTimeoutMs = Math.max(liveRequestTimeoutMs, archivedRequestTimeoutMs);
const initializeTimeoutMs = Math.max( const initializeTimeoutMs = options.initializeTimeoutMs ?? DEFAULT_INITIALIZE_TIMEOUT_MS;
options.initializeTimeoutMs ?? DEFAULT_INITIALIZE_TIMEOUT_MS,
sessionRequestTimeoutMs
);
const totalTimeoutMs = Math.max( const totalTimeoutMs = Math.max(
options.totalTimeoutMs ?? DEFAULT_TOTAL_TIMEOUT_MS, options.totalTimeoutMs ?? DEFAULT_TOTAL_TIMEOUT_MS,
initializeTimeoutMs + sessionRequestTimeoutMs + MIN_SESSION_OVERHEAD_TIMEOUT_MS initializeTimeoutMs +
liveRequestTimeoutMs +
archivedRequestTimeoutMs +
MIN_SESSION_OVERHEAD_TIMEOUT_MS
); );
return this.#withThreadListSession( return this.#withThreadListSession(
@ -140,50 +137,55 @@ export class CodexAppServerClient {
label: 'codex app-server thread/list', label: 'codex app-server thread/list',
}, },
async (session) => { async (session) => {
const [live, archived] = await Promise.allSettled([ const live = await this.#requestThreadListSegment(session, {
session.request<ThreadListResponse>( archived: false,
'thread/list', limit: options.limit,
{ timeoutMs: liveRequestTimeoutMs,
archived: false, });
limit: options.limit, const archived = await this.#requestThreadListSegment(session, {
sortKey: 'updated_at', archived: true,
}, limit: options.limit,
liveRequestTimeoutMs timeoutMs: archivedRequestTimeoutMs,
), });
session.request<ThreadListResponse>(
'thread/list',
{
archived: true,
limit: options.limit,
sortKey: 'updated_at',
},
archivedRequestTimeoutMs
),
]);
return { return {
live: live,
live.status === 'fulfilled' archived,
? { 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),
},
}; };
} }
); );
} }
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>( async #withThreadListSession<T>(
options: ThreadListSessionOptions, options: ThreadListSessionOptions,
handler: (session: JsonRpcSession) => Promise<T> handler: (session: JsonRpcSession) => Promise<T>

View file

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

View file

@ -6,7 +6,7 @@ import type {
} from '@features/recent-projects/contracts'; } from '@features/recent-projects/contracts';
const RECENT_PROJECTS_CLIENT_CACHE_TTL_MS = 15_000; 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 cachedPayload: DashboardRecentProjectsPayloadLike = null;
let cachedAt = 0; let cachedAt = 0;

View file

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

View file

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

View file

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

View file

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

View file

@ -96,4 +96,26 @@ describe('TmuxPlatformCommandExecutor', () => {
3_000 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'; process.env.UV_THREADPOOL_SIZE ??= '16';
// Keep userData stable before any integration can initialize Electron storage. // 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. // Sentry must stay near the top to capture early errors after storage migration.
import './sentry'; import './sentry';
@ -142,6 +140,7 @@ import {
markRendererUnavailable, markRendererUnavailable,
safeSendToRenderer, safeSendToRenderer,
} from './utils/safeWebContentsSend'; } from './utils/safeWebContentsSend';
import { earlyElectronUserDataMigrationResult } from './bootstrapUserDataMigration';
import { syncTelemetryFlag } from './sentry'; import { syncTelemetryFlag } from './sentry';
import { import {
ActiveTeamRegistry, ActiveTeamRegistry,

View file

@ -570,6 +570,7 @@ export class ConfigManager {
...DEFAULT_CONFIG.general, ...DEFAULT_CONFIG.general,
...(loaded.general ?? {}), ...(loaded.general ?? {}),
}; };
mergedGeneral.multimodelEnabled = true;
mergedGeneral.claudeRootPath = normalizeConfiguredClaudeRootPath(mergedGeneral.claudeRootPath); mergedGeneral.claudeRootPath = normalizeConfiguredClaudeRootPath(mergedGeneral.claudeRootPath);
// Merge triggers: preserve existing triggers, add missing builtin ones // 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 readline from 'node:readline';
import { killProcessTree, spawnCli } from '@main/utils/childProcess'; 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_REQUEST_TIMEOUT_MS = 3_000;
const DEFAULT_TOTAL_TIMEOUT_MS = 8_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 { export class JsonRpcRequestError extends Error {
readonly code: number | null; readonly code: number | null;
@ -76,6 +78,9 @@ export class JsonRpcStdioClient {
env?: NodeJS.ProcessEnv; env?: NodeJS.ProcessEnv;
requestTimeoutMs?: number; requestTimeoutMs?: number;
totalTimeoutMs?: number; totalTimeoutMs?: number;
stdinCloseTimeoutMs?: number;
closeTimeoutMs?: number;
forceCloseTimeoutMs?: number;
label: string; label: string;
}, },
handler: (session: JsonRpcSession) => Promise<T> handler: (session: JsonRpcSession) => Promise<T>
@ -95,8 +100,14 @@ export class JsonRpcStdioClient {
args: string[]; args: string[];
env?: NodeJS.ProcessEnv; env?: NodeJS.ProcessEnv;
requestTimeoutMs?: number; requestTimeoutMs?: number;
stdinCloseTimeoutMs?: number;
closeTimeoutMs?: number;
forceCloseTimeoutMs?: number;
}): Promise<JsonRpcSession> { }): Promise<JsonRpcSession> {
const requestTimeoutMs = options.requestTimeoutMs ?? DEFAULT_REQUEST_TIMEOUT_MS; 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, { const child = spawnCli(options.binaryPath, options.args, {
env: options.env, env: options.env,
stdio: ['pipe', 'pipe', 'pipe'], 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> => { const close = async (): Promise<void> => {
if (closed) { if (closed) {
return; return;
@ -204,21 +267,26 @@ export class JsonRpcStdioClient {
notificationListeners.clear(); notificationListeners.clear();
lineReader.close(); lineReader.close();
if (child.stdin && !child.stdin.destroyed && !child.stdin.writableEnded) { await closeStdin();
await new Promise<void>((resolve) => {
try { const gracefulClose = waitForChildClose(closeTimeoutMs);
child.stdin!.end(() => resolve()); killProcessTree(child, 'SIGTERM');
} catch { if (await gracefulClose) {
resolve(); return;
}
});
} }
killProcessTree(child); this.logger.warn('json-rpc close timed out; force killing process', {
try { pid: child.pid,
await once(child, 'close'); timeoutMs: closeTimeoutMs,
} catch { });
this.logger.warn('json-rpc close wait failed');
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 os from 'node:os';
import path from 'node:path'; import path from 'node:path';
import { afterEach, describe, expect, it } from 'vitest'; import { afterEach, describe, expect, it, vi } from 'vitest';
import { JsonRpcStdioClient } from '../JsonRpcStdioClient'; import { JsonRpcStdioClient } from '../JsonRpcStdioClient';
@ -42,6 +42,35 @@ rl.on('line', (line) => {
return scriptPath; 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(() => { afterEach(() => {
for (const dir of tempDirs.splice(0)) { for (const dir of tempDirs.splice(0)) {
fs.rmSync(dir, { recursive: true, force: true }); 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 status = typeof raw.status === 'string' ? raw.status : 'pending';
const hardFailure = status === 'failed'; const hardFailure = status === 'failed';
const bootstrapConfirmed = status === 'bootstrap_confirmed'; const bootstrapConfirmed = status === 'bootstrap_confirmed';
const runtimeAlive = bootstrapConfirmed || status === 'runtime_alive'; const bootstrapReportedRuntimeAlive = status === 'runtime_alive';
const runtimeAlive = bootstrapConfirmed;
const agentToolAccepted = const agentToolAccepted =
bootstrapConfirmed || bootstrapConfirmed ||
runtimeAlive || bootstrapReportedRuntimeAlive ||
status === 'registered' || status === 'registered' ||
status === 'spawn_started' || status === 'spawn_started' ||
hardFailure; hardFailure;
@ -351,7 +352,7 @@ function normalizeBootstrapMemberState(
? 'failed_to_start' ? 'failed_to_start'
: bootstrapConfirmed : bootstrapConfirmed
? 'confirmed_alive' ? 'confirmed_alive'
: runtimeAlive || agentToolAccepted : agentToolAccepted
? 'runtime_pending_bootstrap' ? 'runtime_pending_bootstrap'
: 'starting', : 'starting',
agentToolAccepted, agentToolAccepted,
@ -381,13 +382,13 @@ function normalizeBootstrapMemberState(
? raw.failureReason.trim() ? raw.failureReason.trim()
: 'deterministic bootstrap failed', : 'deterministic bootstrap failed',
] ]
: runtimeAlive : bootstrapConfirmed
? bootstrapConfirmed ? ['late heartbeat received']
? ['late heartbeat received'] : bootstrapReportedRuntimeAlive
: ['runtime alive', 'waiting for teammate check-in'] ? ['runtime alive reported by bootstrap state', 'waiting for strict live verification']
: agentToolAccepted : agentToolAccepted
? ['spawn accepted'] ? ['spawn accepted']
: undefined, : undefined,
}; };
} }

View file

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

View file

@ -80,6 +80,23 @@ function normalizeLivenessKind(value: unknown): TeamAgentRuntimeLivenessKind | u
: undefined; : 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 { function normalizePidSource(value: unknown): TeamAgentRuntimePidSource | undefined {
return value === 'lead_process' || return value === 'lead_process' ||
value === 'tmux_pane' || value === 'tmux_pane' ||
@ -181,7 +198,7 @@ export function summarizePersistedLaunchMembers(
continue; continue;
} }
pendingCount += 1; pendingCount += 1;
if (entry.runtimeAlive) { if (preservesStrongRuntimeAlive(entry)) {
runtimeAlivePendingCount += 1; runtimeAlivePendingCount += 1;
} }
if (entry.launchState === 'runtime_pending_permission') { if (entry.launchState === 'runtime_pending_permission') {
@ -193,7 +210,11 @@ export function summarizePersistedLaunchMembers(
runtimeProcessPendingCount += 1; runtimeProcessPendingCount += 1;
} else if (entry.livenessKind === 'runtime_process_candidate') { } else if (entry.livenessKind === 'runtime_process_candidate') {
runtimeCandidatePendingCount += 1; 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; noRuntimePendingCount += 1;
} }
} }
@ -369,6 +390,18 @@ function normalizePersistedMemberState(
return null; return null;
} }
const providerId = normalizeOptionalTeamProviderId(parsed.providerId); 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 = { const next: PersistedTeamLaunchMemberState = {
name: normalizedName, name: normalizedName,
providerId, providerId,
@ -399,8 +432,8 @@ function normalizePersistedMemberState(
launchIdentity: normalizeLaunchIdentity(parsed.launchIdentity, providerId), launchIdentity: normalizeLaunchIdentity(parsed.launchIdentity, providerId),
launchState: 'starting', launchState: 'starting',
agentToolAccepted: toBoolean(parsed.agentToolAccepted), agentToolAccepted: toBoolean(parsed.agentToolAccepted),
runtimeAlive: toBoolean(parsed.runtimeAlive), runtimeAlive,
bootstrapConfirmed: toBoolean(parsed.bootstrapConfirmed), bootstrapConfirmed,
hardFailure: toBoolean(parsed.hardFailure), hardFailure: toBoolean(parsed.hardFailure),
hardFailureReason: hardFailureReason:
typeof parsed.hardFailureReason === 'string' && parsed.hardFailureReason.trim().length > 0 typeof parsed.hardFailureReason === 'string' && parsed.hardFailureReason.trim().length > 0
@ -411,7 +444,7 @@ function normalizePersistedMemberState(
), ),
runtimePid: normalizeRuntimePid(parsed.runtimePid), runtimePid: normalizeRuntimePid(parsed.runtimePid),
runtimeSessionId: normalizeOptionalString(parsed.runtimeSessionId), runtimeSessionId: normalizeOptionalString(parsed.runtimeSessionId),
livenessKind: normalizeLivenessKind(parsed.livenessKind), livenessKind,
pidSource: normalizePidSource(parsed.pidSource), pidSource: normalizePidSource(parsed.pidSource),
runtimeDiagnostic: normalizeOptionalString(parsed.runtimeDiagnostic), runtimeDiagnostic: normalizeOptionalString(parsed.runtimeDiagnostic),
runtimeDiagnosticSeverity: normalizeDiagnosticSeverity(parsed.runtimeDiagnosticSeverity), runtimeDiagnosticSeverity: normalizeDiagnosticSeverity(parsed.runtimeDiagnosticSeverity),
@ -424,7 +457,7 @@ function normalizePersistedMemberState(
typeof parsed.lastRuntimeAliveAt === 'string' ? parsed.lastRuntimeAliveAt : undefined, typeof parsed.lastRuntimeAliveAt === 'string' ? parsed.lastRuntimeAliveAt : undefined,
lastEvaluatedAt: lastEvaluatedAt:
typeof parsed.lastEvaluatedAt === 'string' ? parsed.lastEvaluatedAt : updatedAtFallback, typeof parsed.lastEvaluatedAt === 'string' ? parsed.lastEvaluatedAt : updatedAtFallback,
sources: normalizeSources(parsed.sources), sources: Object.values(sources).some(Boolean) ? sources : undefined,
diagnostics: Array.isArray(parsed.diagnostics) diagnostics: Array.isArray(parsed.diagnostics)
? parsed.diagnostics.filter( ? parsed.diagnostics.filter(
(item): item is string => typeof item === 'string' && item.trim().length > 0 (item): item is string => typeof item === 'string' && item.trim().length > 0
@ -554,14 +587,15 @@ export function snapshotFromRuntimeMemberStatuses(params: {
sources.nativeHeartbeat = true; sources.nativeHeartbeat = true;
sources.inboxHeartbeat = true; sources.inboxHeartbeat = true;
} }
if (runtime?.livenessSource === 'process' || runtime?.runtimeAlive) { const runtimeAlive = preservesStrongRuntimeAlive(runtime);
if (runtime?.livenessSource === 'process' && runtimeAlive) {
sources.processAlive = true; sources.processAlive = true;
} }
const entry: PersistedTeamLaunchMemberState = { const entry: PersistedTeamLaunchMemberState = {
name, name,
launchState: runtime?.launchState ?? 'starting', launchState: runtime?.launchState ?? 'starting',
agentToolAccepted: runtime?.agentToolAccepted === true, agentToolAccepted: runtime?.agentToolAccepted === true,
runtimeAlive: runtime?.runtimeAlive === true, runtimeAlive,
bootstrapConfirmed: runtime?.bootstrapConfirmed === true, bootstrapConfirmed: runtime?.bootstrapConfirmed === true,
hardFailure: runtime?.hardFailure === true || runtime?.launchState === 'failed_to_start', hardFailure: runtime?.hardFailure === true || runtime?.launchState === 'failed_to_start',
hardFailureReason: runtime?.hardFailureReason ?? runtime?.error, hardFailureReason: runtime?.hardFailureReason ?? runtime?.error,
@ -574,7 +608,7 @@ export function snapshotFromRuntimeMemberStatuses(params: {
runtimeLastSeenAt: runtime?.livenessLastCheckedAt, runtimeLastSeenAt: runtime?.livenessLastCheckedAt,
firstSpawnAcceptedAt: runtime?.firstSpawnAcceptedAt, firstSpawnAcceptedAt: runtime?.firstSpawnAcceptedAt,
lastHeartbeatAt: runtime?.lastHeartbeatAt, lastHeartbeatAt: runtime?.lastHeartbeatAt,
lastRuntimeAliveAt: runtime?.runtimeAlive ? updatedAt : undefined, lastRuntimeAliveAt: runtimeAlive ? updatedAt : undefined,
lastEvaluatedAt: runtime?.updatedAt ?? updatedAt, lastEvaluatedAt: runtime?.updatedAt ?? updatedAt,
sources: Object.values(sources).some(Boolean) ? sources : undefined, sources: Object.values(sources).some(Boolean) ? sources : undefined,
diagnostics: undefined, diagnostics: undefined,
@ -610,6 +644,7 @@ export function snapshotToMemberSpawnStatuses(
if (!entry) continue; if (!entry) continue;
let status: MemberSpawnStatusEntry['status'] = 'offline'; let status: MemberSpawnStatusEntry['status'] = 'offline';
let livenessSource: MemberSpawnLivenessSource | undefined; let livenessSource: MemberSpawnLivenessSource | undefined;
const runtimeAlive = preservesStrongRuntimeAlive(entry);
if (entry.launchState === 'failed_to_start') { if (entry.launchState === 'failed_to_start') {
status = 'error'; status = 'error';
} else if (entry.launchState === 'confirmed_alive') { } else if (entry.launchState === 'confirmed_alive') {
@ -619,8 +654,8 @@ export function snapshotToMemberSpawnStatuses(
entry.launchState === 'runtime_pending_permission' || entry.launchState === 'runtime_pending_permission' ||
entry.launchState === 'runtime_pending_bootstrap' entry.launchState === 'runtime_pending_bootstrap'
) { ) {
status = entry.runtimeAlive ? 'online' : 'waiting'; status = runtimeAlive ? 'online' : 'waiting';
livenessSource = entry.runtimeAlive ? 'process' : undefined; livenessSource = runtimeAlive ? 'process' : undefined;
} else { } else {
status = entry.agentToolAccepted ? 'waiting' : 'spawning'; status = entry.agentToolAccepted ? 'waiting' : 'spawning';
} }
@ -631,7 +666,7 @@ export function snapshotToMemberSpawnStatuses(
hardFailureReason: entry.hardFailureReason, hardFailureReason: entry.hardFailureReason,
livenessSource, livenessSource,
agentToolAccepted: entry.agentToolAccepted, agentToolAccepted: entry.agentToolAccepted,
runtimeAlive: entry.runtimeAlive, runtimeAlive,
bootstrapConfirmed: entry.bootstrapConfirmed, bootstrapConfirmed: entry.bootstrapConfirmed,
hardFailure: entry.hardFailure, hardFailure: entry.hardFailure,
pendingPermissionRequestIds: entry.pendingPermissionRequestIds, 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'; } from './TeamLaunchStateEvaluator';
import { TeamLaunchStateStore } from './TeamLaunchStateStore'; import { TeamLaunchStateStore } from './TeamLaunchStateStore';
import { TeamMcpConfigBuilder } from './TeamMcpConfigBuilder'; import { TeamMcpConfigBuilder } from './TeamMcpConfigBuilder';
import {
isStrongRuntimeEvidence,
resolveTeamMemberRuntimeLiveness,
} from './TeamRuntimeLivenessResolver';
import { isStrictTeamMemberLivenessMode } from './TeamMemberLivenessMode';
import { TeamMemberLogsFinder } from './TeamMemberLogsFinder'; import { TeamMemberLogsFinder } from './TeamMemberLogsFinder';
import { TeamMembersMetaStore } from './TeamMembersMetaStore'; import { TeamMembersMetaStore } from './TeamMembersMetaStore';
import { TeamMetaStore } from './TeamMetaStore'; import { TeamMetaStore } from './TeamMetaStore';
import {
isStrongRuntimeEvidence,
resolveTeamMemberRuntimeLiveness,
sanitizeProcessCommandForDiagnostics,
} from './TeamRuntimeLivenessResolver';
import { TeamSentMessagesStore } from './TeamSentMessagesStore'; import { TeamSentMessagesStore } from './TeamSentMessagesStore';
import { TeamTaskReader } from './TeamTaskReader'; import { TeamTaskReader } from './TeamTaskReader';
import { TeamTranscriptProjectResolver } from './TeamTranscriptProjectResolver'; 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 { function runtimeTaskRefs(teamName: string, value: unknown): InboxMessage['taskRefs'] | undefined {
const refs = normalizeRuntimeStringArray(value); const refs = normalizeRuntimeStringArray(value);
return refs.length > 0 return refs.length > 0
@ -515,6 +563,32 @@ const PREFLIGHT_BINARY_TIMEOUT_MS = 8000;
const PREFLIGHT_AUTH_RETRY_DELAY_MS = 2000; const PREFLIGHT_AUTH_RETRY_DELAY_MS = 2000;
const PREFLIGHT_AUTH_MAX_RETRIES = 2; const PREFLIGHT_AUTH_MAX_RETRIES = 2;
const OPENCODE_PREFLIGHT_MODEL_PROBE_CONCURRENCY = 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< function applyDistinctProvisioningMemberColors<
T extends { name: string; color?: string; removedAt?: number }, T extends { name: string; color?: string; removedAt?: number },
@ -1537,7 +1611,11 @@ function summarizeMemberSpawnStatusRecord(
runtimeProcessPendingCount += 1; runtimeProcessPendingCount += 1;
} else if (entry.livenessKind === 'runtime_process_candidate') { } else if (entry.livenessKind === 'runtime_process_candidate') {
runtimeCandidatePendingCount += 1; 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; noRuntimePendingCount += 1;
} }
} }
@ -3245,12 +3323,13 @@ function updateProgress(
function buildLaunchDiagnosticsFromRun( function buildLaunchDiagnosticsFromRun(
run: ProvisioningRun run: ProvisioningRun
): TeamLaunchDiagnosticItem[] | undefined { ): TeamLaunchDiagnosticItem[] | undefined {
if (!run.isLaunch || run.memberSpawnStatuses.size === 0) { const memberSpawnStatuses = run.memberSpawnStatuses;
if (!run.isLaunch || !memberSpawnStatuses || memberSpawnStatuses.size === 0) {
return undefined; return undefined;
} }
const observedAt = nowIso(); const observedAt = nowIso();
const items: TeamLaunchDiagnosticItem[] = []; const items: TeamLaunchDiagnosticItem[] = [];
for (const [memberName, entry] of run.memberSpawnStatuses.entries()) { for (const [memberName, entry] of memberSpawnStatuses.entries()) {
if (entry.launchState === 'confirmed_alive') { if (entry.launchState === 'confirmed_alive') {
items.push({ items.push({
id: `${memberName}:bootstrap_confirmed`, id: `${memberName}:bootstrap_confirmed`,
@ -3286,6 +3365,18 @@ function buildLaunchDiagnosticsFromRun(
}); });
continue; 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') { if (entry.livenessKind === 'shell_only') {
items.push({ items.push({
id: `${memberName}:tmux_shell_only`, id: `${memberName}:tmux_shell_only`,
@ -3322,6 +3413,22 @@ function buildLaunchDiagnosticsFromRun(
}); });
continue; 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) { if (entry.agentToolAccepted) {
items.push({ items.push({
id: `${memberName}:spawn_accepted`, id: `${memberName}:spawn_accepted`,
@ -3679,7 +3786,12 @@ export class TeamProvisioningService {
private readonly runtimeAdapterProgressByRunId = new Map<string, TeamProvisioningProgress>(); private readonly runtimeAdapterProgressByRunId = new Map<string, TeamProvisioningProgress>();
private readonly runtimeAdapterRunByTeam = new Map< private readonly runtimeAdapterRunByTeam = new Map<
string, string,
{ runId: string; providerId: TeamProviderId; cwd?: string } {
runId: string;
providerId: TeamProviderId;
cwd?: string;
members?: Record<string, TeamRuntimeMemberLaunchEvidence>;
}
>(); >();
private readonly cancelledRuntimeAdapterRunIds = new Set<string>(); private readonly cancelledRuntimeAdapterRunIds = new Set<string>();
private stopAllTeamsGeneration = 0; private stopAllTeamsGeneration = 0;
@ -5882,7 +5994,10 @@ export class TeamProvisioningService {
}, },
diagnostics: mergeRuntimeDiagnostics( diagnostics: mergeRuntimeDiagnostics(
previousMember?.diagnostics, previousMember?.diagnostics,
input.diagnostics, [
...normalizeRuntimeStringArray(input.diagnostics),
...buildRuntimeToolMetadataDiagnostics(input.metadata),
],
input.reason input.reason
), ),
}; };
@ -6498,17 +6613,13 @@ export class TeamProvisioningService {
} }
this.agentRuntimeSnapshotCache.delete(run.teamName); this.agentRuntimeSnapshotCache.delete(run.teamName);
this.liveTeamAgentRuntimeMetadataCache.delete(run.teamName); this.liveTeamAgentRuntimeMetadataCache.delete(run.teamName);
if (isStrictTeamMemberLivenessMode()) { this.setMemberSpawnStatus(run, spawnedMemberName, 'waiting');
this.setMemberSpawnStatus(run, spawnedMemberName, 'waiting'); this.appendMemberBootstrapDiagnostic(
this.appendMemberBootstrapDiagnostic( run,
run, spawnedMemberName,
spawnedMemberName, 'already_running requires strong runtime verification'
'already_running requires strong runtime verification' );
); void this.reevaluateMemberLaunchStatus(run, spawnedMemberName);
void this.reevaluateMemberLaunchStatus(run, spawnedMemberName);
return;
}
this.setMemberSpawnStatus(run, spawnedMemberName, 'online', undefined, 'process');
} else { } else {
this.setMemberSpawnStatus(run, spawnedMemberName, 'waiting'); this.setMemberSpawnStatus(run, spawnedMemberName, 'waiting');
} }
@ -6924,7 +7035,6 @@ export class TeamProvisioningService {
} }
const updatedAt = nowIso(); const updatedAt = nowIso();
const strictLiveness = isStrictTeamMemberLivenessMode();
const run = runId ? (this.runs.get(runId) ?? null) : null; const run = runId ? (this.runs.get(runId) ?? null) : null;
const persistedTeamMeta = await this.teamMetaStore.getMeta(teamName).catch(() => null); const persistedTeamMeta = await this.teamMetaStore.getMeta(teamName).catch(() => null);
@ -7066,14 +7176,9 @@ export class TeamProvisioningService {
: isSharedOpenCodeHost : isSharedOpenCodeHost
? false ? false
: backendType !== 'in-process'; : backendType !== 'in-process';
const launchSnapshotAlive = const historicalBootstrapConfirmed =
this.isTeamAlive(teamName) && launchMember?.bootstrapConfirmed === true ||
(strictLiveness launchMember?.launchState === 'confirmed_alive';
? launchMember?.bootstrapConfirmed === true ||
launchMember?.launchState === 'confirmed_alive'
: launchMember?.runtimeAlive === true ||
launchMember?.bootstrapConfirmed === true ||
launchMember?.launchState === 'confirmed_alive');
let rssBytes = rssPid ? rssBytesByPid.get(rssPid) : undefined; let rssBytes = rssPid ? rssBytesByPid.get(rssPid) : undefined;
if (rssBytes == null && isSharedOpenCodeHost && typeof rssPid === 'number' && rssPid > 0) { if (rssBytes == null && isSharedOpenCodeHost && typeof rssPid === 'number' && rssPid > 0) {
try { try {
@ -7089,7 +7194,7 @@ export class TeamProvisioningService {
snapshotMembers[memberName] = { snapshotMembers[memberName] = {
memberName, memberName,
alive: liveRuntimeMember?.alive === true || launchSnapshotAlive, alive: liveRuntimeMember?.alive === true,
restartable, restartable,
...(backendType ? { backendType } : {}), ...(backendType ? { backendType } : {}),
...(memberProviderId ? { providerId: memberProviderId } : {}), ...(memberProviderId ? { providerId: memberProviderId } : {}),
@ -7120,6 +7225,7 @@ export class TeamProvisioningService {
...(liveRuntimeMember?.runtimeLastSeenAt ...(liveRuntimeMember?.runtimeLastSeenAt
? { runtimeLastSeenAt: liveRuntimeMember.runtimeLastSeenAt } ? { runtimeLastSeenAt: liveRuntimeMember.runtimeLastSeenAt }
: {}), : {}),
...(historicalBootstrapConfirmed ? { historicalBootstrapConfirmed: true } : {}),
...(liveRuntimeMember?.runtimeDiagnostic ...(liveRuntimeMember?.runtimeDiagnostic
? { runtimeDiagnostic: liveRuntimeMember.runtimeDiagnostic } ? { runtimeDiagnostic: liveRuntimeMember.runtimeDiagnostic }
: {}), : {}),
@ -7593,8 +7699,7 @@ export class TeamProvisioningService {
if (!refreshed) return; if (!refreshed) return;
if ( if (
refreshed.launchState === 'failed_to_start' || refreshed.launchState === 'failed_to_start' ||
refreshed.launchState === 'confirmed_alive' || refreshed.launchState === 'confirmed_alive'
refreshed.runtimeAlive
) { ) {
return; return;
} }
@ -7602,81 +7707,92 @@ export class TeamProvisioningService {
if (!refreshedFirstSpawnAcceptedAt) { if (!refreshedFirstSpawnAcceptedAt) {
return; return;
} }
if (isStrictTeamMemberLivenessMode()) { const restartPending = run.pendingMemberRestarts.has(memberName);
const runtimeByMember = await this.getLiveTeamAgentRuntimeMetadata(run.teamName); const runtimeByMember = await this.getLiveTeamAgentRuntimeMetadata(run.teamName);
const metadata = const metadata =
runtimeByMember.get(memberName) ?? runtimeByMember.get(memberName) ??
[...runtimeByMember.entries()].find(([candidateName]) => [...runtimeByMember.entries()].find(([candidateName]) =>
matchesObservedMemberNameForExpected(candidateName, memberName) matchesObservedMemberNameForExpected(candidateName, memberName)
)?.[1]; )?.[1];
const acceptedAtMs = Date.parse(refreshedFirstSpawnAcceptedAt); const acceptedAtMs = Date.parse(refreshedFirstSpawnAcceptedAt);
const elapsedMs = Number.isFinite(acceptedAtMs) ? Date.now() - acceptedAtMs : Infinity; const elapsedMs = Number.isFinite(acceptedAtMs) ? Date.now() - acceptedAtMs : Infinity;
const runtimeDiagnostic = metadata?.runtimeDiagnostic; const runtimeDiagnostic = metadata?.runtimeDiagnostic;
if (metadata?.livenessKind === 'runtime_process') { if (metadata?.livenessKind === 'runtime_process') {
this.setMemberSpawnStatus(run, memberName, 'online', undefined, 'process'); if (elapsedMs >= MEMBER_BOOTSTRAP_STALL_MS) {
return; run.memberSpawnStatuses.set(memberName, {
}
if (metadata?.livenessKind === 'permission_blocked') {
const next = {
...refreshed, ...refreshed,
livenessKind: metadata.livenessKind, livenessKind: metadata.livenessKind,
runtimeDiagnostic: runtimeDiagnostic ?? 'waiting for permission approval', runtimeDiagnostic: 'Runtime process is alive, but no bootstrap check-in after 5 min.',
runtimeDiagnosticSeverity: metadata.runtimeDiagnosticSeverity ?? 'warning', runtimeDiagnosticSeverity: 'warning',
livenessLastCheckedAt: nowIso(), livenessLastCheckedAt: nowIso(),
launchState: 'runtime_pending_permission' as const, });
};
run.memberSpawnStatuses.set(memberName, next);
this.emitMemberSpawnChange(run, memberName);
return;
} }
if ( this.setMemberSpawnStatus(run, memberName, 'online', undefined, 'process');
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);
return; 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) { if (restartPending) {
run.pendingMemberRestarts.delete(memberName); run.pendingMemberRestarts.delete(memberName);
} }
this.setMemberSpawnStatus( run.memberSpawnStatuses.set(memberName, {
run, ...refreshed,
memberName, runtimeAlive: false,
'error', livenessSource: undefined,
restartPending bootstrapConfirmed: false,
? buildRestartGraceTimeoutReason(memberName) ...(metadata?.livenessKind ? { livenessKind: metadata.livenessKind } : {}),
: 'Teammate did not join within the launch grace window.' ...(runtimeDiagnostic ? { runtimeDiagnostic } : {}),
); ...(metadata?.runtimeDiagnosticSeverity
? { runtimeDiagnosticSeverity: metadata.runtimeDiagnosticSeverity }
: {}),
livenessLastCheckedAt: nowIso(),
});
this.setMemberSpawnStatus(run, memberName, 'error', strictReason);
} }
private shouldSkipMemberSpawnAudit(run: ProvisioningRun): boolean { private shouldSkipMemberSpawnAudit(run: ProvisioningRun): boolean {
@ -10152,6 +10268,7 @@ export class TeamProvisioningService {
runId, runId,
providerId: 'opencode', providerId: 'opencode',
cwd: input.request.cwd, cwd: input.request.cwd,
members: result.members,
}); });
this.aliveRunByTeam.set(input.request.teamName, runId); this.aliveRunByTeam.set(input.request.teamName, runId);
} }
@ -11978,7 +12095,6 @@ export class TeamProvisioningService {
async getRuntimeState(teamName: string): Promise<TeamRuntimeState> { async getRuntimeState(teamName: string): Promise<TeamRuntimeState> {
const runId = this.getTrackedRunId(teamName); const runId = this.getTrackedRunId(teamName);
const run = runId ? (this.runs.get(runId) ?? null) : null; const run = runId ? (this.runs.get(runId) ?? null) : null;
const strictLiveness = isStrictTeamMemberLivenessMode();
if (!run) { if (!run) {
const recovered = await readBootstrapRuntimeState(teamName); const recovered = await readBootstrapRuntimeState(teamName);
@ -12221,6 +12337,11 @@ export class TeamProvisioningService {
// Read config.json to get the actual registered members // Read config.json to get the actual registered members
const registeredNames = await this.getRegisteredTeamMemberNames(run.teamName); const registeredNames = await this.getRegisteredTeamMemberNames(run.teamName);
if (!registeredNames) { if (!registeredNames) {
try {
await fs.promises.access(path.join(getTeamsBasePath(), run.teamName));
} catch {
return;
}
const now = Date.now(); const now = Date.now();
if ( if (
shouldWarnOnUnreadableMemberAuditConfig({ shouldWarnOnUnreadableMemberAuditConfig({
@ -12351,7 +12472,6 @@ export class TeamProvisioningService {
statuses: Record<string, MemberSpawnStatusEntry> statuses: Record<string, MemberSpawnStatusEntry>
): Promise<Record<string, MemberSpawnStatusEntry>> { ): Promise<Record<string, MemberSpawnStatusEntry>> {
const runtimeByMember = await this.getLiveTeamAgentRuntimeMetadata(teamName); const runtimeByMember = await this.getLiveTeamAgentRuntimeMetadata(teamName);
const strictLiveness = isStrictTeamMemberLivenessMode();
const nextStatuses = { ...statuses }; const nextStatuses = { ...statuses };
for (const [memberName, metadata] of runtimeByMember.entries()) { for (const [memberName, metadata] of runtimeByMember.entries()) {
const resolvedStatusKey = const resolvedStatusKey =
@ -12370,20 +12490,23 @@ export class TeamProvisioningService {
if (!current) { if (!current) {
continue; continue;
} }
const runtimeDiagnostic = buildRuntimeDiagnosticForSpawn(metadata);
const nextEntry: MemberSpawnStatusEntry = { const nextEntry: MemberSpawnStatusEntry = {
...current, ...current,
...(metadata.model ? { runtimeModel: metadata.model } : {}), ...(metadata.model ? { runtimeModel: metadata.model } : {}),
...(metadata.livenessKind ? { livenessKind: metadata.livenessKind } : {}), ...(metadata.livenessKind ? { livenessKind: metadata.livenessKind } : {}),
...(metadata.runtimeDiagnostic ? { runtimeDiagnostic: metadata.runtimeDiagnostic } : {}), ...(runtimeDiagnostic ? { runtimeDiagnostic } : {}),
...(metadata.runtimeDiagnosticSeverity ...(metadata.runtimeDiagnosticSeverity
? { runtimeDiagnosticSeverity: metadata.runtimeDiagnosticSeverity } ? { runtimeDiagnosticSeverity: metadata.runtimeDiagnosticSeverity }
: {}), : {}),
livenessLastCheckedAt: nowIso(), livenessLastCheckedAt: nowIso(),
}; };
const failureReason = current.hardFailureReason ?? current.error; const failureReason = current.hardFailureReason ?? current.error;
const hasStrongEvidence = strictLiveness const hasStrongEvidence = isStrongRuntimeEvidence(metadata);
? isStrongRuntimeEvidence(metadata) const hasWeakEvidence =
: metadata.alive === true; metadata.livenessKind != null &&
!isStrongRuntimeEvidence(metadata) &&
current.bootstrapConfirmed !== true;
if ( if (
hasStrongEvidence && hasStrongEvidence &&
current.hardFailure !== true && current.hardFailure !== true &&
@ -12412,6 +12535,26 @@ export class TeamProvisioningService {
nextEntry.livenessSource = current.bootstrapConfirmed ? current.livenessSource : 'process'; nextEntry.livenessSource = current.bootstrapConfirmed ? current.livenessSource : 'process';
nextEntry.launchState = deriveMemberLaunchState(nextEntry); 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; nextStatuses[resolvedStatusKey] = nextEntry;
} }
return nextStatuses; return nextStatuses;
@ -12669,7 +12812,6 @@ export class TeamProvisioningService {
if (cached && cached.expiresAtMs > Date.now()) { if (cached && cached.expiresAtMs > Date.now()) {
return cached.metadata; return cached.metadata;
} }
const strictLiveness = isStrictTeamMemberLivenessMode();
const runId = this.getTrackedRunId(teamName); const runId = this.getTrackedRunId(teamName);
const run = runId ? (this.runs.get(runId) ?? null) : null; const run = runId ? (this.runs.get(runId) ?? null) : null;
@ -12817,7 +12959,7 @@ export class TeamProvisioningService {
upsertMetadata(memberName, { upsertMetadata(memberName, {
backendType: 'process', backendType: 'process',
providerId: 'opencode', providerId: 'opencode',
alive: evidence?.runtimeAlive === true, alive: false,
livenessKind: evidence?.livenessKind, livenessKind: evidence?.livenessKind,
pidSource: evidence?.pidSource, pidSource: evidence?.pidSource,
runtimeDiagnostic: evidence?.runtimeDiagnostic, 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); const persistedLaunchSnapshot = await this.launchStateStore.read(teamName).catch(() => null);
for (const persistedMember of Object.values(persistedLaunchSnapshot?.members ?? {})) { for (const persistedMember of Object.values(persistedLaunchSnapshot?.members ?? {})) {
const memberName = persistedMember.name?.trim() ?? ''; const memberName = persistedMember.name?.trim() ?? '';
if (!memberName || this.isMemberRemovedInMeta(metaMembers, memberName)) { if (!memberName || this.isMemberRemovedInMeta(metaMembers, memberName)) {
continue; continue;
} }
const currentRuntimeAdapterEvidence = currentRuntimeAdapterRun?.members?.[memberName];
upsertMetadata(memberName, { upsertMetadata(memberName, {
backendType: backendType:
persistedMember.providerId === 'opencode' persistedMember.providerId === 'opencode'
? 'process' ? 'process'
: metadataByMember.get(memberName)?.backendType, : metadataByMember.get(memberName)?.backendType,
providerId: persistedMember.providerId, providerId: persistedMember.providerId,
alive: persistedMember.runtimeAlive === true || persistedMember.bootstrapConfirmed === true, alive: false,
livenessKind: persistedMember.livenessKind, livenessKind: currentRuntimeAdapterEvidence?.livenessKind ?? persistedMember.livenessKind,
pidSource: persistedMember.pidSource, pidSource: currentRuntimeAdapterEvidence?.pidSource ?? persistedMember.pidSource,
runtimeDiagnostic: persistedMember.runtimeDiagnostic, runtimeDiagnostic:
currentRuntimeAdapterEvidence?.runtimeDiagnostic ?? persistedMember.runtimeDiagnostic,
runtimeDiagnosticSeverity: persistedMember.runtimeDiagnosticSeverity, runtimeDiagnosticSeverity: persistedMember.runtimeDiagnosticSeverity,
runtimeLastSeenAt: runtimeLastSeenAt:
persistedMember.runtimeLastSeenAt ?? persistedMember.runtimeLastSeenAt ??
persistedMember.lastHeartbeatAt ?? persistedMember.lastHeartbeatAt ??
persistedMember.lastRuntimeAliveAt, persistedMember.lastRuntimeAliveAt,
...(persistedMember.model?.trim() ? { model: persistedMember.model.trim() } : {}), ...(persistedMember.model?.trim() ? { model: persistedMember.model.trim() } : {}),
...(typeof persistedMember.runtimePid === 'number' && persistedMember.runtimePid > 0 ...(typeof currentRuntimeAdapterEvidence?.runtimePid === 'number' &&
? { metricsPid: persistedMember.runtimePid } currentRuntimeAdapterEvidence.runtimePid > 0
: {}), ? { metricsPid: currentRuntimeAdapterEvidence.runtimePid }
...(persistedMember.runtimeSessionId : typeof persistedMember.runtimePid === 'number' && persistedMember.runtimePid > 0
? { runtimeSessionId: persistedMember.runtimeSessionId } ? { 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()) { for (const [memberName, metadata] of metadataByMember.entries()) {
const paneId = metadata.tmuxPaneId?.trim() ?? ''; const paneId = metadata.tmuxPaneId?.trim() ?? '';
const status = this.findTrackedMemberSpawnStatus(run, memberName);
const launchMember = persistedLaunchSnapshot?.members[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({ const resolved = resolveTeamMemberRuntimeLiveness({
teamName, teamName,
memberName, memberName,
@ -12910,15 +13089,9 @@ export class TeamProvisioningService {
processTableAvailable, processTableAvailable,
nowIso: nowIso(), 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, { metadataByMember.set(memberName, {
...metadata, ...metadata,
alive: strictLiveness ? resolved.alive : legacyWeakAlive, alive: resolved.alive,
...(typeof resolved.pid === 'number' && resolved.pid > 0 ? { pid: resolved.pid } : {}), ...(typeof resolved.pid === 'number' && resolved.pid > 0 ? { pid: resolved.pid } : {}),
...(typeof (resolved.metricsPid ?? metadata.metricsPid) === 'number' && ...(typeof (resolved.metricsPid ?? metadata.metricsPid) === 'number' &&
Number.isFinite(resolved.metricsPid ?? metadata.metricsPid) && Number.isFinite(resolved.metricsPid ?? metadata.metricsPid) &&
@ -13050,7 +13223,11 @@ export class TeamProvisioningService {
runtimeProcessPendingCount += 1; runtimeProcessPendingCount += 1;
} else if (entry.livenessKind === 'runtime_process_candidate') { } else if (entry.livenessKind === 'runtime_process_candidate') {
runtimeCandidatePendingCount += 1; 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; noRuntimePendingCount += 1;
} }
} }
@ -13100,10 +13277,8 @@ export class TeamProvisioningService {
}`; }`;
} }
const stillStartingCount = Math.max( const runtimeProcessPendingCount = launchSummary.runtimeProcessPendingCount ?? 0;
0, const stillStartingCount = Math.max(0, launchSummary.pendingCount - runtimeProcessPendingCount);
launchSummary.pendingCount - launchSummary.runtimeAlivePendingCount
);
const diagnosticParts = [ const diagnosticParts = [
launchSummary.shellOnlyPendingCount launchSummary.shellOnlyPendingCount
? `${launchSummary.shellOnlyPendingCount} shell-only` ? `${launchSummary.shellOnlyPendingCount} shell-only`
@ -13121,16 +13296,15 @@ export class TeamProvisioningService {
const diagnosticSuffix = diagnosticParts.length > 0 ? ` - ${diagnosticParts.join(', ')}` : ''; const diagnosticSuffix = diagnosticParts.length > 0 ? ` - ${diagnosticParts.join(', ')}` : '';
if (launchSummary.confirmedCount === 0) { if (launchSummary.confirmedCount === 0) {
const allRuntimeAlive = const allRuntimeAlive =
launchSummary.runtimeAlivePendingCount > 0 && runtimeProcessPendingCount > 0 && runtimeProcessPendingCount === expectedTeammateCount;
launchSummary.runtimeAlivePendingCount === expectedTeammateCount;
return allRuntimeAlive return allRuntimeAlive
? `${prefix} — teammates online` ? `${prefix} — teammates online`
: launchSummary.runtimeAlivePendingCount > 0 : runtimeProcessPendingCount > 0
? `${prefix}${launchSummary.runtimeAlivePendingCount}/${expectedTeammateCount} teammate${launchSummary.runtimeAlivePendingCount === 1 ? '' : 's'} online${stillStartingCount > 0 ? `, ${stillStartingCount} still starting` : ''}` ? `${prefix}${runtimeProcessPendingCount}/${expectedTeammateCount} teammate${runtimeProcessPendingCount === 1 ? '' : 's'} online${stillStartingCount > 0 ? `, ${stillStartingCount} still starting` : ''}`
: `${prefix} — teammates are still starting${diagnosticSuffix}`; : `${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( private buildAggregatePendingLaunchMessage(
@ -13141,6 +13315,7 @@ export class TeamProvisioningService {
pendingCount: number; pendingCount: number;
failedCount: number; failedCount: number;
runtimeAlivePendingCount: number; runtimeAlivePendingCount: number;
runtimeProcessPendingCount?: number;
}, },
snapshot?: PersistedTeamLaunchSnapshot | null snapshot?: PersistedTeamLaunchSnapshot | null
): string { ): string {
@ -13554,10 +13729,14 @@ export class TeamProvisioningService {
if (filteredSnapshot.teamLaunchState === 'clean_success' && launchPhase !== 'active') { if (filteredSnapshot.teamLaunchState === 'clean_success' && launchPhase !== 'active') {
await this.clearPersistedLaunchState(run.teamName); await this.clearPersistedLaunchState(run.teamName);
this.agentRuntimeSnapshotCache.delete(run.teamName);
this.liveTeamAgentRuntimeMetadataCache.delete(run.teamName);
return null; return null;
} }
await this.launchStateStore.write(run.teamName, filteredSnapshot); await this.launchStateStore.write(run.teamName, filteredSnapshot);
this.agentRuntimeSnapshotCache.delete(run.teamName);
this.liveTeamAgentRuntimeMetadataCache.delete(run.teamName);
return filteredSnapshot; 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 nextMembers = { ...filteredPersisted.members };
const persistedMemberNames = this.getPersistedLaunchMemberNames(filteredPersisted); const persistedMemberNames = this.getPersistedLaunchMemberNames(filteredPersisted);
const now = nowIso(); const now = nowIso();
@ -14262,11 +14441,6 @@ export class TeamProvisioningService {
current.firstSpawnAcceptedAt = current.firstSpawnAcceptedAt =
current.firstSpawnAcceptedAt ?? bootstrapMember.firstSpawnAcceptedAt; current.firstSpawnAcceptedAt ?? bootstrapMember.firstSpawnAcceptedAt;
} }
if (bootstrapMember?.runtimeAlive && !current.runtimeAlive) {
current.runtimeAlive = true;
current.lastRuntimeAliveAt =
current.lastRuntimeAliveAt ?? bootstrapMember.lastRuntimeAliveAt;
}
if (bootstrapMember?.bootstrapConfirmed && !current.bootstrapConfirmed) { if (bootstrapMember?.bootstrapConfirmed && !current.bootstrapConfirmed) {
current.bootstrapConfirmed = true; current.bootstrapConfirmed = true;
current.lastHeartbeatAt = current.lastHeartbeatAt ?? bootstrapMember.lastHeartbeatAt; current.lastHeartbeatAt = current.lastHeartbeatAt ?? bootstrapMember.lastHeartbeatAt;
@ -14274,10 +14448,13 @@ export class TeamProvisioningService {
const matchedConfigNames = [...configMembers].filter((name) => const matchedConfigNames = [...configMembers].filter((name) =>
matchesObservedMemberNameForExpected(name, expected) matchesObservedMemberNameForExpected(name, expected)
); );
const observedRuntimeAlive = [...liveAgentNames].some((name) => const runtimeMetadataCandidates = [...liveRuntimeByMember.entries()].filter(([name]) =>
matchesObservedMemberNameForExpected(name, expected) 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( const heartbeatMessage = this.selectLatestLeadInboxLaunchReconcileMessage(
leadInboxMessages, leadInboxMessages,
persistedMemberNames, persistedMemberNames,
@ -14289,11 +14466,15 @@ export class TeamProvisioningService {
: null; : null;
const acceptedAtMs = const acceptedAtMs =
current.firstSpawnAcceptedAt != null ? Date.parse(current.firstSpawnAcceptedAt) : NaN; current.firstSpawnAcceptedAt != null ? Date.parse(current.firstSpawnAcceptedAt) : NaN;
current.runtimeAlive = runtimeAlive; current.runtimeAlive = observedRuntimeAlive;
current.lastRuntimeAliveAt = observedRuntimeAlive ? now : current.lastRuntimeAliveAt; 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 = {
...(current.sources ?? {}), ...(current.sources ?? {}),
processAlive: runtimeAlive || undefined, processAlive: observedRuntimeAlive || undefined,
configRegistered: matchedConfigNames.length > 0 || undefined, configRegistered: matchedConfigNames.length > 0 || undefined,
configDrift: configDrift:
heartbeatMessage != null && matchedConfigNames.length === 0 heartbeatMessage != null && matchedConfigNames.length === 0
@ -15050,13 +15231,14 @@ export class TeamProvisioningService {
run.processKilled = true; run.processKilled = true;
run.cancelRequested = true; run.cancelRequested = true;
killTeamProcess(run.child); killTeamProcess(run.child);
if (this.hasSecondaryRuntimeRuns(teamName)) { const stopSecondaryRuntimeLanes = this.hasSecondaryRuntimeRuns(teamName)
await this.stopMixedSecondaryRuntimeLanes(teamName); ? this.stopMixedSecondaryRuntimeLanes(teamName)
} : null;
const progress = updateProgress(run, 'disconnected', 'Team stopped by user'); const progress = updateProgress(run, 'disconnected', 'Team stopped by user');
run.onProgress(progress); run.onProgress(progress);
this.cleanupRun(run); this.cleanupRun(run);
logger.info(`[${teamName}] Process stopped (SIGKILL)`); logger.info(`[${teamName}] Process stopped (SIGKILL)`);
await stopSecondaryRuntimeLanes;
} }
private getShutdownTrackedTeamNames(): string[] { private getShutdownTrackedTeamNames(): string[] {
@ -15448,8 +15630,8 @@ export class TeamProvisioningService {
killTrackedCliProcesses('SIGKILL'); killTrackedCliProcesses('SIGKILL');
this.killTransientProbeProcessesForShutdown(); this.killTransientProbeProcessesForShutdown();
await this.cancelPendingRuntimeAdapterLaunchesForShutdown();
const initialTracked = await this.stopTrackedTeamsForShutdown('Shutdown'); const initialTracked = await this.stopTrackedTeamsForShutdown('Shutdown');
await this.cancelPendingRuntimeAdapterLaunchesForShutdown();
// A create/launch may have been inside a per-team lock before it exposed a // 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 // run in provisioningRunByTeam. Wait briefly, then rescan to catch anything
@ -15584,7 +15766,15 @@ export class TeamProvisioningService {
); );
return true; 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; 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 { function hasPersistedEvidence(input: ResolveTeamMemberRuntimeLivenessInput): boolean {
return Boolean( return Boolean(
input.agentId?.trim() || input.agentId?.trim() ||
@ -186,18 +190,6 @@ export function resolveTeamMemberRuntimeLiveness(
diagnostics.push('process table unavailable'); 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 ( if (
tracked?.launchState === 'runtime_pending_permission' || tracked?.launchState === 'runtime_pending_permission' ||
(tracked?.pendingPermissionRequestIds?.length ?? 0) > 0 (tracked?.pendingPermissionRequestIds?.length ?? 0) > 0
@ -236,15 +228,44 @@ export function resolveTeamMemberRuntimeLiveness(
? input.processRows.find((row) => row.pid === runtimePid) ? input.processRows.find((row) => row.pid === runtimePid)
: undefined; : undefined;
if (runtimePidRow && input.providerId === 'opencode') { 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({ return result({
alive: true, alive: false,
livenessKind: 'runtime_process', livenessKind: 'runtime_process_candidate',
pidSource: 'opencode_bridge', pidSource: 'opencode_bridge',
pid: runtimePidRow.pid, pid: runtimePidRow.pid,
runtimeSessionId, runtimeSessionId,
processCommand: sanitizeProcessCommandForDiagnostics(runtimePidRow.command), processCommand,
runtimeDiagnostic: 'OpenCode runtime process detected', runtimeDiagnostic: 'OpenCode runtime pid is alive, but process identity is unverified',
diagnostics: [...diagnostics, 'matched OpenCode runtime pid in process table'], 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 (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({ return result({
alive: false, alive: false,
livenessKind: 'stale_metadata', livenessKind: 'stale_metadata',

View file

@ -170,6 +170,11 @@ export class TeamTaskReader {
completedAt: i.completedAt, completedAt: i.completedAt,
})) }))
: undefined; : 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 = { const task: TeamTask = {
id: id:
typeof parsed.id === 'string' || typeof parsed.id === 'number' ? String(parsed.id) : '', typeof parsed.id === 'string' || typeof parsed.id === 'number' ? String(parsed.id) : '',
@ -192,11 +197,7 @@ export class TeamTaskReader {
promptTaskRefs: normalizeTaskRefs(parsed.promptTaskRefs), promptTaskRefs: normalizeTaskRefs(parsed.promptTaskRefs),
owner: typeof parsed.owner === 'string' ? parsed.owner : undefined, owner: typeof parsed.owner === 'string' ? parsed.owner : undefined,
createdBy: typeof parsed.createdBy === 'string' ? parsed.createdBy : undefined, createdBy: typeof parsed.createdBy === 'string' ? parsed.createdBy : undefined,
status: (['pending', 'in_progress', 'completed', 'deleted'] as const).includes( status,
parsed.status as TeamTask['status']
)
? (parsed.status as TeamTask['status'])
: 'pending',
workIntervals, workIntervals,
historyEvents, historyEvents,
blocks: Array.isArray(parsed.blocks) blocks: Array.isArray(parsed.blocks)
@ -299,6 +300,7 @@ export class TeamTaskReader {
reviewState: getReviewStateFromTask({ reviewState: getReviewStateFromTask({
historyEvents, historyEvents,
reviewState: parsed.reviewState as TeamTask['reviewState'], reviewState: parsed.reviewState as TeamTask['reviewState'],
status,
}), }),
sourceMessageId: sourceMessageId:
typeof parsed.sourceMessageId === 'string' && parsed.sourceMessageId.trim() typeof parsed.sourceMessageId === 'string' && parsed.sourceMessageId.trim()
@ -413,6 +415,7 @@ export class TeamTaskReader {
createdAt: typeof parsed.createdAt === 'string' ? parsed.createdAt : undefined, createdAt: typeof parsed.createdAt === 'string' ? parsed.createdAt : undefined,
reviewState: getReviewStateFromTask({ reviewState: getReviewStateFromTask({
reviewState: parsed.reviewState as TeamTask['reviewState'], 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'; import type { CliFlavor, CliFlavorUiOptions } from '@shared/types';
export const DEFAULT_CLI_FLAVOR: CliFlavor = 'agent_teams_orchestrator'; export const DEFAULT_CLI_FLAVOR: CliFlavor = 'agent_teams_orchestrator';
@ -18,8 +16,7 @@ export function getConfiguredCliFlavor(): CliFlavor {
return envOverride; return envOverride;
} }
const multimodelEnabled = configManager.getConfig().general.multimodelEnabled; return DEFAULT_CLI_FLAVOR;
return multimodelEnabled ? 'agent_teams_orchestrator' : 'claude';
} }
export function getCliFlavorUiOptions(flavor: CliFlavor): CliFlavorUiOptions { 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_OUTPUT_TAIL_PARTS = 20;
export const PROGRESS_LAUNCH_DIAGNOSTICS_LIMIT = 20; export const PROGRESS_LAUNCH_DIAGNOSTICS_LIMIT = 20;
const PROGRESS_LAUNCH_DIAGNOSTIC_TEXT_LIMIT = 500; 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" * 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) { if (!trimmed) {
return undefined; return undefined;
} }
return trimmed.length > PROGRESS_LAUNCH_DIAGNOSTIC_TEXT_LIMIT const redacted = trimmed.replace(SECRET_FLAG_PATTERN, '$1[redacted]');
? `${trimmed.slice(0, PROGRESS_LAUNCH_DIAGNOSTIC_TEXT_LIMIT - 3).trimEnd()}...` return redacted.length > PROGRESS_LAUNCH_DIAGNOSTIC_TEXT_LIMIT
: trimmed; ? `${redacted.slice(0, PROGRESS_LAUNCH_DIAGNOSTIC_TEXT_LIMIT - 3).trimEnd()}...`
: redacted;
} }
export function boundLaunchDiagnostics( export function boundLaunchDiagnostics(

View file

@ -302,7 +302,7 @@ export class OpenCodeTeamRuntimeAdapter implements TeamLaunchRuntimeAdapter {
providerId: this.providerId, providerId: this.providerId,
launchState: member.launchState, launchState: member.launchState,
agentToolAccepted: member.agentToolAccepted, agentToolAccepted: member.agentToolAccepted,
runtimeAlive: member.runtimeAlive, runtimeAlive: member.bootstrapConfirmed === true,
bootstrapConfirmed: member.bootstrapConfirmed, bootstrapConfirmed: member.bootstrapConfirmed,
hardFailure: member.hardFailure, hardFailure: member.hardFailure,
hardFailureReason: member.hardFailureReason, hardFailureReason: member.hardFailureReason,
@ -544,7 +544,6 @@ function mapBridgeMemberToRuntimeEvidence(
diagnostics: string[] diagnostics: string[]
): TeamRuntimeMemberLaunchEvidence { ): TeamRuntimeMemberLaunchEvidence {
const confirmed = launchState === 'confirmed_alive'; const confirmed = launchState === 'confirmed_alive';
const createdOrBlocked = launchState === 'created' || launchState === 'permission_blocked';
const failed = launchState === 'failed'; const failed = launchState === 'failed';
const hasRuntimePid = const hasRuntimePid =
typeof runtimePid === 'number' && Number.isFinite(runtimePid) && runtimePid > 0; typeof runtimePid === 'number' && Number.isFinite(runtimePid) && runtimePid > 0;
@ -552,14 +551,14 @@ function mapBridgeMemberToRuntimeEvidence(
const livenessKind = confirmed const livenessKind = confirmed
? 'confirmed_bootstrap' ? 'confirmed_bootstrap'
: pendingRuntimeObserved : pendingRuntimeObserved
? 'runtime_process' ? 'runtime_process_candidate'
: launchState === 'permission_blocked' : launchState === 'permission_blocked'
? 'permission_blocked' ? 'permission_blocked'
: runtimeMaterialized || sessionId : runtimeMaterialized || sessionId
? 'runtime_process_candidate' ? 'runtime_process_candidate'
: 'registered_only'; : 'registered_only';
const runtimeDiagnostic = pendingRuntimeObserved const runtimeDiagnostic = pendingRuntimeObserved
? 'OpenCode runtime process reported by bridge' ? 'OpenCode runtime pid reported by bridge without local process verification'
: launchState === 'permission_blocked' : launchState === 'permission_blocked'
? 'OpenCode runtime is waiting for permission approval' ? 'OpenCode runtime is waiting for permission approval'
: runtimeMaterialized || sessionId : runtimeMaterialized || sessionId
@ -575,8 +574,13 @@ function mapBridgeMemberToRuntimeEvidence(
: launchState === 'permission_blocked' : launchState === 'permission_blocked'
? 'runtime_pending_permission' ? 'runtime_pending_permission'
: 'runtime_pending_bootstrap', : 'runtime_pending_bootstrap',
agentToolAccepted: confirmed || createdOrBlocked || runtimeMaterialized, agentToolAccepted:
runtimeAlive: confirmed || pendingRuntimeObserved, confirmed ||
pendingRuntimeObserved ||
launchState === 'permission_blocked' ||
runtimeMaterialized ||
Boolean(sessionId),
runtimeAlive: confirmed,
bootstrapConfirmed: confirmed, bootstrapConfirmed: confirmed,
hardFailure: failed, hardFailure: failed,
hardFailureReason: failed ? 'OpenCode bridge reported member launch failure' : undefined, 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 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 // Ensure leading dash for absolute paths
return encoded.startsWith('-') ? encoded : `-${encoded}`; 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" // Legacy Windows format observed in some Claude installs: "C--Users-name-project"
// (no leading dash, drive separator encoded as "--"). // (no leading dash, drive separator encoded as "--").
const legacyWindowsRegex = /^([a-zA-Z])--(.+)$/; const legacyWindowsRegex = /^([a-zA-Z])--(.*)$/;
const legacyWindowsMatch = legacyWindowsRegex.exec(encodedName); const legacyWindowsMatch = legacyWindowsRegex.exec(encodedName);
if (legacyWindowsMatch) { if (legacyWindowsMatch) {
const drive = legacyWindowsMatch[1].toUpperCase(); const drive = legacyWindowsMatch[1].toUpperCase();

View file

@ -97,6 +97,13 @@ interface TaskReadDiag {
const MAX_LAUNCH_STATE_BYTES = 32 * 1024; const MAX_LAUNCH_STATE_BYTES = 32 * 1024;
const TEAM_LAUNCH_STATE_FILE = 'launch-state.json'; const TEAM_LAUNCH_STATE_FILE = 'launch-state.json';
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) // Parsed JSON types (loose shapes from disk)
@ -743,28 +750,66 @@ function normalizeHistoryEvents(parsed: ParsedTask): RawHistoryEvent[] | undefin
.map((i) => ({ ...i })); .map((i) => ({ ...i }));
} }
/** Derive review state from historyEvents (inline reducer for worker isolation). */ function normalizeReviewState(value: unknown): string {
function deriveReviewStateFromEvents(events: RawHistoryEvent[] | undefined): string { return value === 'review' || value === 'needsFix' || value === 'approved' ? value : 'none';
if (!Array.isArray(events) || events.length === 0) return 'none'; }
for (let i = events.length - 1; i >= 0; i--) {
const e = events[i]; function normalizeFallbackReviewState(value: unknown, status: string): string {
const t = e.type; const reviewState = normalizeReviewState(value);
if ( if (reviewState === 'none') return 'none';
t === 'review_requested' || if (status === 'in_progress' || status === 'deleted') return 'none';
t === 'review_changes_requested' || if (status === 'pending') return reviewState === 'needsFix' ? 'needsFix' : 'none';
t === 'review_approved' || if (status === 'completed') {
t === 'review_started' return reviewState === 'review' || reviewState === 'approved' ? reviewState : 'none';
) { }
const to = typeof e.to === 'string' ? e.to : 'none'; return reviewState;
return to === 'review' || to === 'needsFix' || to === 'approved' ? to : 'none'; }
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';
} }
} }
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 { function normalizeComments(parsed: ParsedTask): unknown[] | undefined {
if (!Array.isArray(parsed.comments)) return undefined; if (!Array.isArray(parsed.comments)) return undefined;
return (parsed.comments as unknown[]) return (parsed.comments as unknown[])
@ -869,7 +914,16 @@ async function readTasksDirForTeam(
? (parsed.needsClarification as string) ? (parsed.needsClarification as string)
: undefined; : undefined;
const historyEvents = normalizeHistoryEvents(parsed); 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({ tasks.push({
id: typeof parsed.id === 'string' || typeof parsed.id === 'number' ? String(parsed.id) : '', id: typeof parsed.id === 'string' || typeof parsed.id === 'number' ? String(parsed.id) : '',
@ -896,13 +950,7 @@ async function readTasksDirForTeam(
: undefined, : undefined,
owner: typeof parsed.owner === 'string' ? parsed.owner : undefined, owner: typeof parsed.owner === 'string' ? parsed.owner : undefined,
createdBy: typeof parsed.createdBy === 'string' ? parsed.createdBy : undefined, createdBy: typeof parsed.createdBy === 'string' ? parsed.createdBy : undefined,
status: status,
parsed.status === 'pending' ||
parsed.status === 'in_progress' ||
parsed.status === 'completed' ||
parsed.status === 'deleted'
? (parsed.status as string)
: 'pending',
workIntervals: normalizeWorkIntervals(parsed), workIntervals: normalizeWorkIntervals(parsed),
historyEvents: normalizeHistoryEvents(parsed), historyEvents: normalizeHistoryEvents(parsed),
blocks: Array.isArray(parsed.blocks) ? (parsed.blocks as unknown[]) : undefined, 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 { ProviderModelBadges } from '@renderer/components/runtime/ProviderModelBadges';
import { getProviderRuntimeBackendSummary } from '@renderer/components/runtime/ProviderRuntimeBackendSelector'; import { getProviderRuntimeBackendSummary } from '@renderer/components/runtime/ProviderRuntimeBackendSelector';
import { ProviderRuntimeSettingsDialog } from '@renderer/components/runtime/ProviderRuntimeSettingsDialog'; import { ProviderRuntimeSettingsDialog } from '@renderer/components/runtime/ProviderRuntimeSettingsDialog';
import { SettingsToggle } from '@renderer/components/settings/components';
import { TerminalLogPanel } from '@renderer/components/terminal/TerminalLogPanel'; import { TerminalLogPanel } from '@renderer/components/terminal/TerminalLogPanel';
import { TerminalModal } from '@renderer/components/terminal/TerminalModal'; import { TerminalModal } from '@renderer/components/terminal/TerminalModal';
import { useCliInstaller } from '@renderer/hooks/useCliInstaller'; import { useCliInstaller } from '@renderer/hooks/useCliInstaller';
@ -265,11 +264,8 @@ interface InstalledBannerProps {
cliStatusError: string | null; cliStatusError: string | null;
providersCollapsed: boolean; providersCollapsed: boolean;
isBusy: boolean; isBusy: boolean;
multimodelEnabled: boolean;
multimodelBusy: boolean;
onInstall: () => void; onInstall: () => void;
onRefresh: () => void; onRefresh: () => void;
onMultimodelToggle: (enabled: boolean) => void;
onToggleProvidersCollapsed: () => void; onToggleProvidersCollapsed: () => void;
onProviderLogin: (providerId: CliProviderId) => void; onProviderLogin: (providerId: CliProviderId) => void;
onProviderLogout: (providerId: CliProviderId) => void; onProviderLogout: (providerId: CliProviderId) => void;
@ -573,11 +569,8 @@ const InstalledBanner = ({
cliStatusError, cliStatusError,
providersCollapsed, providersCollapsed,
isBusy, isBusy,
multimodelEnabled,
multimodelBusy,
onInstall, onInstall,
onRefresh, onRefresh,
onMultimodelToggle,
onToggleProvidersCollapsed, onToggleProvidersCollapsed,
onProviderLogin, onProviderLogin,
onProviderLogout, onProviderLogout,
@ -683,23 +676,6 @@ const InstalledBanner = ({
<span className="text-xs font-medium" style={{ color: 'var(--color-text-secondary)' }}> <span className="text-xs font-medium" style={{ color: 'var(--color-text-secondary)' }}>
Multimodel Multimodel
</span> </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> </div>
{/* Extensions button — available whenever the runtime is installed */} {/* Extensions button — available whenever the runtime is installed */}
{canOpenExtensions && ( {canOpenExtensions && (
@ -1033,7 +1009,6 @@ export const CliStatusBanner = (): React.JSX.Element | null => {
const [manageProviderId, setManageProviderId] = useState<CliProviderId>('anthropic'); const [manageProviderId, setManageProviderId] = useState<CliProviderId>('anthropic');
const [manageDialogOpen, setManageDialogOpen] = useState(false); const [manageDialogOpen, setManageDialogOpen] = useState(false);
const [isVerifyingAuth, setIsVerifyingAuth] = useState(false); const [isVerifyingAuth, setIsVerifyingAuth] = useState(false);
const [isSwitchingFlavor, setIsSwitchingFlavor] = useState(false);
const [showTroubleshoot, setShowTroubleshoot] = useState(false); const [showTroubleshoot, setShowTroubleshoot] = useState(false);
const [providersCollapsed, setProvidersCollapsed] = useState(() => const [providersCollapsed, setProvidersCollapsed] = useState(() =>
loadDashboardCliStatusBannerCollapsed() loadDashboardCliStatusBannerCollapsed()
@ -1147,37 +1122,6 @@ export const CliStatusBanner = (): React.JSX.Element | null => {
})(); })();
}, [bootstrapCliStatus, codexAccount, fetchCliStatus, multimodelEnabled]); }, [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(() => { const recheckAuthState = useCallback(() => {
setIsVerifyingAuth(true); setIsVerifyingAuth(true);
void (async () => { void (async () => {
@ -1422,11 +1366,8 @@ export const CliStatusBanner = (): React.JSX.Element | null => {
cliStatusError={cliStatusError ?? null} cliStatusError={cliStatusError ?? null}
providersCollapsed={providersCollapsed} providersCollapsed={providersCollapsed}
isBusy={isBusy} isBusy={isBusy}
multimodelEnabled={multimodelEnabled}
multimodelBusy={isSwitchingFlavor}
onInstall={handleInstall} onInstall={handleInstall}
onRefresh={handleRefresh} onRefresh={handleRefresh}
onMultimodelToggle={(enabled) => void handleMultimodelToggle(enabled)}
onToggleProvidersCollapsed={handleToggleProvidersCollapsed} onToggleProvidersCollapsed={handleToggleProvidersCollapsed}
onProviderLogin={handleProviderLogin} onProviderLogin={handleProviderLogin}
onProviderLogout={handleProviderLogout} onProviderLogout={handleProviderLogout}
@ -1650,11 +1591,8 @@ export const CliStatusBanner = (): React.JSX.Element | null => {
cliStatusError={cliStatusError ?? null} cliStatusError={cliStatusError ?? null}
providersCollapsed={providersCollapsed} providersCollapsed={providersCollapsed}
isBusy={isBusy} isBusy={isBusy}
multimodelEnabled={multimodelEnabled}
multimodelBusy={isSwitchingFlavor}
onInstall={handleInstall} onInstall={handleInstall}
onRefresh={handleRefresh} onRefresh={handleRefresh}
onMultimodelToggle={(enabled) => void handleMultimodelToggle(enabled)}
onToggleProvidersCollapsed={handleToggleProvidersCollapsed} onToggleProvidersCollapsed={handleToggleProvidersCollapsed}
onProviderLogin={handleProviderLogin} onProviderLogin={handleProviderLogin}
onProviderLogout={handleProviderLogout} onProviderLogout={handleProviderLogout}
@ -1712,11 +1650,8 @@ export const CliStatusBanner = (): React.JSX.Element | null => {
cliStatusError={cliStatusError ?? null} cliStatusError={cliStatusError ?? null}
providersCollapsed={providersCollapsed} providersCollapsed={providersCollapsed}
isBusy={isBusy} isBusy={isBusy}
multimodelEnabled={multimodelEnabled}
multimodelBusy={isSwitchingFlavor}
onInstall={handleInstall} onInstall={handleInstall}
onRefresh={handleRefresh} onRefresh={handleRefresh}
onMultimodelToggle={(enabled) => void handleMultimodelToggle(enabled)}
onToggleProvidersCollapsed={handleToggleProvidersCollapsed} onToggleProvidersCollapsed={handleToggleProvidersCollapsed}
onProviderLogin={handleProviderLogin} onProviderLogin={handleProviderLogin}
onProviderLogout={handleProviderLogout} onProviderLogout={handleProviderLogout}
@ -1934,11 +1869,8 @@ export const CliStatusBanner = (): React.JSX.Element | null => {
cliStatusError={cliStatusError ?? null} cliStatusError={cliStatusError ?? null}
providersCollapsed={providersCollapsed} providersCollapsed={providersCollapsed}
isBusy={isBusy} isBusy={isBusy}
multimodelEnabled={multimodelEnabled}
multimodelBusy={isSwitchingFlavor}
onInstall={handleInstall} onInstall={handleInstall}
onRefresh={handleRefresh} onRefresh={handleRefresh}
onMultimodelToggle={(enabled) => void handleMultimodelToggle(enabled)}
onToggleProvidersCollapsed={handleToggleProvidersCollapsed} onToggleProvidersCollapsed={handleToggleProvidersCollapsed}
onProviderLogin={handleProviderLogin} onProviderLogin={handleProviderLogin}
onProviderLogout={handleProviderLogout} onProviderLogout={handleProviderLogout}

View file

@ -27,7 +27,6 @@ import {
import { ProviderModelBadges } from '@renderer/components/runtime/ProviderModelBadges'; import { ProviderModelBadges } from '@renderer/components/runtime/ProviderModelBadges';
import { getProviderRuntimeBackendSummary } from '@renderer/components/runtime/ProviderRuntimeBackendSelector'; import { getProviderRuntimeBackendSummary } from '@renderer/components/runtime/ProviderRuntimeBackendSelector';
import { ProviderRuntimeSettingsDialog } from '@renderer/components/runtime/ProviderRuntimeSettingsDialog'; import { ProviderRuntimeSettingsDialog } from '@renderer/components/runtime/ProviderRuntimeSettingsDialog';
import { SettingsToggle } from '@renderer/components/settings/components';
import { TerminalModal } from '@renderer/components/terminal/TerminalModal'; import { TerminalModal } from '@renderer/components/terminal/TerminalModal';
import { useCliInstaller } from '@renderer/hooks/useCliInstaller'; import { useCliInstaller } from '@renderer/hooks/useCliInstaller';
import { useStore } from '@renderer/store'; import { useStore } from '@renderer/store';
@ -211,7 +210,6 @@ export const CliStatusSection = (): React.JSX.Element | null => {
} | null>(null); } | null>(null);
const [manageProviderId, setManageProviderId] = useState<CliProviderId>('gemini'); const [manageProviderId, setManageProviderId] = useState<CliProviderId>('gemini');
const [manageDialogOpen, setManageDialogOpen] = useState(false); const [manageDialogOpen, setManageDialogOpen] = useState(false);
const [isSwitchingFlavor, setIsSwitchingFlavor] = useState(false);
const multimodelEnabled = appConfig?.general?.multimodelEnabled ?? true; const multimodelEnabled = appConfig?.general?.multimodelEnabled ?? true;
const loadingCliStatus = const loadingCliStatus =
!cliStatus && cliStatusLoading && multimodelEnabled !cliStatus && cliStatusLoading && multimodelEnabled
@ -323,37 +321,6 @@ export const CliStatusSection = (): React.JSX.Element | null => {
})(); })();
}, [bootstrapCliStatus, fetchCliStatus, invalidateCliStatus, multimodelEnabled]); }, [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( const handleRuntimeBackendChange = useCallback(
async (providerId: CliProviderId, backendId: string) => { async (providerId: CliProviderId, backendId: string) => {
const currentBackends = appConfig?.runtime?.providerBackends ?? { const currentBackends = appConfig?.runtime?.providerBackends ?? {
@ -440,23 +407,6 @@ export const CliStatusSection = (): React.JSX.Element | null => {
> >
Multimodel Multimodel
</span> </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> </div>
{/* Inline action buttons */} {/* Inline action buttons */}
{effectiveCliStatus.supportsSelfUpdate && effectiveCliStatus.updateAvailable ? ( {effectiveCliStatus.supportsSelfUpdate && effectiveCliStatus.updateAvailable ? (

View file

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

View file

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

View file

@ -981,12 +981,12 @@ export const TeamListView = (): React.JSX.Element => {
</div> </div>
{team.teamLaunchState === 'partial_pending' ? ( {team.teamLaunchState === 'partial_pending' ? (
<p className="mt-2 text-[11px] text-amber-300"> <p className="mt-2 text-[11px] text-amber-300">
{team.runtimeAlivePendingCount && team.runtimeAlivePendingCount > 0 {team.runtimeProcessPendingCount && team.runtimeProcessPendingCount > 0
? buildPendingRuntimeSummaryCopy({ ? buildPendingRuntimeSummaryCopy({
confirmedCount: team.confirmedCount, confirmedCount: team.confirmedCount,
expectedMemberCount: team.expectedMemberCount, expectedMemberCount: team.expectedMemberCount,
memberCount: team.memberCount, memberCount: team.memberCount,
runtimeAlivePendingCount: team.runtimeAlivePendingCount, runtimeProcessPendingCount: team.runtimeProcessPendingCount,
includePeriod: true, includePeriod: true,
}) })
: 'Last launch is still reconciling.'} : '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 { AlertTriangle, CheckCircle2, Info, Loader2, X } from 'lucide-react';
import { AdvancedCliSection } from './AdvancedCliSection'; import { AdvancedCliSection } from './AdvancedCliSection';
import { AnthropicFastModeSelector } from './AnthropicFastModeSelector';
import { CodexFastModeSelector } from './CodexFastModeSelector';
import { import {
clearInheritedMemberModelsUnavailableForProvider, clearInheritedMemberModelsUnavailableForProvider,
resolveProviderScopedMemberModel, resolveProviderScopedMemberModel,
@ -1421,10 +1423,12 @@ export const CreateTeamDialog = ({
const summary: string[] = []; const summary: string[] = [];
if (prompt.trim()) summary.push('Lead prompt'); if (prompt.trim()) summary.push('Lead prompt');
if (skipPermissions) summary.push('Auto-approve tools'); if (skipPermissions) summary.push('Auto-approve tools');
if (selectedProviderId === 'anthropic') { if (selectedProviderId === 'anthropic' || selectedProviderId === 'codex') {
if (selectedFastMode === 'on') summary.push('Fast mode'); if (selectedFastMode === 'on') summary.push('Fast mode');
else if (selectedFastMode === 'off') summary.push('Fast disabled'); 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 (worktreeEnabled && worktreeName.trim()) summary.push(`Worktree: ${worktreeName.trim()}`);
if (customArgs.trim()) summary.push('Custom CLI args'); if (customArgs.trim()) summary.push('Custom CLI args');
@ -1721,9 +1725,6 @@ export const CreateTeamDialog = ({
onProviderChange={setSelectedProviderId} onProviderChange={setSelectedProviderId}
onModelChange={setSelectedModel} onModelChange={setSelectedModel}
onEffortChange={setSelectedEffort} onEffortChange={setSelectedEffort}
fastMode={selectedFastMode}
providerFastModeDefault={anthropicProviderFastModeDefault}
onFastModeChange={setSelectedFastMode}
onLimitContextChange={setLimitContext} onLimitContextChange={setLimitContext}
syncModelsWithTeammates={syncModelsWithLead} syncModelsWithTeammates={syncModelsWithLead}
onSyncModelsWithTeammatesChange={handleSyncModelsWithLeadChange} onSyncModelsWithTeammatesChange={handleSyncModelsWithLeadChange}
@ -1732,7 +1733,6 @@ export const CreateTeamDialog = ({
onTeammateWorktreeDefaultChange={setTeammateWorktreeDefault} onTeammateWorktreeDefaultChange={setTeammateWorktreeDefault}
disableGeminiOption={isGeminiUiFrozen()} disableGeminiOption={isGeminiUiFrozen()}
leadModelIssueText={leadModelIssueText} leadModelIssueText={leadModelIssueText}
leadFastModeNotice={anthropicRuntimeNotice}
memberModelIssueById={memberModelIssueById} memberModelIssueById={memberModelIssueById}
headerTop={ headerTop={
<div className="flex items-center gap-2"> <div className="flex items-center gap-2">
@ -1818,6 +1818,47 @@ export const CreateTeamDialog = ({
summary={launchOptionalSummary} summary={launchOptionalSummary}
> >
<div className="space-y-4"> <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"> <div className="space-y-1.5">
<Label htmlFor="team-prompt" className="label-optional"> <Label htmlFor="team-prompt" className="label-optional">
Prompt for team lead (optional) Prompt for team lead (optional)

View file

@ -79,6 +79,7 @@ import {
CheckCircle2, CheckCircle2,
ChevronDown, ChevronDown,
ChevronRight, ChevronRight,
Info,
Loader2, Loader2,
RotateCcw, RotateCcw,
X, X,
@ -1618,10 +1619,12 @@ export const LaunchTeamDialog = (props: LaunchTeamDialogProps): React.JSX.Elemen
summary.push(`Provider: ${getProviderLabel(selectedProviderId)}`); summary.push(`Provider: ${getProviderLabel(selectedProviderId)}`);
if (selectedModel) summary.push(`Model: ${selectedModel}`); if (selectedModel) summary.push(`Model: ${selectedModel}`);
if (selectedEffort) summary.push(`Effort: ${selectedEffort}`); if (selectedEffort) summary.push(`Effort: ${selectedEffort}`);
if (selectedProviderId === 'anthropic') { if (selectedProviderId === 'anthropic' || selectedProviderId === 'codex') {
if (selectedFastMode === 'on') summary.push('Fast mode'); if (selectedFastMode === 'on') summary.push('Fast mode');
else if (selectedFastMode === 'off') summary.push('Fast disabled'); 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 (selectedProviderId === 'anthropic' && limitContext) summary.push('Limited to 200K context');
if (skipPermissions) summary.push('Auto-approve tools'); if (skipPermissions) summary.push('Auto-approve tools');
@ -2263,6 +2266,52 @@ export const LaunchTeamDialog = (props: LaunchTeamDialogProps): React.JSX.Elemen
summary={launchOptionalSummary} summary={launchOptionalSummary}
> >
<div className="space-y-4"> <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 <TeamRosterEditorSection
members={membersDrafts} members={membersDrafts}
onMembersChange={setMembersDrafts} onMembersChange={setMembersDrafts}
@ -2285,13 +2334,10 @@ export const LaunchTeamDialog = (props: LaunchTeamDialogProps): React.JSX.Elemen
providerId={selectedProviderId} providerId={selectedProviderId}
model={selectedModel} model={selectedModel}
effort={(selectedEffort as EffortLevel) || undefined} effort={(selectedEffort as EffortLevel) || undefined}
fastMode={selectedFastMode}
providerFastModeDefault={anthropicProviderFastModeDefault}
limitContext={limitContext} limitContext={limitContext}
onProviderChange={setSelectedProviderId} onProviderChange={setSelectedProviderId}
onModelChange={setSelectedModel} onModelChange={setSelectedModel}
onEffortChange={setSelectedEffort} onEffortChange={setSelectedEffort}
onFastModeChange={setSelectedFastMode}
onLimitContextChange={setLimitContext} onLimitContextChange={setLimitContext}
syncModelsWithTeammates={syncModelsWithLead} syncModelsWithTeammates={syncModelsWithLead}
onSyncModelsWithTeammatesChange={setSyncModelsWithLead} onSyncModelsWithTeammatesChange={setSyncModelsWithLead}
@ -2299,7 +2345,6 @@ export const LaunchTeamDialog = (props: LaunchTeamDialogProps): React.JSX.Elemen
teammateWorktreeDefault={teammateWorktreeDefault} teammateWorktreeDefault={teammateWorktreeDefault}
onTeammateWorktreeDefaultChange={setTeammateWorktreeDefault} onTeammateWorktreeDefaultChange={setTeammateWorktreeDefault}
leadWarningText={leadRuntimeWarningText} leadWarningText={leadRuntimeWarningText}
leadFastModeNotice={anthropicRuntimeNotice}
memberWarningById={memberRuntimeWarningById} memberWarningById={memberRuntimeWarningById}
leadModelIssueText={leadModelIssueText} leadModelIssueText={leadModelIssueText}
memberModelIssueById={memberModelIssueById} memberModelIssueById={memberModelIssueById}

View file

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

View file

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

View file

@ -6,7 +6,16 @@ import { Tabs, TabsContent, TabsList, TabsTrigger } from '@renderer/components/u
import { useMemberStats } from '@renderer/hooks/useMemberStats'; import { useMemberStats } from '@renderer/hooks/useMemberStats';
import { useStore } from '@renderer/store'; import { useStore } from '@renderer/store';
import { selectMemberMessagesForTeamMember } from '@renderer/store/slices/teamSlice'; 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 { isLeadMember } from '@shared/utils/leadDetection';
import { import {
BarChart3, BarChart3,
@ -22,6 +31,7 @@ import { buildMemberActivityEntries } from './memberActivityEntries';
import { MemberDetailHeader } from './MemberDetailHeader'; import { MemberDetailHeader } from './MemberDetailHeader';
import { MemberDetailStats } from './MemberDetailStats'; import { MemberDetailStats } from './MemberDetailStats';
import { type MemberActivityFilter, type MemberDetailTab } from './memberDetailTypes'; import { type MemberActivityFilter, type MemberDetailTab } from './memberDetailTypes';
import { MemberLaunchDiagnosticsButton } from './MemberLaunchDiagnosticsButton';
import { MemberLogsTab } from './MemberLogsTab'; import { MemberLogsTab } from './MemberLogsTab';
import { MemberMessagesTab } from './MemberMessagesTab'; import { MemberMessagesTab } from './MemberMessagesTab';
import { MemberStatsTab } from './MemberStatsTab'; import { MemberStatsTab } from './MemberStatsTab';
@ -50,6 +60,7 @@ interface MemberDetailDialogProps {
leadActivity?: LeadActivityState; leadActivity?: LeadActivityState;
spawnEntry?: MemberSpawnStatusEntry; spawnEntry?: MemberSpawnStatusEntry;
runtimeEntry?: TeamAgentRuntimeEntry; runtimeEntry?: TeamAgentRuntimeEntry;
runtimeRunId?: string | null;
launchParams?: TeamLaunchParams; launchParams?: TeamLaunchParams;
onClose: () => void; onClose: () => void;
onSendMessage: () => void; onSendMessage: () => void;
@ -76,6 +87,7 @@ export const MemberDetailDialog = ({
leadActivity, leadActivity,
spawnEntry, spawnEntry,
runtimeEntry, runtimeEntry,
runtimeRunId,
launchParams, launchParams,
onClose, onClose,
onSendMessage, onSendMessage,
@ -128,10 +140,31 @@ export const MemberDetailDialog = ({
: undefined, : undefined,
[launchParams, member, runtimeEntry, spawnEntry] [launchParams, member, runtimeEntry, spawnEntry]
); );
const memorySourceLabel = getRuntimeMemorySourceLabel(runtimeEntry);
const restartInFlight = const restartInFlight =
spawnEntry?.launchState === 'starting' || spawnEntry?.launchState === 'starting' ||
spawnEntry?.launchState === 'runtime_pending_bootstrap' || spawnEntry?.launchState === 'runtime_pending_bootstrap' ||
spawnEntry?.launchState === 'runtime_pending_permission'; 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(() => { useEffect(() => {
if (!open || !member) { if (!open || !member) {
@ -251,10 +284,23 @@ export const MemberDetailDialog = ({
<DialogFooter> <DialogFooter>
{restartError ? ( {restartError ? (
<div className="mr-auto text-xs text-red-400">{restartError}</div> <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 ? ( ) : runtimeEntry?.pid ? (
<div className="mr-auto text-xs text-[var(--color-text-muted)]"> <div className="mr-auto text-xs text-[var(--color-text-muted)]">
PID {runtimeEntry.pid} PID {runtimeEntry.pid}
{runtimeEntry.pidSource ? ` · ${runtimeEntry.pidSource}` : ''} {memorySourceLabel ? ` · ${memorySourceLabel}` : ''}
</div> </div>
) : ( ) : (
<div className="mr-auto" /> <div className="mr-auto" />

View file

@ -22,6 +22,12 @@ import {
buildMemberLaunchPresentation, buildMemberLaunchPresentation,
displayMemberName, displayMemberName,
} from '@renderer/utils/memberHelpers'; } from '@renderer/utils/memberHelpers';
import {
buildMemberLaunchDiagnosticsPayload,
getMemberLaunchDiagnosticsErrorMessage,
hasMemberLaunchDiagnosticsDetails,
hasMemberLaunchDiagnosticsError,
} from '@renderer/utils/memberLaunchDiagnostics';
import { isLeadMember } from '@shared/utils/leadDetection'; import { isLeadMember } from '@shared/utils/leadDetection';
import { ExternalLink } from 'lucide-react'; import { ExternalLink } from 'lucide-react';
import { useShallow } from 'zustand/react/shallow'; import { useShallow } from 'zustand/react/shallow';
@ -29,6 +35,7 @@ import { useShallow } from 'zustand/react/shallow';
import { getLaunchJoinMilestonesFromMembers, getLaunchJoinState } from '../provisioningSteps'; import { getLaunchJoinMilestonesFromMembers, getLaunchJoinState } from '../provisioningSteps';
import { CurrentTaskIndicator } from './CurrentTaskIndicator'; import { CurrentTaskIndicator } from './CurrentTaskIndicator';
import { MemberLaunchDiagnosticsButton } from './MemberLaunchDiagnosticsButton';
import type { LeadActivityState, TeamTaskWithKanban } from '@shared/types'; import type { LeadActivityState, TeamTaskWithKanban } from '@shared/types';
@ -68,6 +75,7 @@ export const MemberHoverCard = ({
memberSpawnSnapshot, memberSpawnSnapshot,
memberSpawnStatuses, memberSpawnStatuses,
spawnEntry, spawnEntry,
runtimeRunId,
runtimeEntry, runtimeEntry,
leadActivity, leadActivity,
} = useStore( } = useStore(
@ -90,8 +98,11 @@ export const MemberHoverCard = ({
spawnEntry: effectiveTeamName spawnEntry: effectiveTeamName
? s.memberSpawnStatusesByTeam[effectiveTeamName]?.[name] ? s.memberSpawnStatusesByTeam[effectiveTeamName]?.[name]
: undefined, : undefined,
runtimeRunId: effectiveTeamName
? s.teamAgentRuntimeByTeam?.[effectiveTeamName]?.runId
: undefined,
runtimeEntry: effectiveTeamName runtimeEntry: effectiveTeamName
? s.teamAgentRuntimeByTeam[effectiveTeamName]?.members[name] ? s.teamAgentRuntimeByTeam?.[effectiveTeamName]?.members[name]
: undefined, : undefined,
leadActivity: effectiveTeamName ? s.leadActivityByTeam[effectiveTeamName] : undefined, leadActivity: effectiveTeamName ? s.leadActivityByTeam[effectiveTeamName] : undefined,
})) }))
@ -143,6 +154,17 @@ export const MemberHoverCard = ({
launchVisualState === 'stale_runtime' launchVisualState === 'stale_runtime'
? (launchStatusLabel ?? presenceLabel) ? (launchStatusLabel ?? presenceLabel)
: 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 const currentTask: TeamTaskWithKanban | null = member.currentTaskId
? (tasks.find((t) => t.id === member.currentTaskId) ?? null) ? (tasks.find((t) => t.id === member.currentTaskId) ?? null)
: null; : null;
@ -236,18 +258,33 @@ export const MemberHoverCard = ({
</div> </div>
)} )}
{/* Open profile button */} {launchErrorMessage ? (
<button <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">
type="button" <span className="min-w-0 flex-1 truncate" title={launchErrorMessage}>
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)]" {launchErrorMessage}
onClick={(e) => { </span>
e.stopPropagation(); {showCopyDiagnostics ? (
openMemberProfile(member.name); <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"
<ExternalLink size={12} /> />
Open profile ) : null}
</button> </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> </div>
</HoverCardContent> </HoverCardContent>
</HoverCard> </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>; pendingRepliesByMember?: Record<string, number>;
memberSpawnStatuses?: Map<string, MemberSpawnStatusEntry>; memberSpawnStatuses?: Map<string, MemberSpawnStatusEntry>;
memberRuntimeEntries?: Map<string, TeamAgentRuntimeEntry>; memberRuntimeEntries?: Map<string, TeamAgentRuntimeEntry>;
runtimeRunId?: string | null;
isLaunchSettling?: boolean; isLaunchSettling?: boolean;
isTeamAlive?: boolean; isTeamAlive?: boolean;
isTeamProvisioning?: boolean; isTeamProvisioning?: boolean;
@ -192,20 +193,34 @@ function areMemberRuntimeEntriesEquivalent(
if (left.size !== right.size) return false; if (left.size !== right.size) return false;
for (const [key, leftEntry] of left) { for (const [key, leftEntry] of left) {
const rightEntry = right.get(key); const rightEntry = right.get(key);
const leftDiagnostics = leftEntry.diagnostics ?? [];
const rightDiagnostics = rightEntry?.diagnostics ?? [];
if ( if (
leftEntry.memberName !== rightEntry?.memberName || leftEntry.memberName !== rightEntry?.memberName ||
leftEntry.alive !== rightEntry?.alive || leftEntry.alive !== rightEntry?.alive ||
leftEntry.restartable !== rightEntry?.restartable || leftEntry.restartable !== rightEntry?.restartable ||
leftEntry.backendType !== rightEntry?.backendType || 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.pid !== rightEntry?.pid ||
leftEntry.runtimeModel !== rightEntry?.runtimeModel || leftEntry.runtimeModel !== rightEntry?.runtimeModel ||
leftEntry.rssBytes !== rightEntry?.rssBytes || leftEntry.rssBytes !== rightEntry?.rssBytes ||
leftEntry.livenessKind !== rightEntry?.livenessKind || leftEntry.livenessKind !== rightEntry?.livenessKind ||
leftEntry.pidSource !== rightEntry?.pidSource || leftEntry.pidSource !== rightEntry?.pidSource ||
leftEntry.processCommand !== rightEntry?.processCommand ||
leftEntry.paneId !== rightEntry?.paneId ||
leftEntry.panePid !== rightEntry?.panePid ||
leftEntry.paneCurrentCommand !== rightEntry?.paneCurrentCommand || leftEntry.paneCurrentCommand !== rightEntry?.paneCurrentCommand ||
leftEntry.runtimePid !== rightEntry?.runtimePid ||
leftEntry.runtimeSessionId !== rightEntry?.runtimeSessionId ||
leftEntry.runtimeDiagnostic !== rightEntry?.runtimeDiagnostic || leftEntry.runtimeDiagnostic !== rightEntry?.runtimeDiagnostic ||
leftEntry.runtimeDiagnosticSeverity !== rightEntry?.runtimeDiagnosticSeverity || 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; return false;
} }
@ -224,6 +239,7 @@ function areMemberListPropsEqual(
arePendingRepliesEquivalent(prev.pendingRepliesByMember, next.pendingRepliesByMember) && arePendingRepliesEquivalent(prev.pendingRepliesByMember, next.pendingRepliesByMember) &&
areMemberSpawnStatusesEquivalent(prev.memberSpawnStatuses, next.memberSpawnStatuses) && areMemberSpawnStatusesEquivalent(prev.memberSpawnStatuses, next.memberSpawnStatuses) &&
areMemberRuntimeEntriesEquivalent(prev.memberRuntimeEntries, next.memberRuntimeEntries) && areMemberRuntimeEntriesEquivalent(prev.memberRuntimeEntries, next.memberRuntimeEntries) &&
prev.runtimeRunId === next.runtimeRunId &&
prev.isLaunchSettling === next.isLaunchSettling && prev.isLaunchSettling === next.isLaunchSettling &&
prev.isTeamAlive === next.isTeamAlive && prev.isTeamAlive === next.isTeamAlive &&
prev.isTeamProvisioning === next.isTeamProvisioning && prev.isTeamProvisioning === next.isTeamProvisioning &&
@ -239,6 +255,7 @@ export const MemberList = memo(function MemberList({
pendingRepliesByMember, pendingRepliesByMember,
memberSpawnStatuses, memberSpawnStatuses,
memberRuntimeEntries, memberRuntimeEntries,
runtimeRunId,
isLaunchSettling, isLaunchSettling,
isTeamAlive, isTeamAlive,
isTeamProvisioning, isTeamProvisioning,
@ -342,7 +359,9 @@ export const MemberList = memo(function MemberList({
isRemoved ? undefined : runtimeEntry isRemoved ? undefined : runtimeEntry
)} )}
runtimeEntry={isRemoved ? undefined : runtimeEntry} runtimeEntry={isRemoved ? undefined : runtimeEntry}
runtimeRunId={isRemoved ? undefined : runtimeRunId}
spawnStatus={isRemoved ? undefined : spawnEntry?.status} spawnStatus={isRemoved ? undefined : spawnEntry?.status}
spawnEntry={isRemoved ? undefined : spawnEntry}
spawnError={isRemoved ? undefined : (spawnEntry?.error ?? spawnEntry?.hardFailureReason)} spawnError={isRemoved ? undefined : (spawnEntry?.error ?? spawnEntry?.hardFailureReason)}
spawnLivenessSource={isRemoved ? undefined : spawnEntry?.livenessSource} spawnLivenessSource={isRemoved ? undefined : spawnEntry?.livenessSource}
spawnLaunchState={isRemoved ? undefined : spawnEntry?.launchState} spawnLaunchState={isRemoved ? undefined : spawnEntry?.launchState}

View file

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

View file

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

View file

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

View file

@ -810,20 +810,34 @@ function areTeamAgentRuntimeEntriesEqual(
): boolean { ): boolean {
if (left === right) return true; if (left === right) return true;
if (!left || !right) return left === right; if (!left || !right) return left === right;
const leftDiagnostics = left.diagnostics ?? [];
const rightDiagnostics = right.diagnostics ?? [];
return ( return (
left.memberName === right.memberName && left.memberName === right.memberName &&
left.alive === right.alive && left.alive === right.alive &&
left.restartable === right.restartable && left.restartable === right.restartable &&
left.backendType === right.backendType && 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.pid === right.pid &&
left.runtimeModel === right.runtimeModel && left.runtimeModel === right.runtimeModel &&
left.rssBytes === right.rssBytes && left.rssBytes === right.rssBytes &&
left.livenessKind === right.livenessKind && left.livenessKind === right.livenessKind &&
left.pidSource === right.pidSource && left.pidSource === right.pidSource &&
left.processCommand === right.processCommand &&
left.paneId === right.paneId &&
left.panePid === right.panePid &&
left.paneCurrentCommand === right.paneCurrentCommand && left.paneCurrentCommand === right.paneCurrentCommand &&
left.runtimePid === right.runtimePid &&
left.runtimeSessionId === right.runtimeSessionId &&
left.runtimeDiagnostic === right.runtimeDiagnostic && left.runtimeDiagnostic === right.runtimeDiagnostic &&
left.runtimeDiagnosticSeverity === right.runtimeDiagnosticSeverity && 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' runtimeEntry?.livenessKind === 'not_found'
) { ) {
launchVisualState = 'stale_runtime'; launchVisualState = 'stale_runtime';
} else if (
spawnLaunchState === 'runtime_pending_bootstrap' &&
(runtimeEntry?.livenessKind === 'runtime_process' ||
(spawnStatus === 'online' && spawnRuntimeAlive === true))
) {
launchVisualState = 'runtime_pending';
} else if ( } else if (
isLaunchStillStarting( isLaunchStillStarting(
spawnStatus, spawnStatus,
@ -671,6 +665,12 @@ export function buildMemberLaunchPresentation({
) )
) { ) {
launchVisualState = spawnStatus === 'spawning' ? 'spawning' : 'waiting'; launchVisualState = spawnStatus === 'spawning' ? 'spawning' : 'waiting';
} else if (
spawnLaunchState === 'runtime_pending_bootstrap' &&
(runtimeEntry?.livenessKind === 'runtime_process' ||
(spawnStatus === 'online' && spawnRuntimeAlive === true))
) {
launchVisualState = 'runtime_pending';
} else if ( } else if (
isLaunchSettling && isLaunchSettling &&
spawnStatus === 'online' && spawnStatus === 'online' &&
@ -681,15 +681,19 @@ export function buildMemberLaunchPresentation({
} }
const launchStatusLabel = getMemberLaunchStatusLabel(launchVisualState); const launchStatusLabel = getMemberLaunchStatusLabel(launchVisualState);
const displayPresenceLabel = const shouldShowLaunchStatusAsPresence =
launchVisualState === 'permission_pending' || launchVisualState === 'permission_pending' ||
launchVisualState === 'runtime_pending' || launchVisualState === 'runtime_pending' ||
launchVisualState === 'shell_only' || launchVisualState === 'shell_only' ||
launchVisualState === 'runtime_candidate' || launchVisualState === 'runtime_candidate' ||
launchVisualState === 'registered_only' || launchVisualState === 'registered_only' ||
launchVisualState === 'stale_runtime' launchVisualState === 'stale_runtime';
? (launchStatusLabel ?? presenceLabel) const displayPresenceLabel =
: presenceLabel; runtimeAdvisoryTone === 'error' && runtimeAdvisoryLabel
? runtimeAdvisoryLabel
: shouldShowLaunchStatusAsPresence
? (launchStatusLabel ?? presenceLabel)
: presenceLabel;
const spawnBadgeLabel = const spawnBadgeLabel =
spawnStatus && spawnStatus !== 'online' spawnStatus && spawnStatus !== 'online'
? spawnStatus === 'waiting' || spawnStatus === 'spawning' ? 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( export function resolveMemberRuntimeSummary(
member: ResolvedTeamMember, member: ResolvedTeamMember,
launchParams: TeamLaunchParams | undefined, launchParams: TeamLaunchParams | undefined,

View file

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

View file

@ -32,6 +32,17 @@ interface FailedSpawnDetail {
reason: string | null; 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 { function parseStatusUpdatedAtMs(value: string | undefined): number | null {
if (!value) { if (!value) {
return null; return null;
@ -126,25 +137,130 @@ function buildAwaitingPermissionPhrase(count: number): string {
: `${count} teammates awaiting permission approval`; : `${count} teammates awaiting permission approval`;
} }
function buildPendingDiagnosticPhrase( function getMemberNamesFromSpawnSources(params: {
summary: MemberSpawnStatusesSnapshot['summary'] | undefined, memberSpawnStatuses: MemberSpawnStatusCollection;
fallbackJoiningPhrase: string memberSpawnSnapshotStatuses?: MemberSpawnStatusesSnapshot['statuses'];
): string { }): 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) { if (!summary) {
return fallbackJoiningPhrase; return fallbackJoiningPhrase;
} }
const parts = [ const countParts = [
summary.shellOnlyPendingCount ? `${summary.shellOnlyPendingCount} shell-only` : '', formatCountPendingDiagnostic(summary.shellOnlyPendingCount, 'shell-only'),
summary.runtimeProcessPendingCount formatCountPendingDiagnostic(summary.runtimeProcessPendingCount, 'waiting for bootstrap'),
? `${summary.runtimeProcessPendingCount} waiting for bootstrap` formatCountPendingDiagnostic(summary.runtimeCandidatePendingCount, 'process candidates'),
: '', formatCountPendingDiagnostic(summary.permissionPendingCount, 'awaiting permission'),
summary.runtimeCandidatePendingCount formatCountPendingDiagnostic(summary.noRuntimePendingCount, 'no runtime found'),
? `${summary.runtimeCandidatePendingCount} process candidates`
: '',
summary.permissionPendingCount ? `${summary.permissionPendingCount} awaiting permission` : '',
summary.noRuntimePendingCount ? `${summary.noRuntimePendingCount} no runtime found` : '',
].filter(Boolean); ].filter(Boolean);
return parts.length > 0 ? parts.join(', ') : fallbackJoiningPhrase; return countParts.length > 0 ? countParts.join(', ') : fallbackJoiningPhrase;
} }
const ACTIVE_PROVISIONING_STATES = new Set([ const ACTIVE_PROVISIONING_STATES = new Set([
@ -415,7 +531,13 @@ export function buildTeamProvisioningPresentation({
permissionBlockedCount === remainingJoinCount; permissionBlockedCount === remainingJoinCount;
const pendingDetailPhrase = pendingMembersAwaitApproval const pendingDetailPhrase = pendingMembersAwaitApproval
? buildAwaitingPermissionPhrase(permissionBlockedCount) ? buildAwaitingPermissionPhrase(permissionBlockedCount)
: buildPendingDiagnosticPhrase(memberSpawnSnapshot?.summary, joiningPhrase); : buildPendingDiagnosticPhrase({
summary: memberSpawnSnapshot?.summary,
memberSpawnStatuses,
memberSpawnSnapshotStatuses: memberSpawnSnapshot?.statuses,
memberSpawnSnapshotUpdatedAt: memberSpawnSnapshot?.updatedAt,
fallbackJoiningPhrase: joiningPhrase,
});
const readyCompactDetail = const readyCompactDetail =
failedSpawnCount > 0 failedSpawnCount > 0
? (failedSpawnCompactDetail ?? ? (failedSpawnCompactDetail ??
@ -492,7 +614,13 @@ export function buildTeamProvisioningPresentation({
permissionBlockedCount > 0 && permissionBlockedCount > 0 &&
permissionBlockedCount === remainingJoinCount permissionBlockedCount === remainingJoinCount
? buildAwaitingPermissionPhrase(permissionBlockedCount) ? buildAwaitingPermissionPhrase(permissionBlockedCount)
: buildPendingDiagnosticPhrase(memberSpawnSnapshot?.summary, activeJoiningPhrase); : buildPendingDiagnosticPhrase({
summary: memberSpawnSnapshot?.summary,
memberSpawnStatuses,
memberSpawnSnapshotStatuses: memberSpawnSnapshot?.statuses,
memberSpawnSnapshotUpdatedAt: memberSpawnSnapshot?.updatedAt,
fallbackJoiningPhrase: activeJoiningPhrase,
});
return { return {
progress, progress,
isActive: true, isActive: true,

View file

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

View file

@ -24,13 +24,28 @@ export function getReviewStateFromTask(task: ReviewStateLike): TeamReviewState {
} }
} }
const explicit = normalizeReviewState(task.reviewState); const fallbackStatus = typeof task.status === 'string' ? task.status : null;
if (explicit !== 'none') { 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; return explicit;
} };
const explicit = normalizeFallback(task.reviewState);
if (explicit) return explicit;
if (task.kanbanColumn === 'review' || task.kanbanColumn === 'approved') { if (task.kanbanColumn === 'review' || task.kanbanColumn === 'approved') {
return task.kanbanColumn; return normalizeFallback(task.kanbanColumn) ?? 'none';
} }
return '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'; export type TaskChangeStateBucket = 'approved' | 'review' | 'completed' | 'active';
@ -11,25 +11,8 @@ interface TaskChangeStateLike {
kanbanColumn?: 'review' | 'approved' | null; kanbanColumn?: 'review' | 'approved' | null;
} }
function normalizeReviewState(value: unknown): TeamReviewState {
return value === 'review' || value === 'needsFix' || value === 'approved' ? value : 'none';
}
function getEffectiveReviewState(task: TaskChangeStateLike): TeamReviewState { function getEffectiveReviewState(task: TaskChangeStateLike): TeamReviewState {
if (Array.isArray(task.historyEvents) && task.historyEvents.length > 0) { return getReviewStateFromTask(task);
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';
} }
export function getTaskChangeStateBucket(task: TaskChangeStateLike): TaskChangeStateBucket { 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', { expect(logger.warn).toHaveBeenCalledWith('recent-projects source failed', {
sourceId: 'source-1', sourceId: 'source-1',
sourceIndex: 1, sourceIndex: 1,
@ -165,7 +165,7 @@ describe('ListDashboardRecentProjectsUseCase', () => {
cacheKey: 'recent-projects:fresh', cacheKey: 'recent-projects:fresh',
count: 1, count: 1,
degradedSources: 1, degradedSources: 1,
cacheTtlMs: 1_500, cacheTtlMs: 30_000,
durationMs: 250, durationMs: 250,
}); });
}); });
@ -242,7 +242,7 @@ describe('ListDashboardRecentProjectsUseCase', () => {
expect(cache.set).toHaveBeenCalledWith( expect(cache.set).toHaveBeenCalledWith(
'recent-projects:timeout', 'recent-projects:timeout',
{ ids: ['repo:fast'], sources: ['claude'] }, { ids: ['repo:fast'], sources: ['claude'] },
1_500 30_000
); );
} finally { } finally {
vi.useRealTimers(); vi.useRealTimers();
@ -311,13 +311,13 @@ describe('ListDashboardRecentProjectsUseCase', () => {
expect(cache.set).toHaveBeenCalledWith( expect(cache.set).toHaveBeenCalledWith(
'recent-projects:stale', 'recent-projects:stale',
{ ids: ['repo:fresh'], sources: ['claude'] }, { ids: ['repo:fresh'], sources: ['claude'] },
1_500 30_000
); );
expect(logger.info).toHaveBeenCalledWith('recent-projects loaded', { expect(logger.info).toHaveBeenCalledWith('recent-projects loaded', {
cacheKey: 'recent-projects:stale', cacheKey: 'recent-projects:stale',
count: 1, count: 1,
degradedSources: 1, degradedSources: 1,
cacheTtlMs: 1_500, cacheTtlMs: 30_000,
durationMs: 200, durationMs: 200,
}); });
}); });
@ -364,11 +364,11 @@ describe('ListDashboardRecentProjectsUseCase', () => {
await expect(useCase.execute('recent-projects:stale')).resolves.toEqual(stale); await expect(useCase.execute('recent-projects:stale')).resolves.toEqual(stale);
expect(output.present).not.toHaveBeenCalled(); 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', { expect(logger.info).toHaveBeenCalledWith('recent-projects served stale cache', {
cacheKey: 'recent-projects:stale', cacheKey: 'recent-projects:stale',
degradedSources: 1, degradedSources: 1,
cacheTtlMs: 1_500, cacheTtlMs: 30_000,
durationMs: 200, durationMs: 200,
}); });
}); });
@ -431,13 +431,13 @@ describe('ListDashboardRecentProjectsUseCase', () => {
expect(cache.set).toHaveBeenCalledWith( expect(cache.set).toHaveBeenCalledWith(
'recent-projects:explicit-degraded', 'recent-projects:explicit-degraded',
{ ids: ['repo:alpha'], sources: ['claude'] }, { ids: ['repo:alpha'], sources: ['claude'] },
1_500 30_000
); );
expect(logger.info).toHaveBeenCalledWith('recent-projects loaded', { expect(logger.info).toHaveBeenCalledWith('recent-projects loaded', {
cacheKey: 'recent-projects:explicit-degraded', cacheKey: 'recent-projects:explicit-degraded',
count: 1, count: 1,
degradedSources: 1, degradedSources: 1,
cacheTtlMs: 1_500, cacheTtlMs: 30_000,
durationMs: 0, durationMs: 0,
}); });
}); });

View file

@ -166,7 +166,18 @@ describe('CodexRecentProjectsSourceAdapter', () => {
candidates: [], candidates: [],
degraded: true, degraded: true,
}); });
await expect(adapter.list()).resolves.toEqual({
candidates: [],
degraded: true,
});
expect(appServerClient.listRecentThreads).toHaveBeenCalledTimes(1);
expect(appServerClient.listRecentLiveThreads).not.toHaveBeenCalled(); 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 () => { it('drops Codex appstyle temp workspaces from dashboard candidates', async () => {
@ -209,4 +220,81 @@ describe('CodexRecentProjectsSourceAdapter', () => {
expect(identityResolver.resolve).not.toHaveBeenCalled(); 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', () => { describe('CodexAppServerClient', () => {
it('loads live and archived threads in a single app-server session', async () => { it('loads live and archived threads in a single app-server session', async () => {
const session = createSession( const request = vi
vi.fn().mockImplementation((method: string, params?: { archived?: boolean }) => { .fn()
.mockImplementation((method: string, params?: { archived?: boolean }) => {
if (method === 'initialize') { if (method === 'initialize') {
return Promise.resolve({}); return Promise.resolve({});
} }
@ -40,8 +41,8 @@ describe('CodexAppServerClient', () => {
} }
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)); const withSession = vi.fn().mockImplementation((_options, handler) => handler(session));
const client = new CodexAppServerClient({ withSession } as unknown as JsonRpcStdioClient); const client = new CodexAppServerClient({ withSession } as unknown as JsonRpcStdioClient);
@ -58,11 +59,23 @@ describe('CodexAppServerClient', () => {
expect.objectContaining({ expect.objectContaining({
binaryPath: '/usr/local/bin/codex', binaryPath: '/usr/local/bin/codex',
requestTimeoutMs: 4500, requestTimeoutMs: 4500,
totalTimeoutMs: 12000, totalTimeoutMs: 14500,
}), }),
expect.any(Function) expect.any(Function)
); );
expect(session.notify).toHaveBeenCalledWith('initialized'); 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({ expect(result).toEqual({
live: { live: {
threads: [{ id: 'live-1', cwd: '/Users/test/live-project', source: 'cli' }], 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( const session = createSession(
vi.fn().mockImplementation((method: string, params?: { archived?: boolean }) => { vi.fn().mockImplementation((method: string, params?: { archived?: boolean }) => {
if (method === 'initialize') { if (method === 'initialize') {
@ -140,7 +153,7 @@ describe('CodexAppServerClient', () => {
expect(withSession).toHaveBeenCalledWith( expect(withSession).toHaveBeenCalledWith(
expect.objectContaining({ expect.objectContaining({
totalTimeoutMs: 12000, totalTimeoutMs: 14500,
}), }),
expect.any(Function) expect.any(Function)
); );
@ -187,18 +200,20 @@ describe('CodexAppServerClient', () => {
}); });
it('uses the longer initialize timeout for app-server startup', async () => { it('uses the longer initialize timeout for app-server startup', async () => {
const request = vi.fn().mockImplementation((method: string, _params?: unknown, timeoutMs?: number) => { const request = vi
if (method === 'initialize') { .fn()
expect(timeoutMs).toBe(6000); .mockImplementation((method: string, _params?: unknown, timeoutMs?: number) => {
return Promise.resolve({}); if (method === 'initialize') {
} expect(timeoutMs).toBe(6000);
return Promise.resolve({});
}
if (method === 'thread/list') { if (method === 'thread/list') {
return Promise.resolve({ data: [] }); 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 session = createSession(request);
const withSession = vi.fn().mockImplementation((_options, handler) => handler(session)); const withSession = vi.fn().mockImplementation((_options, handler) => handler(session));

View file

@ -103,7 +103,7 @@ describe('recentProjectsClientCache', () => {
await expect(second).resolves.toEqual(payload('alpha')); 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.useFakeTimers();
vi.setSystemTime(new Date('2026-04-14T12:00:00.000Z')); vi.setSystemTime(new Date('2026-04-14T12:00:00.000Z'));
@ -121,7 +121,13 @@ describe('recentProjectsClientCache', () => {
isStale: false, 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({ expect(getRecentProjectsClientSnapshot()).toMatchObject({
payload: payload('alpha', { degraded: true }), payload: payload('alpha', { degraded: true }),
isStale: true, isStale: true,
@ -129,7 +135,9 @@ describe('recentProjectsClientCache', () => {
}); });
it('normalizes legacy array responses from the loader during mixed-version dev reloads', async () => { 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')); await expect(loadRecentProjectsWithClientCache(loader)).resolves.toEqual(payload('alpha'));
expect(getRecentProjectsClientSnapshot()?.payload).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: { alice: {
providerId: 'opencode', providerId: 'opencode',
launchState: 'runtime_pending_bootstrap', launchState: 'runtime_pending_bootstrap',
runtimeAlive: true, runtimeAlive: false,
bootstrapConfirmed: false, bootstrapConfirmed: false,
}, },
}, },
@ -501,8 +501,9 @@ describe('OpenCodeTeamRuntimeAdapter', () => {
providerId: 'opencode', providerId: 'opencode',
launchState: 'runtime_pending_permission', launchState: 'runtime_pending_permission',
pendingPermissionRequestIds: ['perm-1', 'perm-2'], pendingPermissionRequestIds: ['perm-1', 'perm-2'],
runtimeAlive: true, runtimeAlive: false,
agentToolAccepted: true, agentToolAccepted: true,
livenessKind: 'permission_blocked',
bootstrapConfirmed: false, bootstrapConfirmed: false,
hardFailure: 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 () => { it('keeps missing bridge members in bootstrap pending even when another member blocks on permission', async () => {
const launchOpenCodeTeam = vi.fn( const launchOpenCodeTeam = vi.fn(
async () => async () =>

View file

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

View file

@ -279,6 +279,39 @@ describe('TeamBootstrapStateReader', () => {
await expect(readBootstrapRuntimeState('demo')).resolves.toBeNull(); 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 () => { it('reads persisted real-task submission state', async () => {
hoisted.files.set('/mock/teams/demo/bootstrap-state.json', { hoisted.files.set('/mock/teams/demo/bootstrap-state.json', {
contents: JSON.stringify({ contents: JSON.stringify({

View file

@ -242,10 +242,12 @@ function createForwardingJournalStore(initialEntries: Array<Record<string, unkno
const journal = { const journal = {
exists: vi.fn(async () => true), exists: vi.fn(async () => true),
ensureFile: vi.fn(async () => undefined), ensureFile: vi.fn(async () => undefined),
withEntries: vi.fn(async (_teamName: string, fn: (entries: unknown[]) => Promise<{ result: unknown }>) => { withEntries: vi.fn(
const outcome = await fn(journalEntries); async (_teamName: string, fn: (entries: unknown[]) => Promise<{ result: unknown }>) => {
return outcome.result; const outcome = await fn(journalEntries);
}), return outcome.result;
}
),
}; };
return { journalEntries, journal }; return { journalEntries, journal };
@ -262,7 +264,9 @@ function createTaskCommentForwardingService(options: {
}; };
members?: Array<{ name: string; role?: string }>; 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 journal = options.journal ?? createForwardingJournalStore().journal;
const service = new TeamDataService( const service = new TeamDataService(
@ -328,24 +332,26 @@ function buildDefaultTeamConfig(overrides: Partial<TeamConfig> = {}): TeamConfig
}; };
} }
function createGetTeamDataHarness(options: { function createGetTeamDataHarness(
config?: TeamConfig | null; options: {
getTasks?: () => Promise<TeamTask[]>; config?: TeamConfig | null;
listInboxNames?: () => Promise<string[]>; getTasks?: () => Promise<TeamTask[]>;
getMessages?: () => Promise<InboxMessage[]>; listInboxNames?: () => Promise<string[]>;
getMembers?: () => Promise<TeamConfig['members']>; getMessages?: () => Promise<InboxMessage[]>;
getTeamMeta?: () => Promise<TeamMetaFile | null>; getMembers?: () => Promise<TeamConfig['members']>;
getState?: () => Promise<KanbanState>; getTeamMeta?: () => Promise<TeamMetaFile | null>;
readMessages?: () => Promise<InboxMessage[]>; getState?: () => Promise<KanbanState>;
resolveMembers?: ( readMessages?: () => Promise<InboxMessage[]>;
config: TeamConfig, resolveMembers?: (
metaMembers: TeamConfig['members'], config: TeamConfig,
inboxNames: string[], metaMembers: TeamConfig['members'],
tasks: TeamTaskWithKanban[] inboxNames: string[],
) => ResolvedTeamMember[]; tasks: TeamTaskWithKanban[]
listProcesses?: () => TeamProcess[]; ) => ResolvedTeamMember[];
getMemberAdvisories?: () => Promise<Map<string, unknown>>; listProcesses?: () => TeamProcess[];
} = {}) { getMemberAdvisories?: () => Promise<Map<string, unknown>>;
} = {}
) {
const getConfig = vi.fn(async () => const getConfig = vi.fn(async () =>
options.config === undefined ? buildDefaultTeamConfig() : options.config options.config === undefined ? buildDefaultTeamConfig() : options.config
); );
@ -486,7 +492,9 @@ describe('TeamDataService', () => {
{} as never, {} as never,
{} as never, {} as never,
{ resolveMembers: vi.fn(() => []) } 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, {} as never,
membersMetaStore, membersMetaStore,
{ readMessages: vi.fn(async () => []) } as never { readMessages: vi.fn(async () => []) } as never
@ -518,7 +526,9 @@ describe('TeamDataService', () => {
{} as never, {} as never,
{} as never, {} as never,
{ resolveMembers: vi.fn(() => []) } 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, {} as never,
membersMetaStore, membersMetaStore,
{ readMessages: vi.fn(async () => []) } as never { readMessages: vi.fn(async () => []) } as never
@ -1006,7 +1016,10 @@ describe('TeamDataService', () => {
const service = new TeamDataService( const service = new TeamDataService(
{ {
listTeams: vi.fn(), 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, {} as never,
{} as never, {} as never,
@ -1134,7 +1147,10 @@ describe('TeamDataService', () => {
const service = new TeamDataService( const service = new TeamDataService(
{ {
listTeams: vi.fn(), 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,
{ {
getTasks: vi.fn(async () => []), getTasks: vi.fn(async () => []),
@ -1305,7 +1321,9 @@ describe('TeamDataService', () => {
expect(createTaskMock).toHaveBeenCalledWith( expect(createTaskMock).toHaveBeenCalledWith(
expect.objectContaining({ owner: 'alice', createdBy: 'user' }) 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 () => { it('creates task with explicit immediate start only when startImmediately is true', async () => {
@ -1362,7 +1380,9 @@ describe('TeamDataService', () => {
prompt: 'Begin immediately.', 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 () => { it('persists explicit related task links when creating a task', async () => {
@ -1486,7 +1506,47 @@ describe('TeamDataService', () => {
await service.requestReview('my-team', 'task-1'); await service.requestReview('my-team', 'task-1');
expect(requestReviewMock).toHaveBeenCalledWith('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', leadSessionId: 'lead-1',
}); });
}); });
@ -1511,8 +1571,15 @@ describe('TeamDataService', () => {
subject: 'Legacy review task', subject: 'Legacy review task',
status: 'completed', status: 'completed',
owner: 'bob', owner: 'bob',
reviewState: 'review', reviewState: 'none',
historyEvents: [], historyEvents: [
{
id: 'evt-created',
type: 'task_created',
status: 'completed',
timestamp: '2026-03-01T09:00:00.000Z',
},
],
}, },
]), ]),
} as never, } 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 () => { it('propagates leadSessionId for kanban-driven review transitions', async () => {
const requestReviewMock = vi.fn(); const requestReviewMock = vi.fn();
const approveReviewMock = 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: 'review' });
await service.updateKanban('my-team', 'task-1', { op: 'set_column', column: 'approved' }); 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', { expect(requestReviewMock).toHaveBeenCalledWith('task-1', {
from: 'user', from: 'lead',
leadSessionId: 'lead-2', leadSessionId: 'lead-2',
}); });
expect(approveReviewMock).toHaveBeenCalledWith('task-1', { expect(approveReviewMock).toHaveBeenCalledWith('task-1', {
from: 'user', from: 'lead',
suppressTaskComment: true, suppressTaskComment: true,
'notify-owner': true, 'notify-owner': true,
leadSessionId: 'lead-2', leadSessionId: 'lead-2',
}); });
expect(requestChangesMock).toHaveBeenCalledWith('task-1', { expect(requestChangesMock).toHaveBeenCalledWith('task-1', {
from: 'user', from: 'lead',
comment: 'Needs fixes', comment: 'Needs fixes',
leadSessionId: 'lead-2', leadSessionId: 'lead-2',
}); });
@ -1615,10 +1808,12 @@ describe('TeamDataService', () => {
ensureFile: vi.fn(async () => { ensureFile: vi.fn(async () => {
journalExists = true; journalExists = true;
}), }),
withEntries: vi.fn(async (_teamName: string, fn: (entries: unknown[]) => Promise<{ result: unknown }>) => { withEntries: vi.fn(
const outcome = await fn(journalEntries); async (_teamName: string, fn: (entries: unknown[]) => Promise<{ result: unknown }>) => {
return outcome.result; const outcome = await fn(journalEntries);
}), return outcome.result;
}
),
}; };
try { try {
@ -1707,10 +1902,12 @@ describe('TeamDataService', () => {
const journal = { const journal = {
exists: vi.fn(async () => true), exists: vi.fn(async () => true),
ensureFile: vi.fn(async () => undefined), ensureFile: vi.fn(async () => undefined),
withEntries: vi.fn(async (_teamName: string, fn: (entries: unknown[]) => Promise<{ result: unknown }>) => { withEntries: vi.fn(
const outcome = await fn(journalEntries); async (_teamName: string, fn: (entries: unknown[]) => Promise<{ result: unknown }>) => {
return outcome.result; const outcome = await fn(journalEntries);
}), return outcome.result;
}
),
}; };
try { try {
@ -1776,10 +1973,9 @@ describe('TeamDataService', () => {
messageId: 'task-comment-forward:my-team:task-1:comment-1', messageId: 'task-comment-forward:my-team:task-1:comment-1',
}) })
); );
const firstSendRequest = (inboxWriter.sendMessage as unknown as { mock: { calls: unknown[][] } }) const firstSendRequest = (
.mock.calls[0]?.[1] as inboxWriter.sendMessage as unknown as { mock: { calls: unknown[][] } }
| { text?: string } ).mock.calls[0]?.[1] as { text?: string } | undefined;
| undefined;
expect(String(firstSendRequest?.text ?? '')).not.toContain('<agent-block>'); expect(String(firstSendRequest?.text ?? '')).not.toContain('<agent-block>');
const sentEntry = journalEntries.find((entry) => entry.key === 'task-1:comment-1'); const sentEntry = journalEntries.find((entry) => entry.key === 'task-1:comment-1');
expect(sentEntry).toMatchObject({ expect(sentEntry).toMatchObject({
@ -1803,10 +1999,12 @@ describe('TeamDataService', () => {
ensureFile: vi.fn(async () => { ensureFile: vi.fn(async () => {
journalExists = true; journalExists = true;
}), }),
withEntries: vi.fn(async (_teamName: string, fn: (entries: unknown[]) => Promise<{ result: unknown }>) => { withEntries: vi.fn(
const outcome = await fn(journalEntries); async (_teamName: string, fn: (entries: unknown[]) => Promise<{ result: unknown }>) => {
return outcome.result; const outcome = await fn(journalEntries);
}), return outcome.result;
}
),
}; };
try { try {
@ -1902,10 +2100,12 @@ describe('TeamDataService', () => {
const journal = { const journal = {
exists: vi.fn(async () => true), exists: vi.fn(async () => true),
ensureFile: vi.fn(async () => undefined), ensureFile: vi.fn(async () => undefined),
withEntries: vi.fn(async (_teamName: string, fn: (entries: unknown[]) => Promise<{ result: unknown }>) => { withEntries: vi.fn(
const outcome = await fn(journalEntries); async (_teamName: string, fn: (entries: unknown[]) => Promise<{ result: unknown }>) => {
return outcome.result; const outcome = await fn(journalEntries);
}), return outcome.result;
}
),
}; };
try { try {
@ -1983,10 +2183,12 @@ describe('TeamDataService', () => {
const journal = { const journal = {
exists: vi.fn(async () => true), exists: vi.fn(async () => true),
ensureFile: vi.fn(async () => undefined), ensureFile: vi.fn(async () => undefined),
withEntries: vi.fn(async (_teamName: string, fn: (entries: unknown[]) => Promise<{ result: unknown }>) => { withEntries: vi.fn(
const outcome = await fn(journalEntries); async (_teamName: string, fn: (entries: unknown[]) => Promise<{ result: unknown }>) => {
return outcome.result; const outcome = await fn(journalEntries);
}), return outcome.result;
}
),
}; };
try { try {
@ -2081,15 +2283,20 @@ describe('TeamDataService', () => {
}, },
]; ];
const inboxWriter = { 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 = { const journal = {
exists: vi.fn(async () => true), exists: vi.fn(async () => true),
ensureFile: vi.fn(async () => undefined), ensureFile: vi.fn(async () => undefined),
withEntries: vi.fn(async (_teamName: string, fn: (entries: unknown[]) => Promise<{ result: unknown }>) => { withEntries: vi.fn(
const outcome = await fn(journalEntries); async (_teamName: string, fn: (entries: unknown[]) => Promise<{ result: unknown }>) => {
return outcome.result; const outcome = await fn(journalEntries);
}), return outcome.result;
}
),
}; };
try { try {
@ -2183,10 +2390,12 @@ describe('TeamDataService', () => {
const journal = { const journal = {
exists: vi.fn(async () => true), exists: vi.fn(async () => true),
ensureFile: vi.fn(async () => undefined), ensureFile: vi.fn(async () => undefined),
withEntries: vi.fn(async (_teamName: string, fn: (entries: unknown[]) => Promise<{ result: unknown }>) => { withEntries: vi.fn(
const outcome = await fn(journalEntries); async (_teamName: string, fn: (entries: unknown[]) => Promise<{ result: unknown }>) => {
return outcome.result; const outcome = await fn(journalEntries);
}), return outcome.result;
}
),
}; };
try { try {
@ -2283,10 +2492,12 @@ describe('TeamDataService', () => {
const journal = { const journal = {
exists: vi.fn(async () => true), exists: vi.fn(async () => true),
ensureFile: vi.fn(async () => undefined), ensureFile: vi.fn(async () => undefined),
withEntries: vi.fn(async (_teamName: string, fn: (entries: unknown[]) => Promise<{ result: unknown }>) => { withEntries: vi.fn(
const outcome = await fn(journalEntries); async (_teamName: string, fn: (entries: unknown[]) => Promise<{ result: unknown }>) => {
return outcome.result; const outcome = await fn(journalEntries);
}), return outcome.result;
}
),
}; };
try { try {
@ -2368,10 +2579,12 @@ describe('TeamDataService', () => {
const journal = { const journal = {
exists: vi.fn(async () => true), exists: vi.fn(async () => true),
ensureFile: vi.fn(async () => undefined), ensureFile: vi.fn(async () => undefined),
withEntries: vi.fn(async (_teamName: string, fn: (entries: unknown[]) => Promise<{ result: unknown }>) => { withEntries: vi.fn(
const outcome = await fn(journalEntries); async (_teamName: string, fn: (entries: unknown[]) => Promise<{ result: unknown }>) => {
return outcome.result; const outcome = await fn(journalEntries);
}), return outcome.result;
}
),
}; };
try { try {
@ -2611,15 +2824,19 @@ describe('TeamDataService', () => {
const initGate = new Promise<void>((resolve) => { const initGate = new Promise<void>((resolve) => {
releaseInit = () => 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 journalEntries: Array<Record<string, unknown>> = [];
const journal = { const journal = {
exists: vi.fn(async () => true), exists: vi.fn(async () => true),
ensureFile: vi.fn(async () => undefined), ensureFile: vi.fn(async () => undefined),
withEntries: vi.fn(async (_teamName: string, fn: (entries: unknown[]) => Promise<{ result: unknown }>) => { withEntries: vi.fn(
const outcome = await fn(journalEntries); async (_teamName: string, fn: (entries: unknown[]) => Promise<{ result: unknown }>) => {
return outcome.result; const outcome = await fn(journalEntries);
}), return outcome.result;
}
),
}; };
try { try {
@ -3420,7 +3637,9 @@ describe('TeamDataService', () => {
}); });
const feed = await service.getMessageFeed('my-team'); 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(); expect(linked?.relayOfMessageId).toBeUndefined();
}); });
@ -3988,10 +4207,7 @@ describe('TeamDataService', () => {
), ),
]); ]);
const firstSpy = vi.spyOn( const firstSpy = vi.spyOn(firstService as never, 'extractLeadAssistantTextsFromJsonl' as never);
firstService as never,
'extractLeadAssistantTextsFromJsonl' as never
);
const secondSpy = vi.spyOn( const secondSpy = vi.spyOn(
secondService as never, secondService as never,
'extractLeadAssistantTextsFromJsonl' as never 'extractLeadAssistantTextsFromJsonl' as never
@ -4106,7 +4322,9 @@ describe('TeamDataService', () => {
const service = createResolverBackedService(); const service = createResolverBackedService();
const page = await service.getMessagesPage(fixture.teamName, { limit: 20 }); 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( expect(
leadSessionMessages.some((message) => leadSessionMessages.some((message) =>
@ -4187,12 +4405,7 @@ describe('TeamDataService', () => {
await flushMicrotasks(); await flushMicrotasks();
expect(order).toEqual( expect(order).toEqual(
expect.arrayContaining([ expect.arrayContaining(['inboxNames:start', 'meta:start', 'kanban:start', 'tasks:start'])
'inboxNames:start',
'meta:start',
'kanban:start',
'tasks:start',
])
); );
expect(order).not.toContain('processes:start'); expect(order).not.toContain('processes:start');
expect(order).not.toContain('leadTexts:start'); expect(order).not.toContain('leadTexts:start');
@ -4453,7 +4666,11 @@ describe('TeamDataService', () => {
const feed = await harness.service.getMessageFeed('my-team'); 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 () => { 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(() => { vi.spyOn(harness.service as never, 'extractLeadSessionTexts' as never).mockImplementation(
order.push('leadTexts:start'); () => {
throw new Error('lead sync fail'); order.push('leadTexts:start');
}); throw new Error('lead sync fail');
}
);
const pending = harness.service.getTeamData('my-team'); const pending = harness.service.getTeamData('my-team');
await flushMicrotasks(); await flushMicrotasks();
@ -4665,7 +4884,16 @@ describe('TeamDataService', () => {
}); });
describe('getMessagesPage', () => { 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( return new TeamDataService(
{ {
listTeams: vi.fn(), listTeams: vi.fn(),
@ -4678,17 +4906,17 @@ describe('TeamDataService', () => {
{ getTasks: vi.fn(async () => []) } as never, { getTasks: vi.fn(async () => []) } as never,
{ {
listInboxNames: vi.fn(async () => []), listInboxNames: vi.fn(async () => []),
getMessages: vi.fn(async () => getMessages: vi.fn(async () => messages.map((m) => ({ ...m, read: true }))),
messages.map((m) => ({ ...m, read: true }))
),
} as never, } as never,
{} as never, {} as never,
{} as never, {} as never,
{ resolveMembers: vi.fn(() => []) } 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,
{} 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.messages[0]?.messageId).toMatch(/^inbox-/);
expect(page1.nextCursor).toContain(page1.messages[0]!.messageId!); expect(page1.nextCursor).toContain(page1.messages[0]!.messageId!);
expect(page2.messages.every((message) => Boolean(message.messageId))).toBe(true); 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 () => { 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!, 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 { describe, expect, it } from 'vitest';
import { import {
normalizePersistedLaunchSnapshot,
snapshotToMemberSpawnStatuses, snapshotToMemberSpawnStatuses,
summarizePersistedLaunchMembers, summarizePersistedLaunchMembers,
} from '../../../../src/main/services/team/TeamLaunchStateEvaluator'; } from '../../../../src/main/services/team/TeamLaunchStateEvaluator';
@ -54,12 +55,13 @@ describe('TeamLaunchStateEvaluator', () => {
}); });
expect(statuses.bob).toMatchObject({ expect(statuses.bob).toMatchObject({
launchState: 'runtime_pending_permission', launchState: 'runtime_pending_permission',
status: 'online', status: 'waiting',
runtimeAlive: false,
pendingPermissionRequestIds: ['req-1'], 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'], { const summary = summarizePersistedLaunchMembers(['alice'], {
alice: { alice: {
launchState: 'runtime_pending_bootstrap', launchState: 'runtime_pending_bootstrap',
@ -75,7 +77,7 @@ describe('TeamLaunchStateEvaluator', () => {
confirmedCount: 0, confirmedCount: 0,
pendingCount: 2, pendingCount: 2,
failedCount: 0, failedCount: 0,
runtimeAlivePendingCount: 1, runtimeAlivePendingCount: 0,
shellOnlyPendingCount: 0, shellOnlyPendingCount: 0,
runtimeProcessPendingCount: 0, runtimeProcessPendingCount: 0,
runtimeCandidatePendingCount: 0, runtimeCandidatePendingCount: 0,
@ -83,4 +85,78 @@ describe('TeamLaunchStateEvaluator', () => {
permissionPendingCount: 1, 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 () => { it('prefers the newest matching agent pid when multiple processes match the same teammate', async () => {
const svc = new TeamProvisioningService(); const svc = new TeamProvisioningService();
(svc as any).configReader = { (svc as any).configReader = {
@ -836,8 +901,8 @@ describe('TeamProvisioningService', () => {
expect(snapshot.members.alice).toBeUndefined(); expect(snapshot.members.alice).toBeUndefined();
}); });
it('keeps pure OpenCode launch members alive from confirmed launch snapshot while runtime adapter is tracked', async () => { it('keeps historical bootstrap separate from current runtime liveness', async () => {
const teamName = 'pure-opencode-runtime-team'; const teamName = 'pure-opencode-runtime-team-strict';
const projectPath = '/Users/test/project'; const projectPath = '/Users/test/project';
writeLaunchConfig(teamName, projectPath, 'lead-session', ['alice']); writeLaunchConfig(teamName, projectPath, 'lead-session', ['alice']);
writeLaunchState(teamName, 'lead-session', { writeLaunchState(teamName, 'lead-session', {
@ -864,12 +929,102 @@ describe('TeamProvisioningService', () => {
const snapshot = await svc.getTeamAgentRuntimeSnapshot(teamName); const snapshot = await svc.getTeamAgentRuntimeSnapshot(teamName);
expect(snapshot.members.alice).toMatchObject({ expect(snapshot.members.alice).toMatchObject({
alive: true, alive: false,
historicalBootstrapConfirmed: true,
providerId: 'opencode', providerId: 'opencode',
runtimeModel: 'opencode/big-pickle', 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 () => { it('excludes removed meta members from live runtime metadata resolution', async () => {
const svc = new TeamProvisioningService(); const svc = new TeamProvisioningService();
(svc as any).configReader = { (svc as any).configReader = {
@ -1140,6 +1295,9 @@ describe('TeamProvisioningService', () => {
]; ];
(svc as any).aliveRunByTeam.set('runtime-team', 'run-1'); (svc as any).aliveRunByTeam.set('runtime-team', 'run-1');
(svc as any).runs.set('run-1', run); (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).mockReset();
vi.mocked(pidusage).mockImplementation( vi.mocked(pidusage).mockImplementation(
async (target: number | string | Array<number | string>) => { async (target: number | string | Array<number | string>) => {
@ -1216,6 +1374,9 @@ describe('TeamProvisioningService', () => {
), ),
}; };
vi.mocked(pidusage).mockReset(); vi.mocked(pidusage).mockReset();
vi.mocked(listRuntimeProcessesForCurrentTmuxPlatform).mockResolvedValue([
{ pid: 333, ppid: 1, command: 'opencode runtime host' },
]);
vi.mocked(pidusage).mockImplementation( vi.mocked(pidusage).mockImplementation(
async (target: number | string | Array<number | string>) => { async (target: number | string | Array<number | string>) => {
if (Array.isArray(target)) { if (Array.isArray(target)) {
@ -2238,6 +2399,7 @@ describe('TeamProvisioningService', () => {
pendingCount: 1, pendingCount: 1,
failedCount: 0, failedCount: 0,
runtimeAlivePendingCount: 1, runtimeAlivePendingCount: 1,
runtimeProcessPendingCount: 1,
}, },
{ {
version: 2, version: 2,
@ -2314,6 +2476,37 @@ describe('TeamProvisioningService', () => {
).toBe('Finishing launch — 1 teammate awaiting permission approval'); ).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', () => { it('trusts persisted snapshot permission state for pure teams when live run statuses are absent', () => {
const svc = new TeamProvisioningService(); const svc = new TeamProvisioningService();
const run = createMemberSpawnRun({ const run = createMemberSpawnRun({
@ -2330,6 +2523,7 @@ describe('TeamProvisioningService', () => {
pendingCount: 1, pendingCount: 1,
failedCount: 0, failedCount: 0,
runtimeAlivePendingCount: 1, runtimeAlivePendingCount: 1,
runtimeProcessPendingCount: 1,
}, },
{ {
version: 2, version: 2,
@ -2383,6 +2577,7 @@ describe('TeamProvisioningService', () => {
pendingCount: 1, pendingCount: 1,
failedCount: 0, failedCount: 0,
runtimeAlivePendingCount: 1, runtimeAlivePendingCount: 1,
runtimeProcessPendingCount: 1,
}, },
{ {
version: 2, version: 2,
@ -2403,6 +2598,7 @@ describe('TeamProvisioningService', () => {
runtimeAlive: true, runtimeAlive: true,
bootstrapConfirmed: false, bootstrapConfirmed: false,
hardFailure: false, hardFailure: false,
livenessKind: 'runtime_process',
lastEvaluatedAt: '2026-04-22T12:00:00.000Z', lastEvaluatedAt: '2026-04-22T12:00:00.000Z',
}, },
}, },
@ -2411,6 +2607,7 @@ describe('TeamProvisioningService', () => {
pendingCount: 1, pendingCount: 1,
failedCount: 0, failedCount: 0,
runtimeAlivePendingCount: 1, runtimeAlivePendingCount: 1,
runtimeProcessPendingCount: 1,
}, },
teamLaunchState: 'partial_pending', teamLaunchState: 'partial_pending',
} }
@ -2420,6 +2617,24 @@ describe('TeamProvisioningService', () => {
expect(message).not.toContain('/0'); 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', () => { it('uses the union of persisted expected members and persisted member entries for pending launch copy', () => {
const svc = new TeamProvisioningService(); const svc = new TeamProvisioningService();
const run = createMemberSpawnRun({ 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 () => { it('preserves richer persisted expectedMembers when OpenCode runtime liveness updates a stale snapshot', async () => {
const svc = new TeamProvisioningService(); const svc = new TeamProvisioningService();
const previousSnapshot = { const previousSnapshot = {
@ -4356,6 +4647,107 @@ describe('TeamProvisioningService', () => {
}); });
expect(run.pendingMemberRestarts.has('bob')).toBe(false); 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 () => { it('removes generated MCP config when createTeam spawn fails synchronously', async () => {
@ -5753,7 +6145,19 @@ describe('TeamProvisioningService', () => {
); );
const svc = new 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); const result = await svc.getMemberSpawnStatuses(teamName);
@ -6098,14 +6502,12 @@ describe('TeamProvisioningService', () => {
); );
const svc = new TeamProvisioningService(); const svc = new TeamProvisioningService();
(svc as any).getLiveTeamAgentNames = vi.fn(() => new Set(['jack']));
const result = await svc.getMemberSpawnStatuses(teamName); const result = await svc.getMemberSpawnStatuses(teamName);
expect(result.statuses.jack).toMatchObject({ expect(result.statuses.jack).toMatchObject({
status: 'error', status: 'error',
launchState: 'failed_to_start', launchState: 'failed_to_start',
runtimeAlive: true,
}); });
expect(result.statuses.jack?.error).toContain('requested model is not available'); expect(result.statuses.jack?.error).toContain('requested model is not available');
expect(result.statuses.jack?.hardFailureReason).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); 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(); const run = createMemberSpawnRun();
run.activeToolCalls.set('tool-agent-1', { run.activeToolCalls.set('tool-agent-1', {
memberName: 'alice', memberName: 'alice',
@ -6477,10 +6879,9 @@ describe('TeamProvisioningService', () => {
); );
expect(run.memberSpawnStatuses.get('alice')).toMatchObject({ expect(run.memberSpawnStatuses.get('alice')).toMatchObject({
status: 'online', status: 'waiting',
launchState: 'runtime_pending_bootstrap', launchState: 'runtime_pending_bootstrap',
runtimeAlive: true, runtimeAlive: false,
livenessSource: 'process',
hardFailure: false, hardFailure: false,
}); });
}); });
@ -6629,6 +7030,7 @@ describe('TeamProvisioningService', () => {
{ {
alive: true, alive: true,
model: 'gpt-5.2', model: 'gpt-5.2',
livenessKind: 'runtime_process',
}, },
], ],
]) ])
@ -6666,6 +7068,7 @@ describe('TeamProvisioningService', () => {
{ {
alive: true, alive: true,
model: 'gpt-5.2', 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 () => { it('does not clear an explicit restart failure just because the old runtime is still alive', async () => {
const svc = new TeamProvisioningService(); const svc = new TeamProvisioningService();
(svc as any).getLiveTeamAgentRuntimeMetadata = vi.fn( (svc as any).getLiveTeamAgentRuntimeMetadata = vi.fn(
@ -6803,7 +7330,6 @@ describe('TeamProvisioningService', () => {
expect(result.bob).toMatchObject({ expect(result.bob).toMatchObject({
status: 'error', status: 'error',
launchState: 'failed_to_start', launchState: 'failed_to_start',
runtimeAlive: true,
hardFailure: true, hardFailure: true,
hardFailureReason: 'Teammate did not join within the launch grace window.', hardFailureReason: 'Teammate did not join within the launch grace window.',
error: '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 teamName = 'suffixed-live-runtime-team';
const leadSessionId = 'lead-session'; const leadSessionId = 'lead-session';
writeLaunchConfig(teamName, '/Users/test/proj', leadSessionId, ['alice']); writeLaunchConfig(teamName, '/Users/test/proj', leadSessionId, ['alice']);
@ -7039,14 +7565,26 @@ describe('TeamProvisioningService', () => {
}); });
const svc = new 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); const result = await svc.getMemberSpawnStatuses(teamName);
expect(result.statuses.alice).toMatchObject({ expect(result.statuses.alice).toMatchObject({
status: 'online', status: 'waiting',
launchState: 'runtime_pending_bootstrap', launchState: 'runtime_pending_bootstrap',
runtimeAlive: true, runtimeAlive: false,
}); });
}); });
@ -7105,7 +7643,7 @@ describe('TeamProvisioningService', () => {
2 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); 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 // @vitest-environment node
import { afterEach, describe, expect, it, vi } from 'vitest'; import { afterEach, describe, expect, it, vi } from 'vitest';
const getConfigMock = vi.fn();
vi.mock('@main/services/infrastructure/ConfigManager', () => ({
configManager: {
getConfig: () => getConfigMock(),
},
}));
describe('cliFlavor', () => { describe('cliFlavor', () => {
afterEach(() => { afterEach(() => {
delete process.env.CLAUDE_TEAM_CLI_FLAVOR; delete process.env.CLAUDE_TEAM_CLI_FLAVOR;
@ -16,37 +8,20 @@ describe('cliFlavor', () => {
vi.clearAllMocks(); vi.clearAllMocks();
}); });
it('uses multimodel runtime by default when config enables it', async () => { it('uses multimodel runtime by default', async () => {
getConfigMock.mockReturnValue({
general: {
multimodelEnabled: true,
},
});
const { getConfiguredCliFlavor } = await import('@main/services/team/cliFlavor'); const { getConfiguredCliFlavor } = await import('@main/services/team/cliFlavor');
expect(getConfiguredCliFlavor()).toBe('agent_teams_orchestrator'); expect(getConfiguredCliFlavor()).toBe('agent_teams_orchestrator');
}); });
it('uses claude runtime when multimodel is disabled in config', async () => { it('ignores the legacy persisted multimodel flag', async () => {
getConfigMock.mockReturnValue({
general: {
multimodelEnabled: false,
},
});
const { getConfiguredCliFlavor } = await import('@main/services/team/cliFlavor'); 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'; process.env.CLAUDE_TEAM_CLI_FLAVOR = 'claude';
getConfigMock.mockReturnValue({
general: {
multimodelEnabled: true,
},
});
const { getConfiguredCliFlavor } = await import('@main/services/team/cliFlavor'); const { getConfiguredCliFlavor } = await import('@main/services/team/cliFlavor');

View file

@ -3,6 +3,7 @@ import { describe, expect, it } from 'vitest';
import { import {
PROGRESS_LOG_TAIL_LINES, PROGRESS_LOG_TAIL_LINES,
PROGRESS_OUTPUT_TAIL_PARTS, PROGRESS_OUTPUT_TAIL_PARTS,
boundLaunchDiagnostics,
buildProgressAssistantOutput, buildProgressAssistantOutput,
buildProgressLogsTail, buildProgressLogsTail,
} from '../../../../src/main/services/team/progressPayload'; } from '../../../../src/main/services/team/progressPayload';
@ -75,3 +76,32 @@ describe('buildProgressAssistantOutput', () => {
expect(result!.split('\n\n')).toHaveLength(PROGRESS_OUTPUT_TAIL_PARTS); 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', () => { 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', () => { it('should handle empty string', () => {
@ -177,6 +177,10 @@ describe('pathDecoder', () => {
}); });
it('should return true for valid Windows-style encoded path', () => { 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); 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(); 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); vi.stubGlobal('IS_REACT_ACT_ENVIRONMENT', true);
const host = document.createElement('div'); const host = document.createElement('div');
document.body.appendChild(host); document.body.appendChild(host);
@ -340,8 +340,7 @@ describe('CLI status visibility during completed install state', () => {
expect(host.textContent).toContain('Login'); expect(host.textContent).toContain('Login');
const toggle = host.querySelector('[data-testid="multimodel-toggle"]'); const toggle = host.querySelector('[data-testid="multimodel-toggle"]');
expect(toggle).not.toBeNull(); expect(toggle).toBeNull();
expect(toggle?.hasAttribute('disabled')).toBe(false);
await act(async () => { await act(async () => {
root.unmount(); root.unmount();

View file

@ -3,13 +3,8 @@ import { createRoot } from 'react-dom/client';
import { afterEach, describe, expect, it, vi } from 'vitest'; import { afterEach, describe, expect, it, vi } from 'vitest';
vi.mock('@renderer/components/ui/button', () => ({ vi.mock('@renderer/components/ui/button', () => ({
Button: ({ Button: ({ children, onClick }: { children: React.ReactNode; onClick?: () => void }) =>
children, React.createElement('button', { type: 'button', onClick }, children),
onClick,
}: {
children: React.ReactNode;
onClick?: () => void;
}) => React.createElement('button', { type: 'button', onClick }, children),
})); }));
vi.mock('@renderer/components/chat/viewers/MarkdownViewer', () => ({ vi.mock('@renderer/components/chat/viewers/MarkdownViewer', () => ({
@ -77,4 +72,117 @@ describe('ProvisioningProgressBlock', () => {
await Promise.resolve(); 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.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 () => { await act(async () => {
root.unmount(); 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); vi.stubGlobal('IS_REACT_ACT_ENVIRONMENT', true);
const host = document.createElement('div'); const host = document.createElement('div');
document.body.appendChild(host); document.body.appendChild(host);
@ -324,9 +324,9 @@ describe('MemberCard starting-state visuals', () => {
await Promise.resolve(); await Promise.resolve();
}); });
expect(host.textContent).toContain('connecting'); expect(host.textContent).toContain('waiting for bootstrap');
expect(host.textContent).not.toContain('ready'); 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 () => { await act(async () => {
root.unmount(); root.unmount();
@ -430,4 +430,156 @@ describe('MemberCard starting-state visuals', () => {
await Promise.resolve(); 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', () => ({ vi.mock('@renderer/components/ui/button', () => ({
Button: ({ Button: ({ children, onClick }: { children: React.ReactNode; onClick?: () => void }) =>
children,
onClick,
}: {
children: React.ReactNode;
onClick?: () => void;
}) =>
React.createElement( React.createElement(
'button', 'button',
{ {
@ -33,7 +27,8 @@ vi.mock('@renderer/components/ui/button', () => ({
})); }));
vi.mock('@renderer/components/ui/dialog', () => ({ 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 }) => DialogContent: ({ children }: { children: React.ReactNode }) =>
React.createElement('div', null, children), React.createElement('div', null, children),
DialogFooter: ({ children }: { children: React.ReactNode }) => DialogFooter: ({ children }: { children: React.ReactNode }) =>
@ -42,6 +37,15 @@ vi.mock('@renderer/components/ui/dialog', () => ({
React.createElement('div', null, children), 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', () => { vi.mock('@renderer/components/ui/tabs', () => {
let currentValue = ''; let currentValue = '';
let currentOnValueChange: ((value: string) => void) | null = null; let currentOnValueChange: ((value: string) => void) | null = null;
@ -60,14 +64,9 @@ vi.mock('@renderer/components/ui/tabs', () => {
currentOnValueChange = onValueChange ?? null; currentOnValueChange = onValueChange ?? null;
return React.createElement('div', { 'data-tabs-value': value }, children); return React.createElement('div', { 'data-tabs-value': value }, children);
}, },
TabsList: ({ children }: { children: React.ReactNode }) => React.createElement('div', null, children), TabsList: ({ children }: { children: React.ReactNode }) =>
TabsTrigger: ({ React.createElement('div', null, children),
children, TabsTrigger: ({ children, value }: { children: React.ReactNode; value: string }) =>
value,
}: {
children: React.ReactNode;
value: string;
}) =>
React.createElement( React.createElement(
'button', 'button',
{ {
@ -77,13 +76,8 @@ vi.mock('@renderer/components/ui/tabs', () => {
}, },
children children
), ),
TabsContent: ({ TabsContent: ({ children, value }: { children: React.ReactNode; value: string }) =>
children, currentValue === value ? React.createElement('div', null, children) : null,
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', () => ({ vi.mock('@renderer/components/team/members/MemberDetailStats', () => ({
MemberDetailStats: ({ activityCount }: { activityCount: number }) => 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', () => ({ vi.mock('@renderer/components/team/members/MemberTasksTab', () => ({
@ -210,4 +208,132 @@ describe('MemberDetailDialog activity count', () => {
await Promise.resolve(); 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', () => ({ 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 }) => DialogDescription: ({ children }: { children: React.ReactNode }) =>
React.createElement('div', null, children), 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); vi.stubGlobal('IS_REACT_ACT_ENVIRONMENT', true);
const host = document.createElement('div'); const host = document.createElement('div');
document.body.appendChild(host); document.body.appendChild(host);
@ -121,9 +122,9 @@ describe('MemberDetailHeader spawn-aware presence', () => {
await Promise.resolve(); await Promise.resolve();
}); });
expect(host.textContent).toContain('connecting'); expect(host.textContent).toContain('waiting for bootstrap');
expect(host.textContent).not.toContain('online'); 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 () => { await act(async () => {
root.unmount(); root.unmount();

View file

@ -45,6 +45,12 @@ const storeState = {
updatedAt: string; updatedAt: string;
runtimeAlive: boolean; runtimeAlive: boolean;
livenessSource?: string; livenessSource?: string;
livenessKind?: string;
runtimeDiagnostic?: string;
runtimeDiagnosticSeverity?: string;
error?: string;
hardFailure?: boolean;
hardFailureReason?: string;
} }
> >
>, >,
@ -52,6 +58,13 @@ const storeState = {
'northstar-core': undefined, 'northstar-core': undefined,
} as Record<string, unknown>, } as Record<string, unknown>,
leadActivityByTeam: {}, leadActivityByTeam: {},
teamAgentRuntimeByTeam: {} as Record<
string,
{
runId: string | null;
members: Record<string, Record<string, unknown>>;
}
>,
openMemberProfile: vi.fn(), openMemberProfile: vi.fn(),
}; };
@ -61,7 +74,11 @@ vi.mock('@renderer/store', () => ({
vi.mock('@renderer/store/slices/teamSlice', () => ({ vi.mock('@renderer/store/slices/teamSlice', () => ({
getCurrentProvisioningProgressForTeam: () => storeState.progress, 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( (state.selectedTeamName === teamName ? state.selectedTeamData : null)?.members.find(
(candidate) => candidate.name === memberName (candidate) => candidate.name === memberName
) ?? null, ) ?? null,
@ -91,6 +108,15 @@ vi.mock('@renderer/components/ui/hover-card', () => ({
React.createElement('div', null, children), 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', () => ({ vi.mock('@renderer/components/team/members/CurrentTaskIndicator', () => ({
CurrentTaskIndicator: () => null, CurrentTaskIndicator: () => null,
})); }));
@ -116,6 +142,7 @@ describe('MemberHoverCard spawn-aware presence', () => {
runtimeAlive: false, runtimeAlive: false,
}; };
storeState.memberSpawnSnapshotsByTeam['northstar-core'] = undefined; storeState.memberSpawnSnapshotsByTeam['northstar-core'] = undefined;
storeState.teamAgentRuntimeByTeam = {};
storeState.openMemberProfile.mockReset(); 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); vi.stubGlobal('IS_REACT_ACT_ENVIRONMENT', true);
storeState.progress = { storeState.progress = {
runId: 'run-1', runId: 'run-1',
@ -188,9 +215,9 @@ describe('MemberHoverCard spawn-aware presence', () => {
await Promise.resolve(); await Promise.resolve();
}); });
expect(host.textContent).toContain('connecting'); expect(host.textContent).toContain('starting');
expect(host.textContent).not.toContain('online'); 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 () => { await act(async () => {
root.unmount(); 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); vi.stubGlobal('IS_REACT_ACT_ENVIRONMENT', true);
storeState.progress = null; storeState.progress = null;
storeState.memberSpawnStatusesByTeam['northstar-core'].alice = { storeState.memberSpawnStatusesByTeam['northstar-core'].alice = {
@ -224,9 +251,9 @@ describe('MemberHoverCard spawn-aware presence', () => {
await Promise.resolve(); await Promise.resolve();
}); });
expect(host.textContent).toContain('connecting'); expect(host.textContent).toContain('waiting for bootstrap');
expect(host.textContent).not.toContain('online'); 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 () => { await act(async () => {
root.unmount(); root.unmount();
@ -279,4 +306,96 @@ describe('MemberHoverCard spawn-aware presence', () => {
await Promise.resolve(); 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', () => { 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 = ''; document.body.innerHTML = '';
useStore.setState({
selectedTeamName: null,
selectedTeamData: null,
teamDataCacheByName: {},
} as never);
vi.unstubAllGlobals(); vi.unstubAllGlobals();
}); });
@ -138,45 +141,48 @@ describe('GraphNodePopover spawn badge labels', () => {
it('reuses launch-aware presence semantics from cached team data', async () => { it('reuses launch-aware presence semantics from cached team data', async () => {
vi.stubGlobal('IS_REACT_ACT_ENVIRONMENT', true); vi.stubGlobal('IS_REACT_ACT_ENVIRONMENT', true);
useStore.setState({ await act(async () => {
teamDataCacheByName: { useStore.setState({
'northstar-core': { teamDataCacheByName: {
teamName: 'northstar-core', 'northstar-core': {
config: { name: 'Northstar', members: [], projectPath: '/repo' }, teamName: 'northstar-core',
members: [ config: { name: 'Northstar', members: [], projectPath: '/repo' },
{ members: [
name: 'alice', {
status: 'active', name: 'alice',
currentTaskId: null, status: 'active',
taskCount: 0, currentTaskId: null,
lastActiveAt: null, taskCount: 0,
messageCount: 0, lastActiveAt: null,
agentType: 'reviewer', messageCount: 0,
providerId: 'codex', agentType: 'reviewer',
}, providerId: 'codex',
], },
tasks: [], ],
messages: [], tasks: [],
kanbanState: { teamName: 'northstar-core', reviewers: [], tasks: {} }, messages: [],
processes: [], kanbanState: { teamName: 'northstar-core', reviewers: [], tasks: {} },
isAlive: true, processes: [],
}, isAlive: true,
},
memberSpawnStatusesByTeam: {
'northstar-core': {
alice: {
status: 'online',
launchState: 'runtime_pending_bootstrap',
livenessSource: 'process',
runtimeAlive: true,
}, },
}, },
}, memberSpawnStatusesByTeam: {
memberSpawnSnapshotsByTeam: {}, 'northstar-core': {
currentProvisioningRunIdByTeam: {}, alice: {
provisioningRuns: {}, status: 'online',
leadActivityByTeam: {}, launchState: 'runtime_pending_bootstrap',
} as never); livenessSource: 'process',
runtimeAlive: true,
},
},
},
memberSpawnSnapshotsByTeam: {},
currentProvisioningRunIdByTeam: {},
provisioningRuns: {},
leadActivityByTeam: {},
} as never);
await Promise.resolve();
});
const host = document.createElement('div'); const host = document.createElement('div');
document.body.appendChild(host); document.body.appendChild(host);
@ -193,7 +199,7 @@ describe('GraphNodePopover spawn badge labels', () => {
await Promise.resolve(); await Promise.resolve();
}); });
expect(host.textContent).toContain('connecting'); expect(host.textContent).toContain('waiting for bootstrap');
expect(host.textContent).not.toContain('Idle'); expect(host.textContent).not.toContain('Idle');
await act(async () => { 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 () => { 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); vi.stubGlobal('IS_REACT_ACT_ENVIRONMENT', true);
useStore.setState({ await act(async () => {
selectedTeamName: 'northstar-core', useStore.setState({
selectedTeamData: { selectedTeamName: 'northstar-core',
teamName: 'northstar-core', selectedTeamData: {
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': {
teamName: 'northstar-core', teamName: 'northstar-core',
config: { name: 'Northstar', members: [], projectPath: '/repo' }, config: { name: 'Northstar', members: [], projectPath: '/repo' },
tasks: [ tasks: [
@ -283,8 +251,49 @@ describe('GraphNodePopover spawn badge labels', () => {
}, },
processes: [], processes: [],
}, },
}, teamDataCacheByName: {
} as never); '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 onOpenTaskDetail = vi.fn();
const host = document.createElement('div'); const host = document.createElement('div');

View file

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

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