diff --git a/README.md b/README.md index b4e7aacc..669e9c28 100644 --- a/README.md +++ b/README.md @@ -320,6 +320,7 @@ pnpm dist # macOS + Windows + Linux - [ ] Run terminal commands - [ ] Monitor agents processes/stats - [ ] Reusable agents with SOUL.md +- [ ] Сommunicate via messenger --- diff --git a/agent-teams-controller/src/internal/crossTeam.js b/agent-teams-controller/src/internal/crossTeam.js index 474218b9..9b9a3834 100644 --- a/agent-teams-controller/src/internal/crossTeam.js +++ b/agent-teams-controller/src/internal/crossTeam.js @@ -48,25 +48,19 @@ function normalizeMetaMembers(rawMembers) { } function resolveTargetLead(paths, config) { - // 1. config.members — agentType check + // 1. config.members - canonical lead detection shared with queue routing if (config && config.members && config.members.length) { - const lead = config.members.find((m) => m && m.agentType === 'team-lead'); + const lead = config.members.find((m) => runtimeHelpers.isCanonicalLeadMember(m)); if (lead && lead.name) return String(lead.name).trim(); - - // 2. config.members — name check - const namedLead = config.members.find((m) => m && m.name === 'team-lead'); - if (namedLead && namedLead.name) return String(namedLead.name).trim(); } - // 3. members.meta.json — WITH normalization (trim + dedup) + // 2. members.meta.json - WITH normalization (trim + dedup) const metaPath = path.join(paths.teamDir, 'members.meta.json'); try { const raw = JSON.parse(fs.readFileSync(metaPath, 'utf8')); const members = normalizeMetaMembers(raw && raw.members); if (members.length > 0) { - const metaLead = members.find( - (m) => m.agentType === 'team-lead' || m.name === 'team-lead' - ); + const metaLead = members.find((m) => runtimeHelpers.isCanonicalLeadMember(m)); if (metaLead && metaLead.name) return metaLead.name; return members[0].name; } @@ -74,13 +68,8 @@ function resolveTargetLead(paths, config) { /* ENOENT or parse error */ } - // 4. role-based (legacy compat) + // 3. First configured member if (config && config.members && config.members.length) { - const roleLead = config.members.find( - (m) => m && m.role && String(m.role).toLowerCase().includes('lead') - ); - if (roleLead && roleLead.name) return String(roleLead.name).trim(); - // 5. First member if (config.members[0] && config.members[0].name) return String(config.members[0].name).trim(); } @@ -165,7 +154,7 @@ function findRecentDuplicate(outboxList, dedupeKey) { function sendCrossTeamMessage(context, flags) { const fromTeam = context.teamName; const toTeam = typeof flags.toTeam === 'string' ? flags.toTeam.trim() : ''; - const rawFromMember = typeof flags.fromMember === 'string' ? flags.fromMember.trim() : 'team-lead'; + const rawFromMember = typeof flags.fromMember === 'string' ? flags.fromMember.trim() : ''; const replyToConversationId = typeof flags.replyToConversationId === 'string' ? flags.replyToConversationId.trim() : ''; const conversationId = @@ -181,6 +170,10 @@ function sendCrossTeamMessage(context, flags) { if (!TEAM_NAME_PATTERN.test(fromTeam)) { throw new Error(`Invalid fromTeam: ${fromTeam}`); } + const sourceConfig = runtimeHelpers.readTeamConfig(context.paths); + if (!sourceConfig || sourceConfig.deletedAt) { + throw new Error(`Source team not found: ${fromTeam}`); + } if (!TEAM_NAME_PATTERN.test(toTeam)) { throw new Error(`Invalid toTeam: ${toTeam}`); } @@ -190,14 +183,11 @@ function sendCrossTeamMessage(context, flags) { if (!text || text.trim().length === 0) { throw new Error('Message text is required'); } - const fromMember = runtimeHelpers.assertExplicitTeamMemberName( - context.paths, - rawFromMember, - 'fromMember', - { - allowLeadAliases: true, - } - ); + const fromMember = rawFromMember + ? runtimeHelpers.assertExplicitTeamMemberName(context.paths, rawFromMember, 'cross-team sender', { + allowLeadAliases: true, + }) + : runtimeHelpers.inferLeadName(context.paths); // Target context + config const targetContext = createTargetContext(context, toTeam); diff --git a/agent-teams-controller/src/internal/kanban.js b/agent-teams-controller/src/internal/kanban.js index e195522b..d91912c5 100644 --- a/agent-teams-controller/src/internal/kanban.js +++ b/agent-teams-controller/src/internal/kanban.js @@ -101,12 +101,12 @@ function listReviewers(context) { function addReviewer(context, reviewer) { return withTeamBoardLock(context.paths, () => { - runtimeHelpers.assertExplicitTeamMemberName(context.paths, reviewer, 'reviewer', { + const resolvedReviewer = runtimeHelpers.assertExplicitTeamMemberName(context.paths, reviewer, 'reviewer', { allowLeadAliases: true, }); const state = getKanbanState(context); const next = new Set(state.reviewers); - next.add(String(reviewer)); + next.add(String(resolvedReviewer)); kanbanStore.writeKanbanState(context.paths, context.teamName, { ...state, reviewers: [...next], @@ -118,7 +118,13 @@ function addReviewer(context, reviewer) { function removeReviewer(context, reviewer) { return withTeamBoardLock(context.paths, () => { const state = getKanbanState(context); - const next = state.reviewers.filter((entry) => entry !== reviewer); + const resolvedReviewer = runtimeHelpers.resolveExplicitTeamMemberName(context.paths, reviewer, { + allowLeadAliases: true, + }); + const reviewerNames = new Set( + [reviewer, resolvedReviewer].filter((entry) => typeof entry === 'string' && entry.trim()) + ); + const next = state.reviewers.filter((entry) => !reviewerNames.has(entry)); kanbanStore.writeKanbanState(context.paths, context.teamName, { ...state, reviewers: next, diff --git a/agent-teams-controller/src/internal/review.js b/agent-teams-controller/src/internal/review.js index dc56da68..1add991d 100644 --- a/agent-teams-controller/src/internal/review.js +++ b/agent-teams-controller/src/internal/review.js @@ -69,10 +69,9 @@ function normalizeActorKey(value) { function resolveKnownActorName(context, value, label) { const actor = typeof value === 'string' && value.trim() ? value.trim() : ''; if (!actor) return null; - runtimeHelpers.assertExplicitTeamMemberName(context.paths, actor, label, { + return runtimeHelpers.assertExplicitTeamMemberName(context.paths, actor, label, { allowLeadAliases: true, }); - return actor; } function tryResolveKnownActorName(context, value, label) { @@ -301,9 +300,10 @@ function requestReview(context, taskId, flags = {}) { } const nextFrom = - resolveKnownActorName(context, flags.from, 'review requester') || 'team-lead'; + resolveKnownActorName(context, flags.from, 'review requester') || + resolveKnownActorName(context, 'team-lead', 'review requester'); const rawReviewer = getReviewer(context, flags); - const nextReviewer = rawReviewer ? (resolveKnownActorName(context, rawReviewer, 'reviewer'), rawReviewer) : null; + const nextReviewer = rawReviewer ? resolveKnownActorName(context, rawReviewer, 'reviewer') : null; const prevReviewState = getEffectiveReviewState(context, currentTask); if (prevReviewState === 'approved') { throw new Error(`Task #${currentTask.displayId || currentTask.id} is already approved; reopen work before requesting another review`); diff --git a/agent-teams-controller/src/internal/reviewState.js b/agent-teams-controller/src/internal/reviewState.js index 64daa740..0eb5465e 100644 --- a/agent-teams-controller/src/internal/reviewState.js +++ b/agent-teams-controller/src/internal/reviewState.js @@ -80,19 +80,46 @@ function getEffectiveReviewState(task, kanbanEntry) { return historyState; } + const status = typeof task?.status === 'string' ? task.status.trim() : ''; + const normalizeFallback = (state, source) => { + const normalized = normalizeReviewState(state); + if (normalized === 'none') { + return null; + } + + if (status === 'in_progress' || status === 'deleted') { + return { + state: 'none', + source: `${source}_status_reset`, + }; + } + + if (status === 'pending') { + return normalized === 'needsFix' + ? { state: 'needsFix', source: `${source}_pending_needs_fix` } + : { state: 'none', source: `${source}_pending_reset` }; + } + + if (status === 'completed') { + return normalized === 'review' || normalized === 'approved' + ? { state: normalized, source } + : { state: 'none', source: `${source}_completed_reset` }; + } + + return { state: normalized, source }; + }; + const persisted = normalizeReviewState(task && task.reviewState); - if (persisted !== 'none') { - return { - state: persisted, - source: 'task_review_state', - }; + const persistedFallback = normalizeFallback(persisted, 'task_review_state'); + if (persistedFallback) { + return persistedFallback; } if (kanbanEntry && REVIEW_COLUMNS.has(kanbanEntry.column)) { - return { - state: normalizeReviewState(kanbanEntry.column), - source: 'kanban_column', - }; + const kanbanFallback = normalizeFallback(kanbanEntry.column, 'kanban_column'); + if (kanbanFallback) { + return kanbanFallback; + } } return { diff --git a/agent-teams-controller/src/internal/runtimeHelpers.js b/agent-teams-controller/src/internal/runtimeHelpers.js index e78da93c..91003302 100644 --- a/agent-teams-controller/src/internal/runtimeHelpers.js +++ b/agent-teams-controller/src/internal/runtimeHelpers.js @@ -5,6 +5,7 @@ const crypto = require('crypto'); const TASK_ATTACHMENTS_DIR = 'task-attachments'; const MAX_TASK_ATTACHMENT_BYTES = 20 * 1024 * 1024; const TEAM_NAME_PATTERN = /^[a-z0-9][a-z0-9-]{0,127}$/; +const LEAD_AGENT_TYPES = new Set(['team-lead', 'lead', 'orchestrator']); const CROSS_TEAM_TOOL_RECIPIENT_NAMES = new Set([ 'cross_team_send', 'cross_team_list_targets', @@ -134,7 +135,7 @@ function isCanonicalLeadMember(member) { const role = typeof member.role === 'string' ? member.role.trim().toLowerCase() : ''; const name = typeof member.name === 'string' ? member.name.trim().toLowerCase() : ''; return ( - agentType === 'team-lead' || + LEAD_AGENT_TYPES.has(agentType) || name === 'team-lead' || role === 'team-lead' || role === 'team lead' || @@ -179,12 +180,10 @@ function inferLeadName(paths) { const resolved = resolveTeamMembers(paths); const members = resolved.members || []; const lead = - members.find( - (member) => - member && - typeof member.agentType === 'string' && - member.agentType.trim().toLowerCase() === 'team-lead' - ) || + members.find((member) => { + const agentType = typeof member?.agentType === 'string' ? member.agentType.trim().toLowerCase() : ''; + return LEAD_AGENT_TYPES.has(agentType); + }) || members.find((member) => String((member && member.name) || '').trim().toLowerCase() === 'team-lead') || members.find( (member) => { diff --git a/agent-teams-controller/src/internal/tasks.js b/agent-teams-controller/src/internal/tasks.js index d4f464fb..cbedb1bf 100644 --- a/agent-teams-controller/src/internal/tasks.js +++ b/agent-teams-controller/src/internal/tasks.js @@ -215,19 +215,23 @@ function maybeNotifyTaskOwnerOnComment(context, task, comment, options = {}) { } function createTask(context, input) { + let taskInput = input; if (input && typeof input.owner === 'string' && input.owner.trim()) { - assertKnownTaskActor(context, input.owner, 'task owner'); + taskInput = { + ...input, + owner: assertKnownTaskActor(context, input.owner, 'task owner'), + }; } - const task = withTeamBoardLock(context.paths, () => taskStore.createTask(context.paths, input)); - if (input && input.notifyOwner !== false) { + const task = withTeamBoardLock(context.paths, () => taskStore.createTask(context.paths, taskInput)); + if (taskInput && taskInput.notifyOwner !== false) { maybeNotifyAssignedOwner(context, task, { - description: input.description, - prompt: input.prompt, + description: taskInput.description, + prompt: taskInput.prompt, taskRefs: [ - ...(Array.isArray(input.descriptionTaskRefs) ? input.descriptionTaskRefs : []), - ...(Array.isArray(input.promptTaskRefs) ? input.promptTaskRefs : []), + ...(Array.isArray(taskInput.descriptionTaskRefs) ? taskInput.descriptionTaskRefs : []), + ...(Array.isArray(taskInput.promptTaskRefs) ? taskInput.promptTaskRefs : []), ], - from: input.from, + from: taskInput.from, }); } return task; @@ -410,6 +414,10 @@ function softDeleteTask(context, taskId, actor) { function restoreTask(context, taskId, actor) { return withTeamBoardLock(context.paths, () => { + const before = taskStore.readTask(context.paths, taskId, { includeDeleted: true }); + if (before.status !== 'deleted') { + throw new Error(`Task #${before.displayId || before.id} is not deleted; task_restore only restores deleted tasks`); + } let task = taskStore.setTaskStatus(context.paths, taskId, 'pending', actor || 'user'); const state = kanbanStore.readKanbanState(context.paths, context.teamName); if (hasKanbanReference(state, task.id)) { @@ -431,7 +439,7 @@ function setTaskOwner(context, taskId, owner) { const before = taskStore.readTask(context.paths, taskId, { includeDeleted: true }); const nextOwner = isClearOwnerValue(owner) ? owner - : (assertKnownTaskActor(context, owner, 'task owner'), owner); + : assertKnownTaskActor(context, owner, 'task owner'); const after = taskStore.setTaskOwner(context.paths, taskId, nextOwner); return { previousTask: before, diff --git a/agent-teams-controller/src/mcpToolCatalog.js b/agent-teams-controller/src/mcpToolCatalog.js index bcdeacf3..d896dd1d 100644 --- a/agent-teams-controller/src/mcpToolCatalog.js +++ b/agent-teams-controller/src/mcpToolCatalog.js @@ -11,6 +11,7 @@ const AGENT_TEAMS_TASK_TOOL_NAMES = [ 'task_get_comment', 'task_link', 'task_list', + 'task_restore', 'task_set_clarification', 'task_set_owner', 'task_set_status', diff --git a/agent-teams-controller/test/controller.test.js b/agent-teams-controller/test/controller.test.js index 38961ba1..0ca9abcd 100644 --- a/agent-teams-controller/test/controller.test.js +++ b/agent-teams-controller/test/controller.test.js @@ -447,6 +447,31 @@ describe('agent-teams-controller API', () => { expect(briefing).toContain('Counters: actionable=4, awareness=3'); }); + it('treats stale legacy terminal reviewState on pending tasks as owner-ready work', async () => { + const claudeDir = makeClaudeDir(); + const controller = createController({ teamName: 'my-team', claudeDir }); + + const staleTask = controller.tasks.createTask({ + subject: 'Legacy stale approved task', + owner: 'bob', + status: 'pending', + reviewState: 'approved', + notifyOwner: false, + }); + + const briefing = await controller.tasks.taskBriefing('bob'); + const staleLine = briefing.split('\n').find((line) => line.includes(`#${staleTask.displayId}`)); + expect(staleLine).toContain('[status=pending]'); + expect(staleLine).not.toContain('review='); + expect(staleLine).toContain('reason=owner_ready'); + + const rows = controller.tasks.listTaskInventory({ owner: 'bob' }); + expect(rows.find((row) => row.id === staleTask.id)).toMatchObject({ + status: 'pending', + reviewState: 'none', + }); + }); + it('reconciles stale kanban rows and linked inbox comments idempotently', () => { const claudeDir = makeClaudeDir(); const controller = createController({ teamName: 'my-team', claudeDir }); @@ -1281,6 +1306,82 @@ describe('agent-teams-controller API', () => { expect(leadBriefing).not.toContain(`#${task.displayId}`); }); + it('recognizes lead and orchestrator agent types as canonical team leads', async () => { + const claudeDir = makeClaudeDir(); + const configPath = path.join(claudeDir, 'teams', 'my-team', 'config.json'); + fs.writeFileSync( + configPath, + JSON.stringify( + { + name: 'my-team', + leadSessionId: 'lead-session-1', + members: [ + { name: 'alice', role: 'developer' }, + { name: 'leadbot', agentType: 'lead' }, + { name: 'opsbot', agentType: 'orchestrator' }, + ], + }, + null, + 2 + ) + ); + + const controller = createController({ teamName: 'my-team', claudeDir }); + const aliceTask = controller.tasks.createTask({ subject: 'Alice owns this', owner: 'alice' }); + const leadTask = controller.tasks.createTask({ subject: 'Lead owns this', owner: 'leadbot' }); + const aliceBriefing = await controller.tasks.taskBriefing('alice'); + const leadBriefing = await controller.tasks.leadBriefing(); + + expect(aliceBriefing).toContain(`#${aliceTask.displayId}`); + expect(aliceBriefing).toContain('actionOwner=@alice'); + expect(aliceBriefing).not.toContain(`#${leadTask.displayId}`); + expect(leadBriefing).toContain(`#${leadTask.displayId}`); + expect(leadBriefing).not.toContain(`#${aliceTask.displayId}`); + }); + + it('stores canonical member names for lead aliases in owners, reviewers, and reviewer config', () => { + const claudeDir = makeClaudeDir(); + const configPath = path.join(claudeDir, 'teams', 'my-team', 'config.json'); + fs.writeFileSync( + configPath, + JSON.stringify( + { + name: 'my-team', + members: [ + { name: 'leadbot', agentType: 'lead' }, + { name: 'alice', role: 'reviewer' }, + { name: 'bob', role: 'developer' }, + ], + }, + null, + 2 + ) + ); + + const controller = createController({ teamName: 'my-team', claudeDir }); + const leadOwnedTask = controller.tasks.createTask({ subject: 'Lead alias owner', owner: 'lead' }); + expect(leadOwnedTask.owner).toBe('leadbot'); + expect(fs.existsSync(path.join(claudeDir, 'teams', 'my-team', 'inboxes', 'lead.json'))).toBe(false); + + const reassignedTask = controller.tasks.createTask({ subject: 'Reassign alias owner', owner: 'bob' }); + expect(controller.tasks.setTaskOwner(reassignedTask.id, 'team-lead').owner).toBe('leadbot'); + + controller.kanban.addReviewer('lead'); + expect(controller.kanban.listReviewers()).toEqual(['leadbot']); + + const reviewTask = controller.tasks.createTask({ subject: 'Review alias', owner: 'bob' }); + controller.tasks.completeTask(reviewTask.id, 'bob'); + controller.review.requestReview(reviewTask.id, { from: 'alice', reviewer: 'lead' }); + + const requested = controller.tasks + .getTask(reviewTask.id) + .historyEvents.filter((event) => event.type === 'review_requested') + .at(-1); + expect(requested.reviewer).toBe('leadbot'); + expect(fs.existsSync(path.join(claudeDir, 'teams', 'my-team', 'inboxes', 'leadbot.json'))).toBe(true); + expect(fs.existsSync(path.join(claudeDir, 'teams', 'my-team', 'inboxes', 'lead.json'))).toBe(false); + }); + it('rejects task_briefing for unknown members', async () => { const claudeDir = makeClaudeDir(); const controller = createController({ teamName: 'my-team', claudeDir }); @@ -1472,6 +1573,22 @@ describe('agent-teams-controller API', () => { expect(restored.reviewState).toBe('none'); }); + it('rejects task_restore for non-deleted tasks', () => { + const claudeDir = makeClaudeDir(); + const controller = createController({ teamName: 'my-team', claudeDir }); + const task = controller.tasks.createTask({ subject: 'Approved task must stay approved', owner: 'bob' }); + + controller.tasks.completeTask(task.id, 'bob'); + controller.review.requestReview(task.id, { from: 'alice', reviewer: 'alice' }); + controller.review.approveReview(task.id, { from: 'alice' }); + + expect(() => controller.tasks.restoreTask(task.id, 'alice')).toThrow( + 'task_restore only restores deleted tasks' + ); + expect(controller.tasks.getTask(task.id).status).toBe('completed'); + expect(controller.tasks.getTask(task.id).reviewState).toBe('approved'); + }); + it('uses actual kanban overlay for kanbanColumn inventory filters', () => { const claudeDir = makeClaudeDir(); const controller = createController({ teamName: 'my-team', claudeDir }); diff --git a/agent-teams-controller/test/crossTeam.test.js b/agent-teams-controller/test/crossTeam.test.js index ccfc2b6c..bf95784b 100644 --- a/agent-teams-controller/test/crossTeam.test.js +++ b/agent-teams-controller/test/crossTeam.test.js @@ -368,6 +368,108 @@ describe('crossTeam module', () => { expect(fs.existsSync(inboxPath)).toBe(true); }); + it('resolves supported lead agent types before tech-lead role text', () => { + const claudeDir = makeClaudeDir({ + 'team-a': { + name: 'team-a', + members: [{ name: 'team-lead', agentType: 'team-lead' }], + }, + 'team-b': { + name: 'team-b', + members: [ + { name: 'alice', role: 'tech lead' }, + { name: 'olivia', agentType: 'lead' }, + ], + }, + }); + + const controller = createController({ teamName: 'team-a', claudeDir }); + controller.crossTeam.sendCrossTeamMessage({ + toTeam: 'team-b', + text: 'Hello', + }); + + expect(fs.existsSync(path.join(claudeDir, 'teams', 'team-b', 'inboxes', 'olivia.json'))).toBe(true); + expect(fs.existsSync(path.join(claudeDir, 'teams', 'team-b', 'inboxes', 'alice.json'))).toBe(false); + }); + + it('resolves orchestrator lead from members.meta.json', () => { + const claudeDir = makeClaudeDir({ + 'team-a': { + name: 'team-a', + members: [{ name: 'team-lead', agentType: 'team-lead' }], + }, + 'team-b': { + name: 'team-b', + members: [], + }, + }); + + const metaPath = path.join(claudeDir, 'teams', 'team-b', 'members.meta.json'); + fs.writeFileSync( + metaPath, + JSON.stringify({ + members: [ + { name: 'alice', role: 'tech lead' }, + { name: 'orla', agentType: 'orchestrator' }, + ], + }) + ); + + const controller = createController({ teamName: 'team-a', claudeDir }); + controller.crossTeam.sendCrossTeamMessage({ + toTeam: 'team-b', + text: 'Hello', + }); + + expect(fs.existsSync(path.join(claudeDir, 'teams', 'team-b', 'inboxes', 'orla.json'))).toBe(true); + expect(fs.existsSync(path.join(claudeDir, 'teams', 'team-b', 'inboxes', 'alice.json'))).toBe(false); + }); + + it('rejects phantom source teams before delivery or outbox writes', () => { + const claudeDir = makeClaudeDir({ + 'team-b': { + name: 'team-b', + members: [{ name: 'team-lead', agentType: 'team-lead' }], + }, + }); + + const controller = createController({ teamName: 'team-a', claudeDir }); + + expect(() => + controller.crossTeam.sendCrossTeamMessage({ + toTeam: 'team-b', + text: 'Hello from nowhere', + }) + ).toThrow('Source team not found: team-a'); + expect(fs.existsSync(path.join(claudeDir, 'teams', 'team-a'))).toBe(false); + expect(fs.existsSync(path.join(claudeDir, 'teams', 'team-b', 'inboxes', 'team-lead.json'))).toBe(false); + }); + + it('rejects unknown cross-team senders', () => { + const claudeDir = makeClaudeDir({ + 'team-a': { + name: 'team-a', + members: [{ name: 'team-lead', agentType: 'team-lead' }], + }, + 'team-b': { + name: 'team-b', + members: [{ name: 'team-lead', agentType: 'team-lead' }], + }, + }); + + const controller = createController({ teamName: 'team-a', claudeDir }); + + expect(() => + controller.crossTeam.sendCrossTeamMessage({ + toTeam: 'team-b', + fromMember: 'alicce', + text: 'Hello', + }) + ).toThrow('Unknown cross-team sender: alicce'); + expect(fs.existsSync(path.join(claudeDir, 'teams', 'team-b', 'inboxes', 'team-lead.json'))).toBe(false); + }); + it('resolves lead by name fallback', () => { const claudeDir = makeClaudeDir({ 'team-a': { diff --git a/docs/team-management/member-liveness-hardening-plan.md b/docs/team-management/member-liveness-hardening-plan.md new file mode 100644 index 00000000..0913ae99 --- /dev/null +++ b/docs/team-management/member-liveness-hardening-plan.md @@ -0,0 +1,2281 @@ +# Member Liveness Hardening Plan + +## Коротко + +Нужно исправить кейс, где launch UI висит на `Members joining`, участники выглядят как `starting`, а runtime memory показывает около `2 MB`. По текущему коду это почти наверняка значит, что UI видит tmux pane/shell PID, а не реальный teammate runtime. + +Главное изменение: разделить "что-то зарегистрировано", "pane/shell жив", "процесс runtime реально найден" и "member сделал bootstrap/check-in". Сейчас эти сигналы частично смешаны через `runtimeAlive`. + +Рекомендуемый путь: **UI + строгая liveness-модель**. +🎯 9/10 🛡️ 9/10 🧠 7/10 Примерно 650-950 строк production-кода + 350-550 строк тестов. + +## Почему не UI-only + +Топ 3 вариантов: + +1. UI-only diagnostics + 🎯 7 🛡️ 4 🧠 3 Примерно 180-260 строк. + Покажет, что происходит, но backend все равно сможет считать shell живым runtime. Зависание станет понятнее, но не надежнее. + +2. UI + строгая liveness-модель + 🎯 9 🛡️ 9 🧠 7 Примерно 650-950 строк. + Исправляет причину: weak evidence больше не маскирует timeout, UI получает понятные причины, self-heal остается только для надежных сигналов. + +3. Полный lease/heartbeat runtime manager + 🎯 8 🛡️ 10 🧠 9 Примерно 1200-1800 строк. + Самый надежный вариант, но слишком большой для первого фикса. Его лучше делать после варианта 2, когда станут видны реальные runtime-команды и частота edge cases. + +## Что проверено в коде + +Факты, которые важны для плана: + +- `mcp-server/src/tools/runtimeTools.ts` уже содержит `runtime_bootstrap_checkin` и `runtime_heartbeat`. Это сильный сигнал, его надо сделать главным источником подтверждения. +- `agent-teams-controller/src/internal/runtime.js` уже прокидывает `runtimeBootstrapCheckin()` в desktop runtime. +- `src/main/services/team/TeamBootstrapStateReader.ts` уже читает `bootstrap-state.json`, `bootstrap-journal.jsonl` и классифицирует stuck bootstrap. Там уже есть важные тайминги: `ACTIVE_BOOTSTRAP_STUCK_CLASSIFICATION_MS = 3 min` и `TERMINAL_BOOTSTRAP_ONLY_PENDING_GRACE_MS = 5 min`. +- `TeamProvisioningService.getLiveTeamAgentRuntimeMetadata()` собирает evidence из config/meta/persisted runtime/tmux/process table и прогоняет его через strict resolver. +- Для tmux раньше читался только `#{pane_id}\t#{pane_pid}` через `listTmuxPanePidsForCurrentPlatform()`. `pane_pid` часто является shell (`zsh`, `bash`, `sh`), поэтому `2 MB` выглядело логично. +- `attachLiveRuntimeMetadataToStatuses()` теперь повышает member до `runtimeAlive: true` только через strong evidence: `confirmed_bootstrap` или `runtime_process`. +- `reevaluateMemberLaunchStatus()` больше не доверяет старому `runtimeAlive === true` без live metadata. +- `OpenCodeTeamRuntimeAdapter.mapBridgeMemberToRuntimeEvidence()` теперь не выставляет `runtimeAlive: true` для bridge-only `created` или `permission_blocked`. Такие сигналы остаются candidate/pending до bootstrap или OS verification. +- `recordOpenCodeRuntimeBootstrapCheckin()` и `recordOpenCodeRuntimeHeartbeat()` уже пишут `confirmed_alive`, `runtimeAlive: true`, `bootstrapConfirmed: true`, `nativeHeartbeat: true` через `updateOpenCodeRuntimeMemberLiveness()`. Значит confirmed state уже есть, надо не дать слабым сигналам выглядеть как он. +- `OpenCodeLaunchTransactionStore.canMarkOpenCodeRunReady()` уже требует `member_session_recorded`, `required_tools_proven` и `bootstrap_confirmed`. Это strict readiness precedent, который надо сохранить. +- Renderer уже получает оба источника: `memberSpawnStatuses` и `teamAgentRuntimeByTeam`. Но `MemberCard` сейчас получает только `runtimeSummary` строкой, а не сам `TeamAgentRuntimeEntry`. +- `teamSlice.areTeamAgentRuntimeEntriesEqual()` должен сравнивать `livenessKind`, `pidSource` и diagnostic fields, иначе UI может не перерендериться при смене strict evidence. +- `teamSlice.areMemberSpawnStatusEntriesEqual()` должен сравнивать visible liveness fields (`livenessKind/runtimeDiagnostic`) и продолжать игнорировать timing-only fields. +- `areLaunchSummaryCountsEqual()` должен сравнивать aggregate diagnostic counts (`shellOnlyPendingCount`, `runtimeProcessPendingCount`, `runtimeCandidatePendingCount`, `noRuntimePendingCount`, `permissionPendingCount`). UI не должен использовать legacy `runtimeAlivePendingCount` как process evidence. +- `TeamAgentRuntimeWatcher` обновляет runtime snapshot раз в 5 секунд, а spawn statuses раз в 2.5 секунды. Диагностические поля должны попадать либо в оба snapshot слоя, либо UX должен быть устойчив к задержке runtime snapshot. +- Renderer `member-spawn` event сейчас вызывает refresh spawn statuses, но не runtime snapshot. Если tooltip/detail зависят от `TeamAgentRuntimeSnapshot`, event handler тоже должен запланировать runtime refresh. +- Runtime tools принимают `metadata`, но `recordOpenCodeRuntimeBootstrapCheckin()` и `recordOpenCodeRuntimeHeartbeat()` сейчас используют только `diagnostics`. Если runtime присылает PID/version/command в `metadata`, эта информация теряется. +- `handleMemberSpawnToolResult()` раньше при reason `already_running` делал `setMemberSpawnStatus(..., "online", ..., "process")`. В strict model это заменено на `waiting` + runtime re-evaluation. +- `waitForTmuxPanesToExit()` использует `listTmuxPanePidsForCurrentPlatform()` только как "pane exists" check. Поэтому старый `listPanePids()` wrapper должен остаться ровно pane-existence helper, а не получить новую liveness-семантику. +- Для member liveness strict model включена по умолчанию без отдельного env-флага. +- `src/shared/types/api.ts`, `src/preload/index.ts` и `src/renderer/api/httpClient.ts` уже прокидывают `getMemberSpawnStatuses()` и `getTeamAgentRuntime()` через shared snapshot types. Новый контракт можно добавить optional fields без нового IPC channel, но browser HTTP fallback должен возвращать валидный старый shape. +- `TeamProvisioningService.readUnixProcessTableRows()` сейчас приватный, sync и читает только `pid,command`. Для надежного liveness нужен `ppid`, WSL-aware execution и unit-test seam. Это не должно оставаться приватным ad hoc helper внутри огромного service. +- `getLiveTeamAgentRuntimeMetadata()` сейчас читает tmux panes и process table внутри одного метода. После strict model там станет слишком много правил, поэтому план должен вынести pure resolution в отдельный helper/module. + +## Главная проблема + +Текущий `runtimeAlive` слишком широкий: + +```text +tmux pane exists +-> pane_pid is zsh/bash with low RSS +-> metadata.alive = true +-> MemberSpawnStatusEntry.runtimeAlive = true +-> grace timeout does not fail +-> UI shows starting/joining for minutes +``` + +Нужно прекратить использовать один boolean для разных уровней доверия. + +## Целевой контракт + +### Evidence ladder + +Сигналы должны оцениваться сверху вниз: + +1. `confirmed_bootstrap` + Member сделал `member_briefing`, `runtime_bootstrap_checkin`, `runtime_heartbeat`, meaningful inbox heartbeat или успешный bootstrap transcript. Это самый сильный сигнал. + +2. `runtime_process` + Найден процесс runtime с надежной идентичностью: `--team-name ` + `--agent-id `, или OpenCode bridge вернул валидный `runtimePid`/`sessionId`, и PID жив. + +3. `runtime_process_candidate` + Найден non-shell descendant под tmux pane, но без строгого identity match. Это diagnostic signal, не strong alive signal в первой реализации. + +4. `permission_blocked` + Runtime/bridge явно говорит, что требуется permission approval. + +5. `shell_only` + Tmux pane жив, но foreground command или root pane process выглядит как shell, и runtime child не найден. + +6. `registered_only` + Member есть в `config.json`/`members.meta.json`, но live process не найден. + +7. `stale_metadata` + Есть persisted `agentId`, `tmuxPaneId` или `runtimePid`, но live evidence не подтвержден. + +8. `not_found` + Нет полезных runtime данных. + +### Strong vs weak + +Только эти состояния ставят `runtimeAlive: true`: + +- `confirmed_bootstrap` +- `runtime_process` + +Эти состояния не ставят `runtimeAlive: true`: + +- `runtime_process_candidate` +- `permission_blocked` +- `shell_only` +- `registered_only` +- `stale_metadata` +- `not_found` + +Почему `runtime_process_candidate` не strong: non-shell child может быть `node`, `script`, `sleep`, wrapper или одноразовая команда. Без `teamName/agentId/sessionId` это слишком рискованно для снятия failure. + +## Тайминги + +Оставить текущий `MEMBER_LAUNCH_GRACE_MS = 90_000` как короткий timeout для отсутствия strong evidence. + +Добавить отдельный bootstrap stall deadline: + +```ts +const MEMBER_BOOTSTRAP_STALL_MS = 5 * 60_000; +``` + +Правила: + +- После 90 секунд: + - `shell_only`, `registered_only`, `stale_metadata`, `not_found` -> `failed_to_start`. + - `permission_blocked` -> не hard fail, показать permission UI. + - `runtime_process_candidate` -> warning, но не считать ready. + - `runtime_process` -> warning `waiting for bootstrap`, но не hard fail на 90 сек. + +- После 5 минут: + - `runtime_process_candidate` без bootstrap -> `failed_to_start`. + - `runtime_process` без bootstrap -> `runtimeDiagnosticSeverity: "warning"` и launch banner должен перестать быть мутным: `runtime alive but no bootstrap after 5 min`. + +Важно: verified runtime process не надо сразу убивать или hard fail-ить только потому, что bootstrap не пришел. Но UI не должен продолжать generic `starting`. + +## Rollout mode + +Строгая модель меняет поведение launch timeout, поэтому изначальный план рассматривал rollout через отдельный флаг. +Текущая реализация после hardening включает strict liveness по умолчанию и не содержит старый переключатель режима. + +Актуальное поведение: + +| Area | Strict-only behavior | +| ------------------------------ | ------------------------------------- | +| `livenessKind` | always filled when evidence exists | +| UI labels | enabled | +| `runtimeAlive` from shell-only | always false | +| `already_running` shortcut | waits for strong runtime verification | +| timeout self-heal | strong evidence only | +| launchDiagnostics | enabled for warning/error states | + +Operational rollback должен быть отдельным code revert или follow-up setting, а не скрытым env-флагом. + +## Structured launch diagnostics + +Файлы: + +- `src/shared/types/team.ts` +- `src/main/services/team/TeamProvisioningService.ts` +- `src/main/services/team/progressPayload.ts` +- `src/renderer/components/team/ProvisioningProgressBlock.tsx` + +`TeamProvisioningProgress` сейчас почти полностью строковый: + +- `message` +- `warnings` +- `cliLogsTail` +- `assistantOutput` + +`cliLogsTail` и `assistantOutput` уже специально ограничены (`PROGRESS_LOG_TAIL_LINES`, `PROGRESS_OUTPUT_TAIL_PARTS`), чтобы не провоцировать renderer OOM. Поэтому нельзя решать проблему "непонятно что происходит" простым расширением логов. + +Добавить маленький структурированный payload: + +```ts +export interface TeamLaunchDiagnosticItem { + id: string; + memberName?: string; + severity: 'info' | 'warning' | 'error'; + code: + | 'spawn_accepted' + | 'runtime_process_detected' + | 'runtime_process_candidate' + | 'tmux_shell_only' + | 'runtime_not_found' + | 'permission_pending' + | 'bootstrap_confirmed' + | 'bootstrap_stalled' + | 'stale_runtime_event_rejected' + | 'process_table_unavailable'; + label: string; + detail?: string; + observedAt: string; +} + +export interface TeamProvisioningProgress { + // existing fields... + launchDiagnostics?: TeamLaunchDiagnosticItem[]; +} +``` + +Bounded contract: + +- максимум 20 diagnostic items в progress payload; +- newest-first или stable sorted by severity/member; +- no raw unbounded command strings; +- process command must be sanitized/truncated; +- member-level details live in `MemberSpawnStatusEntry`/`TeamAgentRuntimeEntry`, progress diagnostics are only summary. + +Renderer: + +- `ProvisioningProgressBlock` can render a compact "Diagnostics" disclosure above Live output. +- It should show code-specific rows like `bob - shell only - tmux pane foreground command is zsh`. +- It should not require opening CLI logs to understand common stuck states. + +Recommended UI rows: + +```text +bob shell only tmux pane foreground command is zsh +jack waiting for bootstrap verified runtime process, no check-in yet +tom no runtime found spawn accepted 94s ago +``` + +This is separate from `Copy diagnostics`, which can include full sanitized JSON. + +## Типы + +Файл: `src/shared/types/team.ts` + +```ts +export type TeamAgentRuntimeLivenessKind = + | 'confirmed_bootstrap' + | 'runtime_process' + | 'runtime_process_candidate' + | 'permission_blocked' + | 'shell_only' + | 'registered_only' + | 'stale_metadata' + | 'not_found'; + +export type TeamAgentRuntimePidSource = + | 'lead_process' + | 'tmux_pane' + | 'tmux_child' + | 'agent_process_table' + | 'opencode_bridge' + | 'runtime_bootstrap' + | 'persisted_metadata'; + +export type TeamAgentRuntimeDiagnosticSeverity = 'info' | 'warning' | 'error'; + +export interface TeamAgentRuntimeEntry { + memberName: string; + alive: boolean; + restartable: boolean; + backendType?: TeamAgentRuntimeBackendType; + providerId?: TeamProviderId; + providerBackendId?: TeamProviderBackendId; + laneId?: string; + laneKind?: 'primary' | 'secondary'; + pid?: number; + runtimeModel?: string; + rssBytes?: number; + livenessKind?: TeamAgentRuntimeLivenessKind; + pidSource?: TeamAgentRuntimePidSource; + processCommand?: string; + paneId?: string; + panePid?: number; + paneCurrentCommand?: string; + runtimePid?: number; + runtimeSessionId?: string; + runtimeLeaseExpiresAt?: string; + runtimeLastSeenAt?: string; + runtimeDiagnostic?: string; + runtimeDiagnosticSeverity?: TeamAgentRuntimeDiagnosticSeverity; + diagnostics?: string[]; + updatedAt: string; +} +``` + +В `MemberSpawnStatusEntry` добавить только компактные поля для launch UI: + +```ts +export interface MemberSpawnStatusEntry { + // existing fields + runtimeDiagnostic?: string; + runtimeDiagnosticSeverity?: 'info' | 'warning' | 'error'; + livenessKind?: TeamAgentRuntimeLivenessKind; + livenessLastCheckedAt?: string; +} +``` + +Почему `runtimeSessionId` и `runtimeLastSeenAt` важны: + +- OpenCode runtime tools всегда передают `runtimeSessionId`. +- `runtime_bootstrap_checkin` и `runtime_heartbeat` уже являются lease-like сигналом. +- Без `runtimeLastSeenAt` UI не сможет отличить "процесс подтвержден 10 секунд назад" от "persisted state висит со вчера". +- `runtimeLeaseExpiresAt` можно не включать в Phase 0, но тип стоит заложить сразу, если lease/heartbeat manager будет Phase 5. + +## Runtime tool metadata + +Файлы: + +- `mcp-server/src/tools/runtimeTools.ts` +- `src/main/services/team/TeamProvisioningService.ts` + +`runtime_bootstrap_checkin` и `runtime_heartbeat` уже принимают `metadata`, но main сейчас не извлекает из нее ничего. Это упущение: OpenCode/runtime может передать полезные low-level детали, которые не стоит парсить из logs. + +Поддержать bounded metadata: + +```ts +interface RuntimeToolMetadata { + runtimePid?: number; + processCommand?: string; + runtimeVersion?: string; + hostPid?: number; + cwd?: string; +} +``` + +Parser: + +```ts +function parseRuntimeToolMetadata(value: unknown): RuntimeToolMetadata { + if (!value || typeof value !== 'object' || Array.isArray(value)) { + return {}; + } + const raw = value as Record; + const runtimePid = + typeof raw.runtimePid === 'number' && Number.isFinite(raw.runtimePid) && raw.runtimePid > 0 + ? Math.trunc(raw.runtimePid) + : undefined; + const processCommand = + typeof raw.processCommand === 'string' ? raw.processCommand.slice(0, 500) : undefined; + return { + ...(runtimePid ? { runtimePid } : {}), + ...(processCommand ? { processCommand } : {}), + }; +} +``` + +Security/robustness: + +- bound string lengths; +- ignore nested objects except allowlisted fields; +- never put raw metadata into logs/UI; +- include sanitized fields in copy diagnostics. + +`updateOpenCodeRuntimeMemberLiveness()` should accept sanitized metadata: + +```ts +await this.updateOpenCodeRuntimeMemberLiveness({ + teamName, + runId, + memberName, + runtimeSessionId, + observedAt, + diagnostics: payload.diagnostics, + metadata: parseRuntimeToolMetadata(payload.metadata), + reason: 'OpenCode runtime bootstrap check-in accepted', +}); +``` + +If metadata has `runtimePid`, still verify it: + +- PID must be alive now; +- command must still look like the expected runtime, if command info is available; +- runId/teamName/sessionId must match current tombstone/launch state. + +Do not trust metadata PID by itself. + +## Internal metadata + +Файл: `src/main/services/team/TeamProvisioningService.ts` + +Расширить внутренний тип: + +```ts +interface LiveTeamAgentRuntimeMetadata { + alive: boolean; + backendType?: TeamAgentRuntimeBackendType; + agentId?: string; + pid?: number; + metricsPid?: number; + model?: string; + tmuxPaneId?: string; + livenessKind?: TeamAgentRuntimeLivenessKind; + pidSource?: TeamAgentRuntimePidSource; + processCommand?: string; + panePid?: number; + paneCurrentCommand?: string; + runtimeSessionId?: string; + diagnostics?: string[]; +} +``` + +Helper: + +```ts +function isStrongRuntimeEvidence(metadata: LiveTeamAgentRuntimeMetadata | undefined): boolean { + return ( + metadata?.livenessKind === 'confirmed_bootstrap' || metadata?.livenessKind === 'runtime_process' + ); +} + +function isWeakRuntimeEvidence(metadata: LiveTeamAgentRuntimeMetadata | undefined): boolean { + return ( + metadata?.livenessKind === 'runtime_process_candidate' || + metadata?.livenessKind === 'permission_blocked' || + metadata?.livenessKind === 'shell_only' || + metadata?.livenessKind === 'registered_only' || + metadata?.livenessKind === 'stale_metadata' || + metadata?.livenessKind === 'not_found' + ); +} +``` + +## Liveness resolver seam + +Файл: `src/main/services/team/TeamRuntimeLivenessResolver.ts` + +Не стоит держать весь liveness algorithm внутри `TeamProvisioningService`. Там уже смешаны launch state, persistence, progress, tmux, OpenCode, inbox audit и runtime snapshot. Для надежности и тестов лучше вынести pure resolver. + +Варианты: + +1. Вынести только pure helpers + 🎯 8 🛡️ 7 🧠 4 Примерно 120-180 строк. + Быстро, но `getLiveTeamAgentRuntimeMetadata()` останется большим orchestration методом. + +2. Вынести resolver с input/output контрактом + 🎯 9 🛡️ 9 🧠 6 Примерно 220-340 строк. + Лучший баланс: service собирает raw facts, resolver принимает facts и возвращает `LiveTeamAgentRuntimeMetadata`. + +3. Вынести полноценный runtime monitor service + 🎯 8 🛡️ 10 🧠 8 Примерно 500-800 строк. + Архитектурно чище, но слишком большой шаг для текущего фикса. + +Рекомендация: вариант 2. + +Resolver input: + +```ts +export interface ResolveTeamMemberRuntimeLivenessInput { + teamName: string; + memberName: string; + agentId?: string; + backendType?: TeamAgentRuntimeBackendType; + providerId?: TeamProviderId; + tmuxPaneId?: string; + persistedRuntimePid?: number; + persistedRuntimeSessionId?: string; + trackedSpawnStatus?: MemberSpawnStatusEntry; + openCodeEvidence?: TeamRuntimeMemberLaunchEvidence; + pane?: TmuxPaneRuntimeInfo; + processRows: readonly RuntimeProcessTableRow[]; + nowIso: string; +} +``` + +Resolver output: + +```ts +export interface ResolvedTeamMemberRuntimeLiveness { + alive: boolean; + livenessKind: TeamAgentRuntimeLivenessKind; + pidSource?: TeamAgentRuntimePidSource; + pid?: number; + metricsPid?: number; + panePid?: number; + paneCurrentCommand?: string; + processCommand?: string; + runtimeSessionId?: string; + runtimeDiagnostic: string; + runtimeDiagnosticSeverity: TeamAgentRuntimeDiagnosticSeverity; + diagnostics: string[]; +} +``` + +`TeamProvisioningService` responsibilities after extraction: + +- read config/meta/persisted launch/runtime state; +- batch-read tmux pane runtime info once; +- batch-read process table once; +- call resolver per member; +- cache and expose the resolved metadata; +- invalidate caches on check-in/heartbeat/restart/stop/pane kill. + +Resolver responsibilities: + +- classify shell-only vs runtime process vs candidate; +- enforce strong/weak evidence rules; +- choose `pidSource`; +- sanitize diagnostics; +- never read filesystem, tmux, process table or stores directly. + +This seam makes the hardest rules unit-testable without spawning tmux or fake processes. + +## Tmux runtime info + +Файл: `src/features/tmux-installer/main/infrastructure/runtime/TmuxPlatformCommandExecutor.ts` + +Сейчас читается только pane PID. Нужно получать больше контекста: + +```ts +export interface TmuxPaneRuntimeInfo { + paneId: string; + panePid: number; + currentCommand?: string; + currentPath?: string; + sessionName?: string; + windowName?: string; +} + +async listPaneRuntimeInfo(paneIds: readonly string[]): Promise> { + const normalizedPaneIds = [...new Set(paneIds.map((paneId) => paneId.trim()).filter(Boolean))]; + if (normalizedPaneIds.length === 0) return new Map(); + + const format = [ + '#{pane_id}', + '#{pane_pid}', + '#{pane_current_command}', + '#{pane_current_path}', + '#{session_name}', + '#{window_name}', + ].join('\t'); + + const result = await this.execTmux(['list-panes', '-a', '-F', format], 3_000); + if (result.exitCode !== 0) { + throw new Error(result.stderr || 'Failed to list tmux panes'); + } + + const wanted = new Set(normalizedPaneIds); + const infoByPaneId = new Map(); + + for (const line of result.stdout.split('\n')) { + const [paneId = '', rawPid = '', currentCommand = '', currentPath = '', sessionName = '', windowName = ''] = + line.split('\t'); + const normalizedPaneId = paneId.trim(); + if (!wanted.has(normalizedPaneId)) continue; + + const panePid = Number.parseInt(rawPid.trim(), 10); + if (!Number.isFinite(panePid) || panePid <= 0) continue; + + infoByPaneId.set(normalizedPaneId, { + paneId: normalizedPaneId, + panePid, + currentCommand: currentCommand.trim() || undefined, + currentPath: currentPath.trim() || undefined, + sessionName: sessionName.trim() || undefined, + windowName: windowName.trim() || undefined, + }); + } + + return infoByPaneId; +} +``` + +Оставить старый метод как wrapper: + +```ts +async listPanePids(paneIds: readonly string[]): Promise> { + const info = await this.listPaneRuntimeInfo(paneIds); + return new Map([...info.entries()].map(([paneId, pane]) => [paneId, pane.panePid])); +} +``` + +Compatibility rule: + +- `listPanePids()` remains "does this pane exist and what is its root pane PID". +- It must not imply teammate runtime liveness. +- Existing callers like `waitForTmuxPanesToExit()` should keep working without knowing about `livenessKind`. + +## Process table + +Нужен `ppid`, иначе невозможно понять, есть ли runtime child под tmux pane. + +```ts +interface RuntimeProcessTableRow { + pid: number; + ppid: number; + command: string; +} +``` + +Do not implement this as `readUnixProcessTableRows()` inside `TeamProvisioningService`. The current helper is private, sync and native-only. The strict model needs a testable, platform-aware provider. + +Recommended shape: + +```ts +export interface RuntimeProcessTableProvider { + listRuntimeProcesses(): Promise; +} +``` + +`TmuxPlatformCommandExecutor` can implement it because it already knows whether the current tmux runtime is native or WSL-backed. + +На macOS/Linux: + +```ts +ps -ax -o pid=,ppid=,command= +``` + +На Windows/WSL важно: `ps` должен выполняться внутри той же WSL distro, где выполняется tmux. Host-side Windows `ps` не увидит Linux children. + +Практичный вариант: + +- добавить в `TmuxPlatformCommandExecutor` метод `listRuntimeProcesses()`; +- внутри Windows ветки использовать `TmuxWslService` и запускать `wsl -d -e ps -ax -o pid=,ppid=,command=`; +- на native платформах использовать обычный `execFile('ps', ...)`. + +Пример парсинга: + +```ts +function parseRuntimeProcessTable(output: string): RuntimeProcessTableRow[] { + const rows: RuntimeProcessTableRow[] = []; + + for (const line of output.split('\n')) { + const match = /^\s*(\d+)\s+(\d+)\s+(.*)$/.exec(line); + if (!match) continue; + + const pid = Number.parseInt(match[1], 10); + const ppid = Number.parseInt(match[2], 10); + const command = match[3]?.trim() ?? ''; + + if (Number.isFinite(pid) && pid > 0 && Number.isFinite(ppid) && command) { + rows.push({ pid, ppid, command }); + } + } + + return rows; +} +``` + +Performance contract: + +- read process table once per runtime snapshot, not once per member; +- reuse the same rows for every member resolver call; +- respect the existing backend cache TTL around 2 seconds; +- if process table read fails, return an explicit diagnostic and do not mark shell-only as strong alive. + +Failure contract: + +- `process_table_unavailable` is a warning, not an immediate hard fail by itself; +- if tmux pane info is available but process table is unavailable, classify as `shell_only` only when `pane_current_command` is shell-like; +- if both tmux and process table are unavailable, classify as `stale_metadata` or `not_found` based on persisted evidence; +- do not self-clear a previous failure on provider failure. + +### PID freshness and reuse + +PID alone is not identity. A stale persisted `runtimePid` can be reused by the OS for another process. + +Rules: + +- Never treat persisted PID as strong evidence without reading the current process table. +- A PID match is strong only if current command identity also matches expected runtime identity. +- If possible later, add process start time to the table and compare it with `firstSpawnAcceptedAt`/`runtimeLastSeenAt`. +- If process start time is unavailable, use command identity and current run/session identity as the minimum. + +Optional future row: + +```ts +interface RuntimeProcessTableRow { + pid: number; + ppid: number; + command: string; + startedAtMs?: number; +} +``` + +Do not block Phase 1 on `startedAtMs`; block it on "no PID-only strong evidence". + +## Shell detection + +```ts +const SHELL_COMMAND_NAMES = new Set(['sh', 'bash', 'zsh', 'fish', 'dash', 'login', 'tmux']); + +function basenameCommand(command: string | undefined): string { + const firstToken = command?.trim().split(/\s+/, 1)[0] ?? ''; + const base = firstToken.split(/[\\/]/).pop() ?? firstToken; + return base.replace(/^-/, '').toLowerCase(); +} + +function isShellLikeCommand(command: string | undefined): boolean { + return SHELL_COMMAND_NAMES.has(basenameCommand(command)); +} +``` + +## Runtime identity matching + +Текущий `commandContainsCliArgValue()` лучше заменить на helper, который поддерживает оба вида: + +- `--agent-id abc` +- `--agent-id=abc` +- quoted values + +Минимально: + +```ts +function extractCliArgValues(command: string, argName: string): string[] { + const escapedArg = argName.replace(/[.*+?^${}()|[\]\\]/g, '\\$&'); + const pattern = new RegExp( + `(?:^|\\s)${escapedArg}(?:=|\\s+)("([^"]*)"|'([^']*)'|([^\\s]+))`, + 'g' + ); + + const values: string[] = []; + for (const match of command.matchAll(pattern)) { + const value = (match[2] ?? match[3] ?? match[4] ?? '').trim(); + if (value) values.push(value); + } + return values; +} + +function commandArgEquals(command: string, argName: string, expected: string | undefined): boolean { + if (!expected?.trim()) return false; + return extractCliArgValues(command, argName).some((value) => value === expected.trim()); +} +``` + +Strong process match: + +```ts +function isVerifiedRuntimeProcess(params: { + row: RuntimeProcessTableRow; + teamName: string; + agentId?: string; +}): boolean { + return ( + commandArgEquals(params.row.command, '--team-name', params.teamName) && + commandArgEquals(params.row.command, '--agent-id', params.agentId) + ); +} +``` + +Sanitize any command before it reaches UI/logs/copy diagnostics: + +```ts +const SECRET_FLAG_PATTERN = + /(--(?:api-key|token|password|secret|authorization|auth-token)(?:=|\s+))("[^"]*"|'[^']*'|\S+)/gi; + +function sanitizeProcessCommandForDiagnostics(command: string | undefined): string | undefined { + const trimmed = command?.trim(); + if (!trimmed) return undefined; + return trimmed.replace(SECRET_FLAG_PATTERN, '$1[redacted]').slice(0, 500); +} +``` + +Do not use sanitized commands for identity matching. Match on the raw process table row inside main process memory, then only expose sanitized/truncated command text. + +## Descendant resolution + +```ts +function collectDescendants( + rows: readonly RuntimeProcessTableRow[], + rootPid: number +): RuntimeProcessTableRow[] { + const childrenByParent = new Map(); + + for (const row of rows) { + const bucket = childrenByParent.get(row.ppid) ?? []; + bucket.push(row); + childrenByParent.set(row.ppid, bucket); + } + + const result: RuntimeProcessTableRow[] = []; + const queue = [...(childrenByParent.get(rootPid) ?? [])]; + + while (queue.length > 0) { + const next = queue.shift(); + if (!next) continue; + result.push(next); + queue.push(...(childrenByParent.get(next.pid) ?? [])); + } + + return result; +} +``` + +Resolution: + +```ts +interface ResolvedRuntimeProcess { + kind: TeamAgentRuntimeLivenessKind; + pid?: number; + command?: string; + pidSource?: TeamAgentRuntimePidSource; + diagnostics: string[]; +} + +function resolveTmuxRuntimeProcess(params: { + teamName: string; + agentId?: string; + pane: TmuxPaneRuntimeInfo; + rows: readonly RuntimeProcessTableRow[]; +}): ResolvedRuntimeProcess { + const descendants = collectDescendants(params.rows, params.pane.panePid); + + const verified = descendants.find((row) => + isVerifiedRuntimeProcess({ + row, + teamName: params.teamName, + agentId: params.agentId, + }) + ); + + if (verified) { + return { + kind: 'runtime_process', + pid: verified.pid, + command: verified.command, + pidSource: 'tmux_child', + diagnostics: ['matched tmux descendant by team-name and agent-id'], + }; + } + + const candidate = descendants.find((row) => !isShellLikeCommand(row.command)); + if (candidate) { + return { + kind: 'runtime_process_candidate', + pid: candidate.pid, + command: candidate.command, + pidSource: 'tmux_child', + diagnostics: ['found non-shell descendant without team/member identity'], + }; + } + + if (isShellLikeCommand(params.pane.currentCommand)) { + return { + kind: 'shell_only', + pid: params.pane.panePid, + command: params.pane.currentCommand, + pidSource: 'tmux_pane', + diagnostics: [ + `tmux pane is alive, but foreground command is ${params.pane.currentCommand}`, + 'no verified runtime descendant process was found', + ], + }; + } + + return { + kind: 'not_found', + diagnostics: ['tmux pane exists, but no runtime process could be identified'], + }; +} +``` + +## OpenCode bridge correction + +Файл: `src/main/services/team/runtime/OpenCodeTeamRuntimeAdapter.ts` + +Сейчас `pendingRuntimeObserved = createdOrBlocked && runtimeMaterialized`, а `runtimeMaterialized` фактически означает "bridge вернул member". Это не равно live runtime. + +Надо разделить: + +- `agentToolAccepted`: bridge принял/создал member. +- `runtimeAlive`: есть подтвержденный live runtime signal или confirmed bootstrap. +- `bootstrapConfirmed`: `launchState === "confirmed_alive"`. + +Пример: + +```ts +function mapBridgeMemberToRuntimeEvidence( + memberName: string, + launchState: OpenCodeTeamMemberLaunchBridgeState, + sessionId: string | undefined, + runtimePid: number | undefined, + pendingPermissionRequestIds: string[] | undefined, + runtimeMaterialized: boolean, + diagnostics: string[] +): TeamRuntimeMemberLaunchEvidence { + const confirmed = launchState === 'confirmed_alive'; + const failed = launchState === 'failed'; + const permissionBlocked = launchState === 'permission_blocked'; + const validRuntimePid = + typeof runtimePid === 'number' && Number.isFinite(runtimePid) && runtimePid > 0; + const hasRuntimeSession = typeof sessionId === 'string' && sessionId.trim().length > 0; + const runtimeLivenessKind = confirmed + ? 'confirmed_bootstrap' + : validRuntimePid + ? 'runtime_process' + : permissionBlocked + ? 'permission_blocked' + : hasRuntimeSession + ? 'runtime_process_candidate' + : undefined; + + return { + memberName, + providerId: 'opencode', + launchState: failed + ? 'failed_to_start' + : confirmed + ? 'confirmed_alive' + : permissionBlocked + ? 'runtime_pending_permission' + : 'runtime_pending_bootstrap', + agentToolAccepted: confirmed || runtimeMaterialized, + runtimeAlive: confirmed || validRuntimePid, + bootstrapConfirmed: confirmed, + hardFailure: failed, + hardFailureReason: failed ? 'OpenCode bridge reported member launch failure' : undefined, + pendingPermissionRequestIds: + pendingPermissionRequestIds && pendingPermissionRequestIds.length > 0 + ? [...new Set(pendingPermissionRequestIds)] + : undefined, + sessionId, + ...(validRuntimePid ? { runtimePid } : {}), + ...(runtimeLivenessKind ? { livenessKind: runtimeLivenessKind } : {}), + diagnostics, + }; +} +``` + +Важно: `sessionId` без `runtimePid` лучше считать candidate, а не strong live process. Session id полезен для delivery/permission correlation, но сам по себе не доказывает, что процесс сейчас жив. + +Также `toOpenCodePersistedLaunchMember()` должен сохранять `runtimePid` и `sessionId`, если они есть. Сейчас для primary OpenCode launch evidence это легко потерять. + +### OpenCode transaction/readiness invariant + +`canMarkOpenCodeRunReady()` уже требует `bootstrap_confirmed`, поэтому новая liveness-модель не должна поднимать aggregate state в `clean_success`, если есть только: + +- bridge `created`; +- `sessionId` без bootstrap; +- permission request; +- stale launch-state member. + +Regression test: + +```ts +expect( + canMarkOpenCodeRunReady({ + members: [{ name: 'bob', launchState: 'runtime_pending_bootstrap' }], + // checkpoints exist except bootstrap + }).ok +).toBe(false); +``` + +### Stale runtime events + +`assertOpenCodeRuntimeEvidenceAccepted()` already checks tombstones/current run ownership before accepting bootstrap/heartbeat/delivery evidence. This must remain the gate for all strong OpenCode liveness. + +Rules: + +- `runtime_bootstrap_checkin` from an old `runId` must not revive a stopped/relaunched member. +- `runtime_heartbeat` from an old lane must not refresh `runtimeLastSeenAt`. +- Runtime metadata from rejected evidence must not be written to persisted launch state. +- UI copy diagnostics should include `runId` and `runtimeSessionId` only after accepted evidence. + +Regression tests: + +```ts +await expect( + service.recordOpenCodeRuntimeHeartbeat({ + teamName, + runId: oldRunId, + memberName: 'bob', + runtimeSessionId: oldSessionId, + }) +).rejects.toThrow(); +``` + +## `getLiveTeamAgentRuntimeMetadata()` + +Новая логика: + +1. Сначала читать durable status: + - `bootstrapConfirmed` + - `lastHeartbeatAt` + - `runtime_bootstrap_checkin` + - transcript success + +2. Потом читать verified runtime: + - process table match by `--team-name` + `--agent-id` + - OpenCode runtimePid/sessionId + - tmux descendant with verified identity + +3. Потом diagnostic-only: + - tmux pane shell + - config/meta registration + - stale persisted metadata + +Sketch: + +```ts +const status = this.findTrackedMemberSpawnStatus(run, memberName); +const diagnostics: string[] = []; + +let livenessKind: TeamAgentRuntimeLivenessKind = 'not_found'; +let pid: number | undefined; +let pidSource: TeamAgentRuntimePidSource | undefined; +let processCommand: string | undefined; + +if (status?.bootstrapConfirmed === true) { + livenessKind = 'confirmed_bootstrap'; + diagnostics.push('bootstrap was confirmed by member check-in or heartbeat'); +} + +if (livenessKind !== 'confirmed_bootstrap' && metadata.agentId) { + const processPid = processPidByAgentId.get(metadata.agentId); + if (processPid) { + livenessKind = 'runtime_process'; + pid = processPid; + pidSource = 'agent_process_table'; + diagnostics.push('matched process table by team-name and agent-id'); + } +} + +if (livenessKind !== 'runtime_process' && paneInfo) { + const resolved = resolveTmuxRuntimeProcess({ + teamName, + agentId: metadata.agentId, + pane: paneInfo, + rows: processRows, + }); + + livenessKind = resolved.kind; + pid = resolved.pid; + pidSource = resolved.pidSource; + processCommand = resolved.command; + diagnostics.push(...resolved.diagnostics); +} + +if (livenessKind === 'not_found' && metadata.agentId) { + livenessKind = 'stale_metadata'; + diagnostics.push('persisted agent id exists, but no live process matched it'); +} + +const alive = livenessKind === 'confirmed_bootstrap' || livenessKind === 'runtime_process'; + +metadataByMember.set(memberName, { + ...metadata, + alive, + livenessKind, + ...(pid ? { pid } : {}), + ...(pidSource ? { pidSource } : {}), + ...(processCommand ? { processCommand } : {}), + ...(paneInfo + ? { + panePid: paneInfo.panePid, + paneCurrentCommand: paneInfo.currentCommand, + } + : {}), + diagnostics, +}); +``` + +Fallback policy: + +- Если enhanced tmux info failed, не возвращать `alive: true` только из старого `panePid`. +- Если `ps` failed, показывать diagnostic `process table unavailable`; не self-clear failure. +- Если cached metadata есть, сохранять `model/backendType`, но не сохранять stale `alive`. +- Если `previousMember.bootstrapConfirmed === true`, persisted launch state может оставаться confirmed для истории, но runtime snapshot должен показывать `alive` отдельно от historical `bootstrapConfirmed`. Иначе UI может считать старого member live после stop/relaunch. + +## Persisted launch state + +Файл: `src/main/services/team/TeamLaunchStateEvaluator.ts` + +Сейчас `RuntimeMemberSpawnState` и persisted member normalization не знают про новые diagnostic поля. Нужно расширить аккуратно, чтобы старые snapshots читались без migration. + +Добавить в `PersistedTeamLaunchMemberState`: + +```ts +runtimeSessionId?: string; +livenessKind?: TeamAgentRuntimeLivenessKind; +pidSource?: TeamAgentRuntimePidSource; +runtimeDiagnostic?: string; +runtimeDiagnosticSeverity?: TeamAgentRuntimeDiagnosticSeverity; +``` + +Правило: + +- persisted `livenessKind` можно использовать для UI explanation; +- persisted `livenessKind` нельзя использовать как live proof без свежего `lastRuntimeAliveAt` или live runtime check. + +Normalize: + +```ts +function normalizeLivenessKind(value: unknown): TeamAgentRuntimeLivenessKind | undefined { + return value === 'confirmed_bootstrap' || + value === 'runtime_process' || + value === 'runtime_process_candidate' || + value === 'permission_blocked' || + value === 'shell_only' || + value === 'registered_only' || + value === 'stale_metadata' || + value === 'not_found' + ? value + : undefined; +} +``` + +`updateOpenCodeRuntimeMemberLiveness()` должен сохранять: + +```ts +livenessKind: 'confirmed_bootstrap', +pidSource: 'runtime_bootstrap', +runtimeSessionId: input.runtimeSessionId, +runtimeDiagnostic: undefined, +runtimeDiagnosticSeverity: undefined, +``` + +`toOpenCodePersistedLaunchMember()` должен сохранять: + +```ts +runtimePid: evidence?.runtimePid, +runtimeSessionId: evidence?.sessionId, +livenessKind: evidence?.bootstrapConfirmed + ? 'confirmed_bootstrap' + : evidence?.runtimeAlive + ? 'runtime_process' + : evidence?.pendingPermissionRequestIds?.length + ? 'permission_blocked' + : undefined, +``` + +Mapping functions that must be updated: + +- `RuntimeMemberSpawnState` pick list must include `livenessKind`, `runtimeDiagnostic`, `runtimeDiagnosticSeverity`. +- `snapshotFromRuntimeMemberStatuses()` must copy those fields into `PersistedTeamLaunchMemberState`. +- `snapshotToMemberSpawnStatuses()` must copy them back into `MemberSpawnStatusEntry`. +- `normalizePersistedLaunchSnapshot()` must normalize unknown old files without dropping valid new fields. + +Example: + +```ts +statuses[memberName] = { + status, + launchState: entry.launchState, + error: entry.hardFailure ? entry.hardFailureReason : undefined, + hardFailureReason: entry.hardFailureReason, + livenessSource, + agentToolAccepted: entry.agentToolAccepted, + runtimeAlive: entry.runtimeAlive, + bootstrapConfirmed: entry.bootstrapConfirmed, + hardFailure: entry.hardFailure, + pendingPermissionRequestIds: entry.pendingPermissionRequestIds, + firstSpawnAcceptedAt: entry.firstSpawnAcceptedAt, + lastHeartbeatAt: entry.lastHeartbeatAt, + livenessKind: entry.livenessKind, + runtimeDiagnostic: entry.runtimeDiagnostic, + runtimeDiagnosticSeverity: entry.runtimeDiagnosticSeverity, + updatedAt: entry.lastEvaluatedAt, +}; +``` + +Backward compatibility: + +- old snapshots without these fields should behave exactly as today; +- new optional summary counts should default to `0` at presentation time; +- do not bump snapshot `version` unless a required field is introduced. For this plan, keep `version: 2`. + +## `attachLiveRuntimeMetadataToStatuses()` + +Текущий behavior: + +```ts +if (metadata.alive) { + nextEntry.runtimeAlive = true; + nextEntry.livenessSource = 'process'; +} +``` + +Новый behavior: + +```ts +const strongRuntimeAlive = isStrongRuntimeEvidence(metadata); +const weakRuntimeEvidence = isWeakRuntimeEvidence(metadata); + +if ( + strongRuntimeAlive && + current.hardFailure !== true && + current.launchState !== 'failed_to_start' +) { + nextEntry.status = 'online'; + nextEntry.agentToolAccepted = true; + nextEntry.runtimeAlive = true; + nextEntry.hardFailure = false; + nextEntry.hardFailureReason = undefined; + nextEntry.error = undefined; + nextEntry.livenessSource = current.bootstrapConfirmed ? current.livenessSource : 'process'; + nextEntry.livenessKind = metadata.livenessKind; + nextEntry.runtimeDiagnostic = undefined; + nextEntry.runtimeDiagnosticSeverity = undefined; + nextEntry.launchState = deriveMemberLaunchState(nextEntry); +} + +if (weakRuntimeEvidence && current.bootstrapConfirmed !== true) { + nextEntry.runtimeAlive = false; + nextEntry.livenessKind = metadata.livenessKind; + nextEntry.runtimeDiagnostic = buildRuntimeDiagnostic(metadata); + nextEntry.runtimeDiagnosticSeverity = metadata.livenessKind === 'shell_only' ? 'warning' : 'info'; +} +``` + +Self-heal из `failed_to_start` оставить только для strong evidence: + +```ts +if ( + strongRuntimeAlive && + current.launchState === 'failed_to_start' && + isAutoClearableLaunchFailureReason(failureReason) +) { + // clear auto failure +} +``` + +## Spawn tool result handling + +Файл: `src/main/services/team/TeamProvisioningService.ts` + +`handleMemberSpawnToolResult()` раньше содержал shortcut: + +```ts +if (parsedStatus.reason === 'already_running') { + this.setMemberSpawnStatus(run, spawnedMemberName, 'online', undefined, 'process'); +} +``` + +В strict liveness модели это опасно: `already_running` доказывает, что runtime/CLI отказался дублировать spawn, но не доказывает, что нужный teammate сейчас прошел bootstrap или что текущий pane PID является runtime процессом. + +Итоговая логика: + +```ts +if (parsedStatus.reason === 'already_running') { + this.agentRuntimeSnapshotCache.delete(run.teamName); + this.liveTeamAgentRuntimeMetadataCache.delete(run.teamName); + this.setMemberSpawnStatus(run, spawnedMemberName, 'waiting'); + this.appendMemberBootstrapDiagnostic( + run, + spawnedMemberName, + 'already_running requires strong runtime verification' + ); + void this.reevaluateMemberLaunchStatus(run, spawnedMemberName); +} +``` + +Tests: + +- `already_running` + shell-only pane -> stays pending/warning, no `runtimeAlive`. +- `already_running` + verified process -> can become `runtime_pending_bootstrap`. +- `already_running` + confirmed bootstrap -> confirmed alive. + +## `reevaluateMemberLaunchStatus()` + +Текущий early return по `refreshed.runtimeAlive` слишком широкий. + +Новый sketch: + +```ts +await this.refreshMemberSpawnStatusesFromLeadInbox(run); +await this.maybeAuditMemberSpawnStatuses(run, { force: true }); + +const refreshed = run.memberSpawnStatuses.get(memberName); +if (!refreshed) return; + +const runtimeByMember = await this.getLiveTeamAgentRuntimeMetadata(run.teamName); +const runtime = findRuntimeMetadataForMember(runtimeByMember, memberName); +const strongRuntimeAlive = isStrongRuntimeEvidence(runtime); + +if (refreshed.launchState === 'failed_to_start' || refreshed.launchState === 'confirmed_alive') { + return; +} + +if (strongRuntimeAlive) { + this.setMemberRuntimeDiagnostic(run, memberName, { + livenessKind: runtime?.livenessKind, + message: 'Runtime process is alive, waiting for teammate bootstrap/check-in.', + severity: 'warning', + }); + return; +} + +if (runtime?.livenessKind === 'permission_blocked') { + return; +} + +const reason = + runtime?.livenessKind === 'shell_only' + ? `Teammate did not join within the launch grace window. Tmux pane is alive, but only shell command "${runtime.paneCurrentCommand ?? 'unknown'}" was detected.` + : runtime?.livenessKind === 'runtime_process_candidate' + ? 'Teammate did not confirm bootstrap. Only an unverified runtime process candidate was found.' + : 'Teammate did not join within the launch grace window.'; + +this.setMemberSpawnStatus(run, memberName, 'error', reason); +``` + +Для `runtime_process_candidate` лучше использовать 5 минут, не 90 секунд: + +```ts +const acceptedAtMs = Date.parse(refreshed.firstSpawnAcceptedAt ?? ''); +const elapsedMs = Number.isFinite(acceptedAtMs) ? Date.now() - acceptedAtMs : 0; +if ( + runtime?.livenessKind === 'runtime_process_candidate' && + elapsedMs < MEMBER_BOOTSTRAP_STALL_MS +) { + return; +} +``` + +## Runtime snapshot and memory display + +`getTeamAgentRuntimeSnapshot()` сейчас выбирает `rssPid = liveRuntimeMember?.pid ?? liveRuntimeMember?.metricsPid`. Это нормально для сбора метрики, но UI должен знать источник. + +Правило: + +- `pidSource = tmux_pane` + `livenessKind = shell_only` -> memory is shell/pane RSS, не runtime RSS. +- `pidSource = tmux_child` или `agent_process_table` -> memory is runtime process RSS. +- OpenCode shared host `metricsPid` -> показать как shared host, не как member-owned runtime. +- `launchSnapshotAlive` сейчас может сделать `alive: true`, если persisted launch member был `runtimeAlive` или `bootstrapConfirmed`. После изменения это надо разделить: + - `historicallyConfirmedBootstrap` - для display/history. + - `alive` - только свежий live runtime или свежий heartbeat lease. + +Добавить в `TeamAgentRuntimeEntry`: + +```ts +runtimeDiagnostic?: string; +pidSource?: TeamAgentRuntimePidSource; +paneCurrentCommand?: string; +historicalBootstrapConfirmed?: boolean; +runtimeLastSeenAt?: string; +``` + +UI tooltip может объяснить: + +```text +RSS source: tmux pane shell +PID: 26691 +Command: zsh +Runtime process: not found +Bootstrap: no check-in yet +``` + +## Restartability semantics + +Файлы: + +- `src/main/services/team/TeamProvisioningService.ts` +- `src/renderer/components/team/members/MemberDetailDialog.tsx` + +Важно не смешать `alive` и `restartable`. + +`shell_only` должен быть `alive: false`, но часто должен оставаться `restartable: true`, если есть `tmuxPaneId`. Иначе пользователь увидит `shell only`, но не сможет нажать Restart. + +Rules: + +- `confirmed_bootstrap` / `runtime_process` with member-owned PID -> `alive: true`, `restartable: true`. +- `shell_only` with `tmuxPaneId` -> `alive: false`, `restartable: true`, restart kills pane. +- `registered_only` without PID/pane -> `alive: false`, `restartable: false`. +- OpenCode shared host `metricsPid` -> `restartable: false` unless adapter owns a member lane stop/restart path. +- `in-process` -> keep `restartable: false`. + +`restartMember()` already kills persisted tmux panes via `killTmuxPaneForCurrentPlatformSync(paneId)`, so strict liveness should not remove pane ids from runtime snapshot just because they are weak evidence. + +Test: + +```ts +expect(shellOnlyRuntimeEntry).toMatchObject({ + alive: false, + restartable: true, + livenessKind: 'shell_only', + pidSource: 'tmux_pane', +}); +``` + +## IPC/store implications + +Файлы: + +- `src/main/ipc/teams.ts` +- `src/renderer/store/index.ts` +- `src/renderer/store/slices/teamSlice.ts` +- `src/renderer/components/team/TeamDetailView.tsx` + +IPC уже возвращает `TeamAgentRuntimeSnapshot`, значит новый контракт проходит без нового channel. Но store equality обязательно надо обновить: + +```ts +function areTeamAgentRuntimeEntriesEqual( + left: TeamAgentRuntimeEntry | undefined, + right: TeamAgentRuntimeEntry | undefined +): boolean { + if (left === right) return true; + if (!left || !right) return left === right; + return ( + left.memberName === right.memberName && + left.alive === right.alive && + left.restartable === right.restartable && + left.backendType === right.backendType && + left.pid === right.pid && + left.runtimeModel === right.runtimeModel && + left.rssBytes === right.rssBytes && + left.livenessKind === right.livenessKind && + left.pidSource === right.pidSource && + left.paneCurrentCommand === right.paneCurrentCommand && + left.runtimeDiagnostic === right.runtimeDiagnostic && + left.runtimeDiagnosticSeverity === right.runtimeDiagnosticSeverity && + left.runtimeLastSeenAt === right.runtimeLastSeenAt + ); +} +``` + +Если не сделать это, backend может правильно вычислять `shell_only`, а UI продолжит показывать старую карточку из-за suppressed store update. + +Нужно обновить и spawn equality: + +```ts +function areMemberSpawnStatusEntriesEqual( + left: MemberSpawnStatusEntry | undefined, + right: MemberSpawnStatusEntry | undefined +): boolean { + if (left === right) return true; + if (!left || !right) return left === right; + return ( + // existing visible fields + left.status === right.status && + left.launchState === right.launchState && + left.error === right.error && + left.hardFailureReason === right.hardFailureReason && + left.livenessSource === right.livenessSource && + left.runtimeAlive === right.runtimeAlive && + left.runtimeModel === right.runtimeModel && + left.bootstrapConfirmed === right.bootstrapConfirmed && + left.hardFailure === right.hardFailure && + // new visible diagnostic fields + left.livenessKind === right.livenessKind && + left.runtimeDiagnostic === right.runtimeDiagnostic && + left.runtimeDiagnosticSeverity === right.runtimeDiagnosticSeverity + ); +} +``` + +Summary equality: + +```ts +function areLaunchSummaryCountsEqual( + left: PersistedTeamLaunchSummary | undefined, + right: PersistedTeamLaunchSummary | undefined +): boolean { + if (left === right) return true; + if (!left || !right) return left === right; + return ( + left.confirmedCount === right.confirmedCount && + left.pendingCount === right.pendingCount && + left.failedCount === right.failedCount && + left.runtimeAlivePendingCount === right.runtimeAlivePendingCount && + left.shellOnlyPendingCount === right.shellOnlyPendingCount && + left.runtimeProcessPendingCount === right.runtimeProcessPendingCount && + left.runtimeCandidatePendingCount === right.runtimeCandidatePendingCount && + left.noRuntimePendingCount === right.noRuntimePendingCount && + left.permissionPendingCount === right.permissionPendingCount + ); +} +``` + +Event handling: + +```ts +if (event.type === 'member-spawn') { + if (isStaleRuntimeEvent) return; + seedCurrentRunIdIfMissing(); + scheduleMemberSpawnStatusesRefresh(event.teamName); + scheduleTeamAgentRuntimeRefresh(event.teamName); + return; +} +``` + +If `scheduleTeamAgentRuntimeRefresh()` does not exist, add a small debounced variant mirroring `scheduleMemberSpawnStatusesRefresh()`. + +Polling: + +- `TeamSpawnStatusWatcher` - 2.5 sec. +- `TeamAgentRuntimeWatcher` - 5 sec. +- Backend runtime metadata cache TTL is 2 sec. + +Для launch UI лучше продублировать короткий `livenessKind/runtimeDiagnostic` в `MemberSpawnStatusEntry`, а подробные PID/command детали оставить в runtime snapshot. Тогда badge меняется быстро, tooltip догоняет через runtime snapshot. + +Cache invalidation checklist: + +- invalidate `agentRuntimeSnapshotCache` and `liveTeamAgentRuntimeMetadataCache` on runtime check-in; +- invalidate on heartbeat; +- invalidate on member restart/stop/remove; +- invalidate when tmux pane kill succeeds; +- invalidate when launch state store writes a new liveness diagnostic. + +Without this, a member can remain visually `shell only` for up to the polling interval after a valid check-in, which is acceptable, but not after an explicit check-in event. + +## API/preload propagation + +No new IPC channel is needed, but the type propagation still has sharp edges. + +Files to verify: + +- `src/shared/types/team.ts` +- `src/shared/types/api.ts` +- `src/preload/index.ts` +- `src/renderer/api/httpClient.ts` +- `src/renderer/store/slices/teamSlice.ts` + +Rules: + +- New fields on `TeamAgentRuntimeEntry`, `MemberSpawnStatusEntry` and `PersistedTeamLaunchSummary` must be optional at first. +- `src/preload/index.ts` can keep the same `invokeIpcWithResult()` calls. +- `src/shared/types/api.ts` should not need method signature changes, but typecheck must prove it. +- `src/renderer/api/httpClient.ts` browser fallback must still return valid snapshots when new fields are absent. +- Renderer helpers must tolerate `undefined` `livenessKind` and map it to current behavior. + +Recommended type compatibility test: + +```ts +const snapshot: TeamAgentRuntimeSnapshot = { + teamName: 'demo', + updatedAt: new Date().toISOString(), + runId: null, + members: { + bob: { + memberName: 'bob', + alive: false, + restartable: true, + livenessKind: 'shell_only', + pidSource: 'tmux_pane', + paneCurrentCommand: 'zsh', + updatedAt: new Date().toISOString(), + }, + }, +}; +``` + +This catches accidental required fields before runtime. + +## Progress diagnostics update path + +`updateProgress()` currently accepts only: + +```ts +Pick< + TeamProvisioningProgress, + 'pid' | 'error' | 'warnings' | 'cliLogsTail' | 'configReady' | 'messageSeverity' +>; +``` + +If `launchDiagnostics` is added to `TeamProvisioningProgress`, `updateProgress()` must accept it explicitly: + +```ts +extras?: Pick< + TeamProvisioningProgress, + | 'pid' + | 'error' + | 'warnings' + | 'cliLogsTail' + | 'configReady' + | 'messageSeverity' + | 'launchDiagnostics' +> +``` + +And keep it bounded: + +```ts +launchDiagnostics: boundLaunchDiagnostics( + extras?.launchDiagnostics ?? run.progress.launchDiagnostics +), +``` + +Do not store this as `assistantOutput`. `assistantOutput` is rendered as markdown and is the wrong surface for machine-produced liveness facts. + +## Renderer UX + +### Member card labels + +Файлы: + +- `src/renderer/utils/memberHelpers.ts` +- `src/renderer/components/team/members/MemberCard.tsx` +- `src/renderer/utils/memberRuntimeSummary.ts` + +Новые visual states: + +```ts +export type MemberLaunchVisualState = + | 'waiting' + | 'spawning' + | 'permission_pending' + | 'waiting_bootstrap' + | 'shell_only' + | 'runtime_candidate' + | 'registered_only' + | 'stale_runtime' + | 'error' + | null; +``` + +Mapping: + +```ts +function resolveLaunchVisualState(params: { + spawnStatus?: MemberSpawnStatusEntry; + runtimeEntry?: TeamAgentRuntimeEntry; +}): MemberLaunchVisualState { + const { spawnStatus, runtimeEntry } = params; + + if (spawnStatus?.launchState === 'failed_to_start') return 'error'; + if (spawnStatus?.launchState === 'runtime_pending_permission') return 'permission_pending'; + + if (runtimeEntry?.livenessKind === 'shell_only') return 'shell_only'; + if (runtimeEntry?.livenessKind === 'runtime_process_candidate') return 'runtime_candidate'; + if (runtimeEntry?.livenessKind === 'registered_only') return 'registered_only'; + if (runtimeEntry?.livenessKind === 'stale_metadata') return 'stale_runtime'; + + if ( + spawnStatus?.launchState === 'runtime_pending_bootstrap' && + runtimeEntry?.livenessKind === 'runtime_process' + ) { + return 'waiting_bootstrap'; + } + + return spawnStatus?.status === 'spawning' ? 'spawning' : 'waiting'; +} +``` + +Labels: + +```ts +const MEMBER_LAUNCH_LABELS: Record, string> = { + waiting: 'starting', + spawning: 'starting', + permission_pending: 'permission', + waiting_bootstrap: 'waiting for bootstrap', + shell_only: 'shell only', + runtime_candidate: 'process candidate', + registered_only: 'registered', + stale_runtime: 'stale runtime', + error: 'spawn failed', +}; +``` + +Текущий `MemberCard` не принимает `runtimeEntry`, поэтому надо изменить props: + +```ts +interface MemberCardProps { + // existing + runtimeEntry?: TeamAgentRuntimeEntry; + spawnEntry?: MemberSpawnStatusEntry; +} +``` + +И передавать из `MemberList`: + +```tsx + +``` + +Затем `buildMemberLaunchPresentation()` должен принимать `runtimeEntry` или хотя бы `livenessKind`: + +```ts +const launchPresentation = buildMemberLaunchPresentation({ + member, + spawnStatus, + spawnLaunchState, + spawnLivenessSource, + spawnRuntimeAlive, + runtimeEntry, + runtimeAdvisory: member.runtimeAdvisory, + isLaunchSettling, + isTeamAlive, + isTeamProvisioning, + leadActivity, +}); +``` + +То же нужно для `MemberDetailHeader` и `MemberHoverCard`, иначе список и detail view будут расходиться по labels. + +### Tooltip + +Tooltip examples: + +```text +bob +Spawn accepted: yes +Registered in config: yes +Runtime: tmux pane alive, foreground command is zsh +Runtime process: not found +PID source: tmux pane +Bootstrap: no member_briefing/check-in yet +``` + +```text +alice +Spawn accepted: yes +Runtime: verified process detected +PID source: tmux child +Bootstrap: waiting for member_briefing/check-in +``` + +```text +tom +Spawn accepted: yes +Runtime: not found after 90s +Bootstrap: no check-in +Last error: Teammate did not join within the launch grace window. +``` + +### Launch banner + +Файл: `src/renderer/utils/teamProvisioningPresentation.ts` + +Generic: + +```text +4 teammates still joining +``` + +Заменить на aggregate detail: + +```text +4 teammates still joining - 3 shell-only, 1 waiting for bootstrap +``` + +Helper: + +```ts +function summarizePendingLaunchDiagnostics(params: { + statuses: Record; + runtimeEntries: Record | undefined; +}): string | null { + const counts = { + shellOnly: 0, + waitingBootstrap: 0, + candidate: 0, + permission: 0, + noRuntime: 0, + }; + + for (const [memberName, status] of Object.entries(params.statuses)) { + if (status.launchState === 'confirmed_alive' || status.launchState === 'failed_to_start') { + continue; + } + + const runtimeEntry = params.runtimeEntries?.[memberName]; + if (status.launchState === 'runtime_pending_permission') counts.permission += 1; + else if (runtimeEntry?.livenessKind === 'shell_only') counts.shellOnly += 1; + else if (runtimeEntry?.livenessKind === 'runtime_process') counts.waitingBootstrap += 1; + else if (runtimeEntry?.livenessKind === 'runtime_process_candidate') counts.candidate += 1; + else counts.noRuntime += 1; + } + + const parts = [ + counts.shellOnly ? `${counts.shellOnly} shell-only` : '', + counts.waitingBootstrap ? `${counts.waitingBootstrap} waiting for bootstrap` : '', + counts.candidate ? `${counts.candidate} process candidates` : '', + counts.permission ? `${counts.permission} awaiting permission` : '', + counts.noRuntime ? `${counts.noRuntime} no runtime found` : '', + ].filter(Boolean); + + return parts.length > 0 ? parts.join(', ') : null; +} +``` + +Сейчас `buildTeamProvisioningPresentation()` принимает только spawn statuses/snapshot, не runtime entries. Есть три варианта: + +1. Добавить `runtimeSnapshot?: TeamAgentRuntimeSnapshot` в `buildTeamProvisioningPresentation()`. + 🎯 8 🛡️ 8 🧠 5 Примерно 80-130 строк. + +2. Дублировать aggregate diagnostic counts в `MemberSpawnStatusesSnapshot.summary`. + 🎯 9 🛡️ 9 🧠 6 Примерно 120-190 строк. + +3. Использовать только `progress.message`. + 🎯 6 🛡️ 5 🧠 3 Примерно 30-60 строк. + +Рекомендую 2: backend уже лучше знает truth model и может атомарно отдать `shellOnlyCount`, `runtimeProcessPendingCount`, `candidateCount`, `noRuntimeCount`. UI тогда не зависит от race между 2.5 sec spawn polling и 5 sec runtime polling. + +Расширить summary: + +```ts +export interface PersistedTeamLaunchSummary { + confirmedCount: number; + pendingCount: number; + failedCount: number; + // Compatibility aggregate only. Do not use as process evidence in UI. + runtimeAlivePendingCount: number; + shellOnlyPendingCount?: number; + runtimeProcessPendingCount?: number; + runtimeCandidatePendingCount?: number; + noRuntimePendingCount?: number; + permissionPendingCount?: number; +} +``` + +### Stepper semantics + +Файл: `src/renderer/components/team/provisioningSteps.ts` + +The current stepper uses: + +- `heartbeatConfirmedCount` +- `processOnlyAliveCount` +- `pendingSpawnCount` +- `failedSpawnCount` + +After strict liveness, `processOnlyAliveCount` must mean **strong runtime process only**. It must not include: + +- `shell_only` +- `runtime_process_candidate` +- `registered_only` +- `stale_metadata` +- `permission_blocked` + +Mapping: + +```ts +if (entry.launchState === 'runtime_pending_bootstrap') { + if (entry.runtimeAlive === true && entry.livenessKind === 'runtime_process') { + processOnlyAliveCount += 1; + } else { + pendingSpawnCount += 1; + } +} +``` + +Why this matters: the screenshot problem is exactly the UI being stuck on "Members joining". Shell-only should remain in joining until it fails, while verified process can move toward finalizing but still show `waiting for bootstrap`. + +### Copy diagnostics + +Добавить в launch details или member tooltip маленькое действие `Copy diagnostics`. + +Payload: + +```ts +interface MemberLaunchDiagnosticsPayload { + teamName: string; + memberName: string; + launchState?: MemberLaunchState; + spawnStatus?: MemberSpawnStatus; + livenessKind?: TeamAgentRuntimeLivenessKind; + livenessSource?: MemberSpawnLivenessSource; + pid?: number; + pidSource?: TeamAgentRuntimePidSource; + paneId?: string; + panePid?: number; + paneCurrentCommand?: string; + processCommand?: string; + runtimeDiagnostic?: string; + diagnostics?: string[]; + updatedAt?: string; +} +``` + +Это поможет быстро понять проблему на скрине друга без доступа к его машине. + +## Файлы для изменения + +Backend/shared: + +- `src/shared/types/team.ts` + - добавить liveness/pid source типы; + - расширить `TeamAgentRuntimeEntry`; + - добавить компактные diagnostic fields в `MemberSpawnStatusEntry`. + - добавить bounded `TeamLaunchDiagnosticItem` и `TeamProvisioningProgress.launchDiagnostics`. + +- `src/main/services/team/TeamRuntimeLivenessResolver.ts` + - вынести pure liveness classification; + - принимать tmux/process/OpenCode/persisted facts; + - возвращать strong/weak classification и sanitized diagnostics. + +- `src/features/tmux-installer/main/infrastructure/runtime/TmuxPlatformCommandExecutor.ts` + - добавить `listPaneRuntimeInfo()`; + - добавить `listRuntimeProcesses()` или equivalent; + - оставить `listPanePids()` совместимым wrapper. + +- `src/features/tmux-installer/main/composition/runtimeSupport.ts` + - экспортировать `listTmuxPaneRuntimeInfoForCurrentPlatform()`; + - экспортировать process table helper, если он живет в tmux runtime executor. + +- `src/main/services/team/TeamProvisioningService.ts` + - расширить `LiveTeamAgentRuntimeMetadata`; + - parse sanitized runtime tool `metadata`; + - добавить strict evidence helpers; + - использовать `TeamRuntimeLivenessResolver`; + - обновить `updateProgress()` extras для `launchDiagnostics`; + - переписать tmux/process resolution; + - убрать strong `online/process` shortcut из `already_running`; + - исправить `attachLiveRuntimeMetadataToStatuses()`; + - исправить `reevaluateMemberLaunchStatus()`; + - invalidate runtime caches на check-in/heartbeat/restart/stop; + - прокинуть diagnostics в `getTeamAgentRuntimeSnapshot()`. + +- `src/main/services/team/TeamLaunchStateEvaluator.ts` + - нормализовать persisted liveness diagnostic fields; + - считать optional diagnostic counts в summary; + - не превращать stale persisted `runtimeAlive` в live proof. + +- `src/main/services/team/runtime/OpenCodeTeamRuntimeAdapter.ts` + - не считать `created` bridge member strong alive без `runtimePid`; + - сохранить `runtimePid` и `sessionId` в persisted launch state. + +- `src/main/services/team/runtime/TeamRuntimeAdapter.ts` + - расширить `TeamRuntimeMemberLaunchEvidence` полями `livenessKind`, `pidSource`, `runtimeDiagnostic`; + - сохранить backward compatibility для существующих adapters. + +- `src/main/services/team/progressPayload.ts` + - добавить `boundLaunchDiagnostics()` и не расширять raw log tails. + +- `src/shared/types/api.ts` + - проверить, что existing `getMemberSpawnStatuses()` и `getTeamAgentRuntime()` contracts не требуют нового channel. + +- `src/preload/index.ts` + - оставить существующие IPC methods, убедиться typecheck проходит с optional fields. + +- `src/renderer/api/httpClient.ts` + - browser fallback должен оставаться valid при отсутствующих diagnostic fields. + +- `src/renderer/store/slices/teamSlice.ts` + - обновить `areTeamAgentRuntimeEntriesEqual()`; + - обновить `areMemberSpawnStatusEntriesEqual()`; + - обновить `areLaunchSummaryCountsEqual()`; + - убедиться, что runtime diagnostic changes не suppress-ятся. + +- `src/renderer/store/index.ts` + - на `member-spawn` event обновлять и spawn statuses, и runtime snapshot. + +Renderer: + +- `src/renderer/utils/memberHelpers.ts` + - добавить visual states и labels. + +- `src/renderer/utils/memberRuntimeSummary.ts` + - memory summary должен учитывать `pidSource`. + +- `src/renderer/components/team/members/MemberList.tsx` + - передать `runtimeEntry` и `spawnEntry` в presentation/member card layer. + +- `src/renderer/components/team/members/MemberCard.tsx` + - badge + tooltip + copy diagnostics. + +- `src/renderer/components/team/members/MemberDetailHeader.tsx` + - использовать тот же launch presentation contract, что и card. + +- `src/renderer/components/team/members/MemberHoverCard.tsx` + - не отставать от list/card labels. + +- `src/renderer/utils/teamProvisioningPresentation.ts` + - aggregate launch diagnostics. + +- `src/renderer/components/team/provisioningSteps.ts` + - `processOnlyAliveCount` считать только для strong runtime process. + +- `src/renderer/components/team/ProvisioningProgressBlock.tsx` + - добавить компактный Diagnostics disclosure для `launchDiagnostics`. + +## Tests + +Backend: + +- `TeamRuntimeLivenessResolver.test.ts` + - tmux foreground shell + no child -> `shell_only`; + - verified process row by `--team-name` + `--agent-id` -> `runtime_process`; + - non-shell descendant without identity -> `runtime_process_candidate`; + - persisted PID without current process identity -> `stale_metadata`; + - process command secrets are redacted in diagnostics; + - provider failure diagnostic does not produce strong alive. + +- `TeamProvisioningService.test.ts` + - tmux shell-only pane не ставит `runtimeAlive`; + - shell-only после 90 секунд становится `failed_to_start`; + - stale persisted `tmuxPaneId` не self-clear-ит failure; + - verified process by `--team-name` + `--agent-id` ставит `runtimeAlive`; + - runtime process candidate не считается strong alive; + - OpenCode `created` без `runtimePid` не ставит `runtimeAlive`; + - OpenCode `created` с `runtimePid` ставит `runtimeAlive`; + - OpenCode `sessionId` без `runtimePid` становится `runtime_process_candidate`, а не strong alive; + - `runtime_bootstrap_checkin` сохраняет `runtimeSessionId`, `livenessKind: "confirmed_bootstrap"`; + - stale runtime heartbeat от old `runId` rejected и не меняет launch state; + - runtime metadata PID без process identity не становится strong alive; + - `already_running` + shell-only не ставит `runtimeAlive`; + - permission blocked остается pending permission, не hard fail. + +- `TmuxPlatformCommandExecutor.test.ts` + - `listPaneRuntimeInfo()` парсит `pane_current_command`; + - `listPanePids()` остается совместимым pane-existence helper; + - process table parser поддерживает `pid`, `ppid`, `command`; + - WSL branch не использует host process table. + +Renderer: + +- `memberHelpers.test.ts` + - `shell_only` -> `shell only`; + - `runtime_process` + pending bootstrap -> `waiting for bootstrap`; + - `runtime_process_candidate` -> `process candidate`; + - permission state не затирается runtime diagnostics. + +- `memberRuntimeSummary.test.ts` + - `2 MB` с `pidSource=tmux_pane` получает tooltip/source `tmux pane shell`; + - runtime child показывает обычный runtime memory. + +- `teamSlice.test.ts` + - изменение `livenessKind` обновляет `teamAgentRuntimeByTeam`; + - изменение `runtimeDiagnostic` обновляет `teamAgentRuntimeByTeam`. + - изменение spawn `livenessKind/runtimeDiagnostic` обновляет `memberSpawnStatusesByTeam`; + - изменение optional summary diagnostic counts обновляет presentation. + - `member-spawn` event schedules both spawn status refresh and runtime snapshot refresh. + +- `httpClient.test.ts` + - browser fallback `getTeamAgentRuntime()` remains valid without diagnostic fields; + - browser fallback `getMemberSpawnStatuses()` remains valid without summary diagnostic counts. + +- `teamProvisioningPresentation.test.ts` + - banner показывает `3 shell-only, 1 waiting for bootstrap`; + - pending permission получает отдельный count. + +- `provisioningSteps.test.ts` + - `shell_only` не увеличивает `processOnlyAliveCount`; + - `runtime_process_candidate` не увеличивает `processOnlyAliveCount`; + - `runtime_process` увеличивает `processOnlyAliveCount`. + +- `ProvisioningProgressBlock.test.tsx` + - renders bounded `launchDiagnostics`; + - does not require opening CLI logs to see `shell only`; + - long process command is truncated/sanitized. + +## Phases + +### Phase 0 - Diagnostics without behavior change + +🎯 10 🛡️ 10 🧠 4 Примерно 180-260 строк. + +Добавить новые optional fields и заполнить `livenessKind`, `pidSource`, `paneCurrentCommand`, `diagnostics`, но пока не менять timeout behavior. + +Цель: увидеть на реальном launch, что именно определяется у друга: shell-only, process candidate, stale metadata или OpenCode bridge claim. + +Add: + +- `TeamRuntimeLivenessResolver` pure tests; +- process table/tmux providers; +- strict-only runtime evidence flow without a runtime-mode switch. + +Verification: + +```bash +pnpm typecheck +pnpm exec vitest run test/main/features/tmux-installer test/main/services/team/TeamProvisioningService.test.ts +``` + +### Phase 1 - Strict strong evidence + +🎯 9 🛡️ 9 🧠 7 Примерно 220-320 строк. + +Переключить `attachLiveRuntimeMetadataToStatuses()` на strong evidence only. Shell/pane/candidate больше не выставляют `runtimeAlive`. + +Verification: + +```bash +pnpm exec vitest run test/main/services/team/TeamProvisioningService.test.ts +``` + +### Phase 2 - Timeout and self-heal hardening + +🎯 9 🛡️ 9 🧠 6 Примерно 120-180 строк. + +Исправить `reevaluateMemberLaunchStatus()`: + +- shell-only/no-runtime/stale -> fail after 90s; +- permission -> stay pending permission; +- candidate -> warning, fail after 5 min; +- verified runtime -> warning, no false hard fail at 90s; +- auto-clear failure только по strong evidence. + +### Phase 3 - UI visibility + +🎯 9 🛡️ 8 🧠 6 Примерно 220-320 строк. + +Добавить: + +- labels `shell only`, `waiting for bootstrap`, `process candidate`; +- tooltip; +- aggregate banner detail; +- copy diagnostics. + +### Phase 4 - Real launch validation + +🎯 8 🛡️ 9 🧠 6 Примерно 100-180 строк тестовых fixtures/scripts. + +Manual checks: + +```bash +tmux list-panes -a -F '#{pane_id} #{pane_pid} #{pane_current_command}' +ps -ax -o pid=,ppid=,command= | rg '||claude|codex|opencode' +``` + +Scenarios: + +1. Успешный Anthropic tmux launch. +2. Shell-only pane. +3. Missing MCP/member_briefing. +4. Permission pending. +5. OpenCode bridge member without `runtimePid`. +6. OpenCode bridge member with `runtimePid`. +7. Restart member while old pane exists. + +## Acceptance criteria + +1. Tmux pane жив, foreground command `zsh/bash/sh`, runtime child не найден: + - `TeamAgentRuntimeEntry.alive === false` + - `livenessKind === "shell_only"` + - `pidSource === "tmux_pane"` + - UI показывает `shell only` + - после 90 секунд member становится `failed_to_start` + +2. Найден process с `--team-name --agent-id `: + - `TeamAgentRuntimeEntry.alive === true` + - `livenessKind === "runtime_process"` + - `MemberSpawnStatusEntry.runtimeAlive === true` + - UI показывает `waiting for bootstrap`, если bootstrap еще не пришел + +3. Member сделал check-in: + - `bootstrapConfirmed === true` + - `livenessKind === "confirmed_bootstrap"` + - `launchState === "confirmed_alive"` + - UI показывает `ready` + +4. Persisted metadata есть, process не найден: + - не self-clear failure; + - не `runtimeAlive`; + - UI показывает `stale runtime` или `registered`. + +5. OpenCode bridge вернул member без `runtimePid`: + - `agentToolAccepted === true`; + - `runtimeAlive === false`; + - UI показывает pending/bridge diagnostics, не `online`. + +6. `2.0 MB` больше не выглядит как полноценный runtime: + - tooltip объясняет `RSS source: tmux pane shell`; + - launch badge показывает `shell only`. + +7. Launch details объясняет stuck state без открытия logs: + - `launchDiagnostics` содержит bounded rows; + - UI показывает хотя бы `shell only`, `waiting for bootstrap`, `no runtime found`; + - `cliLogsTail` и `assistantOutput` остаются bounded. + +8. Store suppression не скрывает диагностику: + - изменение `livenessKind` меняет renderer state; + - изменение summary diagnostic counts меняет presentation; + - `member-spawn` event refreshes runtime snapshot. + +9. Rollout безопасен: + - strict behavior включен по умолчанию; + - diagnostics UI остается доступным без отдельного mode flag; + - rollback требует явного code revert или отдельного follow-up setting. + +10. Provider failures не создают ложный ready: + +- process table failure дает `process_table_unavailable`; +- tmux/process provider failure не self-clear-ит failure; +- command diagnostics sanitized and truncated. + +## Main risks + +### False negative для реального runtime + +Если реальный teammate не содержит `--team-name`/`--agent-id` в command, strict model может понизить его до candidate. + +Mitigation: + +- Phase 0 сначала собирает diagnostics без behavior change. +- Candidate не fail-ится за 90 секунд. +- Allowlist runtime command markers добавлять только после реальных данных. + +### Windows/WSL process tree + +Host-side process table не увидит Linux tmux descendants. + +Mitigation: + +- process table должен жить рядом с tmux executor; +- Windows branch должен запускать `ps` внутри WSL distro. + +### OpenCode shared host + +Один OpenCode host PID может обслуживать несколько members. + +Mitigation: + +- `runtimePid` хранить как `metricsPid`, если это shared host; +- `restartable=false`, если PID не member-owned; +- UI label `shared OpenCode host`, не "member runtime". + +### UI overload + +Слишком много деталей в карточке сделают интерфейс шумным. + +Mitigation: + +- короткий badge в карточке; +- детали в tooltip; +- aggregate counts в banner; +- полный JSON только через copy diagnostics. + +### Process command privacy + +`ps` command can include cwd, file paths, API keys or tokens. + +Mitigation: + +- identity matching uses raw command only inside main process memory; +- UI/logs/copy diagnostics receive sanitized command only; +- redact common secret flags; +- truncate command strings to 500 chars; +- do not include raw runtime tool metadata. + +### Process table overhead + +Reading `ps` per member would be wasteful and flaky on large systems. + +Mitigation: + +- read process table once per runtime snapshot; +- keep existing 2 sec backend cache TTL; +- do not call `pidusage` for weak shell-only rows unless UI needs memory display; +- cap diagnostics to 20 progress rows. + +## Minimal safe patch order + +1. Добавить типы и optional fields. +2. Добавить sanitized runtime tool metadata parser. +3. Добавить tmux `listPaneRuntimeInfo()` и сохранить wrapper `listPanePids()`. +4. Добавить process table provider/parser с `ppid`. +5. Вынести `TeamRuntimeLivenessResolver`. +6. Заполнить `livenessKind`. +7. Написать backend tests на shell-only, verified runtime, stale event, metadata PID. +8. Переключить `attachLiveRuntimeMetadataToStatuses()` на strong evidence. +9. Исправить `already_running` shortcut. +10. Переключить timeout/self-heal logic на strong evidence. +11. Исправить OpenCode bridge mapping. +12. Обновить persisted summary diagnostics и store equality. +13. Добавить `launchDiagnostics` в progress payload и UI disclosure. +14. Добавить renderer labels/tooltips/banner. +15. Добавить copy diagnostics. +16. Manual validation: создать команду, проверить pending names, runtime diagnostics и отсутствие false-ready shell-only процесса. + +## Expected UX + +Before: + +```text +bob starting 2.0 MB +jack starting 2.0 MB +tom starting 2.0 MB +``` + +After: + +```text +bob shell only Anthropic · Opus 4.7 · 2.0 MB +jack waiting for bootstrap Anthropic · Opus 4.7 · 418 MB +tom spawn failed no runtime process after 90s +``` + +Launch banner: + +```text +4 teammates still joining - 3 shell-only, 1 waiting for bootstrap +``` + +Tooltip for shell-only: + +```text +Spawn accepted: yes +Registered in config: yes +Runtime process: not found +Tmux pane: alive +Foreground command: zsh +PID source: tmux pane +Bootstrap: no member_briefing/check-in yet +``` diff --git a/docs/team-management/opencode-native-semantic-messaging-plan.md b/docs/team-management/opencode-native-semantic-messaging-plan.md index 5f5a668f..59b4348e 100644 --- a/docs/team-management/opencode-native-semantic-messaging-plan.md +++ b/docs/team-management/opencode-native-semantic-messaging-plan.md @@ -1,7 +1,7 @@ # OpenCode Native Semantic Messaging Plan -Status: planning document -Scope: `claude_team` + `agent_teams_orchestrator` +Status: planning document +Scope: `claude_team` + `agent_teams_orchestrator` Goal: make OpenCode teammates use the correct app MCP messaging protocol without breaking Codex/Claude native teammates. ## Problem @@ -30,13 +30,13 @@ This can make OpenCode teammates look started but not answer through the Message Chosen approach: OpenCode-native semantic messaging seam. -Option 1: frontend-only display patch - 🎯 2 🛡️ 2 🧠 2, about 50-120 LOC +Option 1: frontend-only display patch - 🎯 2 🛡️ 2 🧠 2, about 50-120 LOC This hides symptoms only. It does not fix the wrong tool instructions sent to OpenCode. -Option 2: orchestrator-only patch - 🎯 6 🛡️ 6 🧠 4, about 180-320 LOC +Option 2: orchestrator-only patch - 🎯 6 🛡️ 6 🧠 4, about 180-320 LOC This is necessary for runtime identity and MCP proof, but not sufficient because `member_briefing` and task assignment messages are produced in `claude_team`. -Option 3: orchestrator + `claude_team` controller/MCP semantic seam - 🎯 9 🛡️ 9 🧠 7, about 1300-2200 LOC with tests +Option 3: orchestrator + `claude_team` controller/MCP semantic seam - 🎯 9 🛡️ 9 🧠 7, about 1300-2200 LOC with tests This fixes the actual contract. Orchestrator owns OpenCode session identity. `claude_team` owns team protocol text and MCP tool schemas. ## Extra Research Corrections @@ -54,11 +54,9 @@ This section records the higher-risk places that were checked after the first dr - The required app-tool proof must cover all teammate-operational tools that `member_briefing` can instruct, not just `message_send` and four task tools. - `claude_team` already exports `AGENT_TEAMS_TEAMMATE_OPERATIONAL_TOOL_NAMES` from `agent-teams-controller`; app-side required tools should derive from that instead of duplicating a second list. - Orchestrator direct `mcp:tools/list` proof sees plain MCP names like `message_send`, not OpenCode canonical ids. Do not compare direct stdio results against `agent_teams_message_send` or `agent-teams_message_send`. -- However, orchestrator readiness currently exposes `toolProof.observedTools` through `readiness.evidence.observedMcpTools`, and the production live evidence builder reuses that field as `evidence.mcpTools.observedTools`. So direct proof should match plain names internally, but should still emit canonical OpenCode ids for public bridge/evidence output, or add a separate explicit `observedDirectToolNames` field. Do not silently change `observedMcpTools` to plain names. +- However, orchestrator readiness currently exposes `toolProof.observedTools` through `readiness.evidence.observedMcpTools`. Direct proof should match plain names internally, but bridge output should keep a clearly named field if canonical OpenCode ids are needed later. Do not silently change `observedMcpTools` semantics. - `agent_teams_orchestrator` does not currently depend on `agent-teams-controller`. Do not import the controller catalog into the orchestrator in v1 unless intentionally adding a new cross-repo/package dependency. -- `OpenCodeReadinessBridge.applyProductionE2EGate()` still builds `requiredMcpTools` from runtime-only tools. If app-side readiness starts requiring teammate-operational tools, the production E2E gate must be moved to the same app tool contract or it will validate a weaker/stale artifact. -- `assertOpenCodeProductionE2EArtifactGate()` compares expected tool ids against `evidence.mcpTools.observedTools` exactly. Stale evidence generated before this change should fail production mode clearly instead of silently pretending the stronger proof exists. -- `scripts/prove-opencode-production.mjs` is only a launcher. The production evidence JSON is built in `test/main/services/team/OpenCodeProductionGate.live.test.ts` inside `buildCandidateEvidence()`. Updating the script alone does nothing; update the live test builder and gate expectations. +- The old project-proof gate was removed from OpenCode launch readiness. Do not reintroduce project-scoped launch blocking for selected models; runtime readiness should be based on inventory, capabilities, runtime stores, app MCP tool proof, and the execution probe. - Current controller teammate-operational catalog includes more than the obvious message/task-start tools: `task_attach_comment_file`, `task_attach_file`, `task_create`, `task_create_from_message`, `task_link`, and `task_unlink` are also teammate-operational and must be included in any explicit orchestrator v1 list. - `mcp-server/src/agent-teams-controller.d.ts` and `src/types/agent-teams-controller.d.ts` mirror controller signatures and must be updated when `memberBriefing(memberName, options)` is added. - `agent-teams-controller` is CommonJS and existing TS code imports it as `import * as agentTeamsControllerModule from 'agent-teams-controller'`; use that pattern in new app-side imports instead of assuming a default ESM export. @@ -190,13 +188,10 @@ Implementation rule: - `/Users/belief/dev/projects/claude/claude_team/src/types/agent-teams-controller.d.ts` - `/Users/belief/dev/projects/claude/claude_team/src/main/services/team/opencode/mcp/OpenCodeMcpToolAvailability.ts` - `/Users/belief/dev/projects/claude/claude_team/src/main/services/team/opencode/bridge/OpenCodeReadinessBridge.ts` -- `/Users/belief/dev/projects/claude/claude_team/src/main/services/team/opencode/e2e/OpenCodeProductionE2EEvidence.ts` - `/Users/belief/dev/projects/claude/claude_team/src/main/services/team/opencode/delivery/RuntimeDeliveryService.ts` - `/Users/belief/dev/projects/claude/claude_team/src/main/services/team/opencode/store/OpenCodeRuntimeManifestEvidenceReader.ts` - `/Users/belief/dev/projects/claude/claude_team/src/main/services/team/taskLogs/stream/OpenCodeTaskLogStreamSource.ts` - `/Users/belief/dev/projects/claude/claude_team/src/main/services/team/taskLogs/stream/BoardTaskLogStreamService.ts` -- `/Users/belief/dev/projects/claude/claude_team/test/main/services/team/OpenCodeProductionGate.live.test.ts` -- `/Users/belief/dev/projects/claude/claude_team/scripts/prove-opencode-production.mjs` `agent_teams_orchestrator` files: @@ -222,7 +217,9 @@ Example: ```js function normalizeRuntimeProvider(value) { - const normalized = String(value || '').trim().toLowerCase(); + const normalized = String(value || '') + .trim() + .toLowerCase(); return normalized === 'opencode' ? 'opencode' : 'native'; } @@ -267,7 +264,9 @@ function createMemberMessagingProtocol(runtimeProvider) { } function isOpenCodeMember(member) { - const provider = String(member?.providerId || member?.provider || '').trim().toLowerCase(); + const provider = String(member?.providerId || member?.provider || '') + .trim() + .toLowerCase(); return provider === 'opencode'; } @@ -302,9 +301,7 @@ Edit pattern: ```js function copyTrimmedString(member, key) { - return typeof member[key] === 'string' && member[key].trim() - ? { [key]: member[key].trim() } - : {}; + return typeof member[key] === 'string' && member[key].trim() ? { [key]: member[key].trim() } : {}; } ``` @@ -314,9 +311,15 @@ Then preserve fields: return { name, ...(typeof member.role === 'string' && member.role.trim() ? { role: member.role.trim() } : {}), - ...(typeof member.workflow === 'string' && member.workflow.trim() ? { workflow: member.workflow.trim() } : {}), - ...(typeof member.agentType === 'string' && member.agentType.trim() ? { agentType: member.agentType.trim() } : {}), - ...(typeof member.color === 'string' && member.color.trim() ? { color: member.color.trim() } : {}), + ...(typeof member.workflow === 'string' && member.workflow.trim() + ? { workflow: member.workflow.trim() } + : {}), + ...(typeof member.agentType === 'string' && member.agentType.trim() + ? { agentType: member.agentType.trim() } + : {}), + ...(typeof member.color === 'string' && member.color.trim() + ? { color: member.color.trim() } + : {}), ...(typeof member.cwd === 'string' && member.cwd.trim() ? { cwd: member.cwd.trim() } : {}), ...copyTrimmedString(member, 'providerId'), ...copyTrimmedString(member, 'providerBackendId'), @@ -398,7 +401,8 @@ Inside the function: ```js const explicitRuntimeProvider = options.runtimeProvider; -const inferredRuntimeProvider = explicitRuntimeProvider || (isOpenCodeMember(effectiveMember) ? 'opencode' : 'native'); +const inferredRuntimeProvider = + explicitRuntimeProvider || (isOpenCodeMember(effectiveMember) ? 'opencode' : 'native'); const messagingProtocol = createMemberMessagingProtocol(inferredRuntimeProvider); ``` @@ -702,13 +706,11 @@ Do not hide `runtime_deliver_message` from readiness or app tool availability pr Instead, make tool descriptions and OpenCode prompts explicitly route normal replies: ```ts -description: - 'Send a visible team/user message. OpenCode teammates should use this for normal replies to the human user, lead, or same-team teammates. When to is "user", from is required and must be your configured teammate name.' +description: 'Send a visible team/user message. OpenCode teammates should use this for normal replies to the human user, lead, or same-team teammates. When to is "user", from is required and must be your configured teammate name.'; ``` ```ts -description: - 'Low-level OpenCode runtime delivery journal tool. Use only when the runtime/app prompt explicitly provides runId, runtimeSessionId, idempotencyKey, and asks for runtime delivery. For normal visible replies, use message_send.' +description: 'Low-level OpenCode runtime delivery journal tool. Use only when the runtime/app prompt explicitly provides runId, runtimeSessionId, idempotencyKey, and asks for runtime delivery. For normal visible replies, use message_send.'; ``` OpenCode-specific prompt wording should avoid generic "deliver message" language: @@ -778,7 +780,9 @@ function normalizeMessageSendFlags(context, flags) { throw new Error('message_send cannot target another team. Use cross_team_send with toTeam.'); } if (!resolvedTo && runtimeHelpers.looksLikeCrossTeamToolRecipient?.(rawTo)) { - throw new Error('message_send cannot target cross_team_send. Use cross_team_send with toTeam.'); + throw new Error( + 'message_send cannot target cross_team_send. Use cross_team_send with toTeam.' + ); } if (!resolvedTo) { throw new Error(`Unknown to: ${rawTo}. Use a configured team member name.`); @@ -837,13 +841,13 @@ Current fact: Options: -Option A: keep cross-team `taskRefs` out of v1 prompts - 🎯 8 🛡️ 8 🧠 2, about 0-25 LOC +Option A: keep cross-team `taskRefs` out of v1 prompts - 🎯 8 🛡️ 8 🧠 2, about 0-25 LOC Safest if we want the smallest messaging seam. The helper must not accept or render `taskRefs` for `buildCrossTeamMessageExample()` yet. -Option B: wire cross-team `taskRefs` end-to-end now - 🎯 8 🛡️ 9 🧠 4, about 70-150 LOC +Option B: wire cross-team `taskRefs` end-to-end now - 🎯 8 🛡️ 9 🧠 4, about 70-150 LOC Best if the helper is meant to be a real semantic messaging seam with uniform traceability. Add `taskRefs` to `cross_team_send` schema, normalize it in controller, store it in target inbox row, append it to sent message, and persist it in `sent-cross-team.json`. -Chosen for v1 if Step 1 helper has a generic `taskRefs` option: Option B. +Chosen for v1 if Step 1 helper has a generic `taskRefs` option: Option B. Chosen for v1 if Step 1 helper only renders static examples: Option A. Implementation for Option B: @@ -903,7 +907,7 @@ Current risk: `TeamProvisioningService.captureSendMessages()` recognizes only: ```ts -part.name === 'mcp__agent-teams__message_send' +part.name === 'mcp__agent-teams__message_send'; ``` But OpenCode and MCP tooling can expose names as: @@ -957,8 +961,7 @@ export function isAgentTeamsToolUse(input: { } const hasKnownPrefix = - rawName !== canonical || - AGENT_TEAMS_PREFIXES.some((prefix) => rawName.startsWith(prefix)); + rawName !== canonical || AGENT_TEAMS_PREFIXES.some((prefix) => rawName.startsWith(prefix)); if (hasKnownPrefix) { return true; } @@ -1078,23 +1081,23 @@ Add a helper: ```ts function buildOpenCodeRuntimeIdentityBlock(input: { - teamName: string - memberName: string - runId: string - runtimeSessionId: string + teamName: string; + memberName: string; + runId: string; + runtimeSessionId: string; }): string { const checkinPayload = { teamName: input.teamName, runId: input.runId, memberName: input.memberName, runtimeSessionId: input.runtimeSessionId, - } + }; const briefingPayload = { teamName: input.teamName, memberName: input.memberName, runtimeProvider: 'opencode', - } + }; return [ '', @@ -1105,7 +1108,7 @@ function buildOpenCodeRuntimeIdentityBlock(input: { `Call the exposed Agent Teams member_briefing tool, usually agent-teams_member_briefing or mcp__agent-teams__member_briefing, with: ${JSON.stringify(briefingPayload)}`, 'For visible team/app messages, use the exposed Agent Teams message_send tool, usually agent-teams_message_send or mcp__agent-teams__message_send. Do not use SendMessage.', '', - ].join('\n') + ].join('\n'); } ``` @@ -1117,12 +1120,12 @@ const runtimeIdentityBlock = buildOpenCodeRuntimeIdentityBlock({ memberName: name, runId, runtimeSessionId: record.opencodeSessionId, -}) +}); await openCodeSessionBridge.promptAsync(record, { text: `${runtimeIdentityBlock}\n\n${prompt}`, agent: 'teammate', -}) +}); ``` Then add a bounded launch-settle helper before mapping the member as final `created`/`confirmed_alive`. @@ -1135,13 +1138,13 @@ Do not add this as a serial wait inside the existing member loop. Options: -Option A: serial settle inside the existing loop - 🎯 4 🛡️ 5 🧠 2, about 30-60 LOC +Option A: serial settle inside the existing loop - 🎯 4 🛡️ 5 🧠 2, about 30-60 LOC Easy, but bad for UX. Three OpenCode teammates with an 8 second preview cap can add 24 seconds of launch latency. -Option B: two-phase launch with bounded concurrent settle - 🎯 8 🛡️ 8 🧠 5, about 140-260 LOC +Option B: two-phase launch with bounded concurrent settle - 🎯 8 🛡️ 8 🧠 5, about 140-260 LOC First ensure sessions and enqueue prompts for all members. Then run bounded preview/reconcile concurrently per prompted member with a small local concurrency cap. This fixes early false `created` without multiplying wait time by teammate count or opening one preview stream per teammate in large teams. -Option C: no settle, rely only on later reconcile - 🎯 5 🛡️ 6 🧠 1, about 0-20 LOC +Option C: no settle, rely only on later reconcile - 🎯 5 🛡️ 6 🧠 1, about 0-20 LOC Avoids launch delay, but keeps the stale/early UI state that caused OpenCode teammates to look unspawned or stuck. Chosen for v1: Option B with a local cap of 3 concurrent settle observers. Do not add a dependency just for this; use a tiny local mapper/helper in the orchestrator testable unit. @@ -1419,13 +1422,13 @@ Risk: Options: -Option A: keep current text parsing - 🎯 4 🛡️ 5 🧠 1, about 0-15 LOC +Option A: keep current text parsing - 🎯 4 🛡️ 5 🧠 1, about 0-15 LOC Smallest, but fragile and contradicts the semantic seam goal. -Option B: pass explicit metadata while still sending the native stored text to OpenCode - 🎯 7 🛡️ 7 🧠 3, about 50-100 LOC +Option B: pass explicit metadata while still sending the native stored text to OpenCode - 🎯 7 🛡️ 7 🧠 3, about 50-100 LOC Better recipient reliability, but still leaves confusing `SendMessage` wording inside the OpenCode prompt. -Option C: keep native inbox text only for native recipients, persist base text for OpenCode recipients, and deliver an OpenCode-native runtime message - 🎯 9 🛡️ 9 🧠 6, about 180-320 LOC with tests +Option C: keep native inbox text only for native recipients, persist base text for OpenCode recipients, and deliver an OpenCode-native runtime message - 🎯 9 🛡️ 9 🧠 6, about 180-320 LOC with tests Best shape. Codex/Claude keep the existing persisted inbox text because they read inbox files directly. OpenCode inbox rows stay clean/retryable with base user text, while OpenCode receives explicit runtime delivery metadata through the adapter/relay. Chosen for v1: Option C. @@ -1512,7 +1515,9 @@ function buildOpenCodeRuntimeMessageText(input: OpenCodeTeamRuntimeMessageInput) '', '', input.text, - ].filter((line): line is string => line !== null).join('\n'); + ] + .filter((line): line is string => line !== null) + .join('\n'); } ``` @@ -1569,13 +1574,13 @@ Risk: Options: -Option A: keep fire-and-forget and add more logs - 🎯 5 🛡️ 5 🧠 1, about 10-30 LOC +Option A: keep fire-and-forget and add more logs - 🎯 5 🛡️ 5 🧠 1, about 10-30 LOC This helps debugging but keeps the user-facing contract dishonest. -Option B: await OpenCode runtime relay for live OpenCode non-lead sends, return additive delivery status, and fix renderer action result/error propagation - 🎯 9 🛡️ 9 🧠 5, about 160-300 LOC with tests +Option B: await OpenCode runtime relay for live OpenCode non-lead sends, return additive delivery status, and fix renderer action result/error propagation - 🎯 9 🛡️ 9 🧠 5, about 160-300 LOC with tests This keeps native persistence behavior unchanged, makes OpenCode failure visible, keeps retry routing in one OpenCode inbox relay path, and fixes the existing dead caller catch path that controls pending-reply cleanup. -Option C: add a durable OpenCode delivery queue with retries and UI retry state - 🎯 8 🛡️ 10 🧠 8, about 350-700 LOC +Option C: add a durable OpenCode delivery queue with retries and UI retry state - 🎯 8 🛡️ 10 🧠 8, about 350-700 LOC This is the best long-term reliability shape, but it is too much to bundle into the semantic messaging seam unless delivery reliability remains flaky after v1. Chosen for v1: Option B. @@ -1672,7 +1677,7 @@ sendTeamMessage: async (teamName, request) => { }); throw error; } -} +}; ``` Update call sites so pending-reply state reflects actual delivery truth: @@ -1730,13 +1735,13 @@ Risk examples: Options: -Option A: only support UI direct-send to OpenCode in v1 - 🎯 5 🛡️ 5 🧠 2, about 0-40 LOC +Option A: only support UI direct-send to OpenCode in v1 - 🎯 5 🛡️ 5 🧠 2, about 0-40 LOC This leaves OpenCode-to-OpenCode and system notification routes unreliable. It is not enough for a real team messaging seam. -Option B: add OpenCode-targeted inbox runtime relay with messageId dedupe/read marking, plus explicit unsupported-lead diagnostics - 🎯 9 🛡️ 9 🧠 6, about 240-440 LOC with tests +Option B: add OpenCode-targeted inbox runtime relay with messageId dedupe/read marking, plus explicit unsupported-lead diagnostics - 🎯 9 🛡️ 9 🧠 6, about 240-440 LOC with tests This preserves native behavior, routes only recipients whose provider is OpenCode, and makes any persisted inbox row deliverable to live OpenCode lanes. -Option C: replace both native and OpenCode inbox handling with a new durable delivery queue - 🎯 8 🛡️ 10 🧠 9, about 600-1200 LOC +Option C: replace both native and OpenCode inbox handling with a new durable delivery queue - 🎯 8 🛡️ 10 🧠 9, about 600-1200 LOC Architecturally clean long-term, but too large for this seam and risky with existing native watchers. Chosen for v1: Option B. @@ -1873,13 +1878,13 @@ File: First decide how the required teammate-operational tool list is owned. -Option A: import `agent-teams-controller` into `agent_teams_orchestrator` - 🎯 5 🛡️ 6 🧠 6, about 80-160 LOC +Option A: import `agent-teams-controller` into `agent_teams_orchestrator` - 🎯 5 🛡️ 6 🧠 6, about 80-160 LOC This removes list duplication, but it adds a new package dependency from the runtime/orchestrator repo into the app controller package. That is a larger architecture decision than this fix needs. -Option B: keep an explicit direct-MCP required list in orchestrator v1 - 🎯 8 🛡️ 8 🧠 3, about 60-140 LOC +Option B: keep an explicit direct-MCP required list in orchestrator v1 - 🎯 8 🛡️ 8 🧠 3, about 60-140 LOC This matches the current repo boundary. The orchestrator only needs plain MCP names for direct `Client.listTools()` proof. Add tests that fail when critical teammate tools like `message_send`, `member_briefing`, `task_start`, or `cross_team_send` are missing. -Option C: generate a shared protocol contract artifact consumed by both repos - 🎯 8 🛡️ 9 🧠 7, about 250-450 LOC +Option C: generate a shared protocol contract artifact consumed by both repos - 🎯 8 🛡️ 9 🧠 7, about 250-450 LOC This is the best long-term shape, but it needs generation, publishing, and CI checks. Treat it as a follow-up after v1 proves the semantic seam. Chosen for v1: Option B. Do not import `agent-teams-controller` into `agent_teams_orchestrator` in this change. @@ -1904,7 +1909,7 @@ const REQUIRED_AGENT_TEAMS_RUNTIME_TOOLS = [ 'runtime_deliver_message', 'runtime_task_event', 'runtime_heartbeat', -] as const +] as const; ``` Change to route-specific direct MCP names. @@ -1913,10 +1918,10 @@ Important: - `Client.listTools()` returns plain names such as `message_send`. - Do not prefix direct stdio results with `agent-teams_`. -- Only OpenCode app/API tool-id proof and production E2E evidence should deal with canonical ids like `agent-teams_message_send`. +- Only OpenCode app/API tool-id proof should deal with canonical ids like `agent-teams_message_send`. - `agent_teams_message_send` is an accepted alias, not the canonical id produced by `buildOpenCodeCanonicalMcpToolId('agent-teams', 'message_send')`. - The orchestrator explicit list should be treated as a boundary adapter, not as the source of truth for app-side UI/readiness. -- `readiness.evidence.observedMcpTools` is already consumed by production evidence as OpenCode tool ids. Keep that public field canonical. If direct proof needs plain diagnostics, add a second private/internal field such as `observedDirectToolNames`. +- Keep `readiness.evidence.observedMcpTools` canonical if it is exposed through the bridge. If direct proof needs plain diagnostics, add a second private/internal field such as `observedDirectToolNames`. ```ts const REQUIRED_AGENT_TEAMS_RUNTIME_PROOF_TOOLS = [ @@ -1924,7 +1929,7 @@ const REQUIRED_AGENT_TEAMS_RUNTIME_PROOF_TOOLS = [ 'runtime_deliver_message', 'runtime_task_event', 'runtime_heartbeat', -] as const +] as const; const REQUIRED_AGENT_TEAMS_TEAMMATE_OPERATIONAL_TOOLS = [ 'member_briefing', @@ -1956,20 +1961,20 @@ const REQUIRED_AGENT_TEAMS_TEAMMATE_OPERATIONAL_TOOLS = [ 'cross_team_send', 'cross_team_list_targets', 'cross_team_get_outbox', -] as const +] as const; const REQUIRED_AGENT_TEAMS_DIRECT_MCP_TOOL_NAMES = [ ...REQUIRED_AGENT_TEAMS_RUNTIME_PROOF_TOOLS, ...REQUIRED_AGENT_TEAMS_TEAMMATE_OPERATIONAL_TOOLS, -] as const +] as const; ``` Update direct listTools mapping: ```ts return (result.tools ?? []) - .map(tool => tool.name) - .filter((name): name is string => typeof name === 'string' && name.trim().length > 0) + .map((tool) => tool.name) + .filter((name): name is string => typeof name === 'string' && name.trim().length > 0); ``` Compare plain names internally: @@ -1988,14 +1993,14 @@ But emit canonical ids for bridge readiness/evidence: ```ts function buildOpenCodeCanonicalMcpToolId(toolName: string): string { - return `${OPEN_CODE_APP_MCP_SERVER_NAME}_${toolName}` + return `${OPEN_CODE_APP_MCP_SERVER_NAME}_${toolName}`; } function matchAppMcpTools(observedDirectToolNames: string[], route: string): AppMcpToolProof { - const observedDirect = new Set(observedDirectToolNames) + const observedDirect = new Set(observedDirectToolNames); const missingTools = REQUIRED_AGENT_TEAMS_DIRECT_MCP_TOOL_NAMES.filter( - tool => !observedDirect.has(tool) - ) + (tool) => !observedDirect.has(tool) + ); return { ok: missingTools.length === 0, @@ -2004,11 +2009,12 @@ function matchAppMcpTools(observedDirectToolNames: string[], route: string): App ), observedDirectToolNames: uniqueSortedStrings(observedDirectToolNames), missingTools, - diagnostics: missingTools.length === 0 - ? [] - : [`OpenCode app MCP tools missing from ${route}: ${missingTools.join(', ')}`], + diagnostics: + missingTools.length === 0 + ? [] + : [`OpenCode app MCP tools missing from ${route}: ${missingTools.join(', ')}`], route, - } + }; } ``` @@ -2093,96 +2099,26 @@ Acceptance: - Existing callers that truly mean runtime schema tools should use `REQUIRED_AGENT_TEAMS_RUNTIME_PROOF_TOOLS`, not the full app list. - Existing callers that mean launch-visible app tools should use `REQUIRED_AGENT_TEAMS_APP_TOOLS` or `REQUIRED_AGENT_TEAMS_APP_TOOL_IDS`. -### Step 12 - Update production E2E gate to prove the same app tools +### Step 12 - Keep app tool proof in readiness only Files: ```text /Users/belief/dev/projects/claude/claude_team/src/main/services/team/opencode/bridge/OpenCodeReadinessBridge.ts -/Users/belief/dev/projects/claude/claude_team/src/main/services/team/opencode/e2e/OpenCodeProductionE2EEvidence.ts -/Users/belief/dev/projects/claude/claude_team/test/main/services/team/OpenCodeProductionGate.live.test.ts -/Users/belief/dev/projects/claude/claude_team/scripts/prove-opencode-production.mjs +/Users/belief/dev/projects/claude/claude_team/src/main/services/team/opencode/mcp/OpenCodeMcpToolAvailability.ts ``` -Current risk: +Current rule: -```ts -requiredMcpTools: REQUIRED_AGENT_TEAMS_RUNTIME_TOOLS.map((tool) => - buildOpenCodeCanonicalMcpToolId('agent-teams', tool) -) -``` - -That is weaker than the planned app-tool readiness proof. It can create two bad states: - -- Production gate passes an artifact that proves runtime tools only, while OpenCode can still miss `message_send` or `member_briefing`. -- Production gate fails confusingly after the required list changes because old evidence was generated with the weaker runtime-only set. - -Change `OpenCodeReadinessBridge.applyProductionE2EGate()` to use the full app tool id list: - -```ts -import { REQUIRED_AGENT_TEAMS_APP_TOOL_IDS } from '../mcp/OpenCodeMcpToolAvailability'; - -// ... -requiredMcpTools: REQUIRED_AGENT_TEAMS_APP_TOOL_IDS, -``` - -Update the live evidence builder in `OpenCodeProductionGate.live.test.ts`. - -Current builder path: - -```ts -mcpTools: { - requiredTools: REQUIRED_AGENT_TEAMS_RUNTIME_TOOLS.map((tool) => - buildOpenCodeCanonicalMcpToolId('agent-teams', tool) - ), - observedTools: input.readinessObservedTools, -}, -``` - -Change it to: - -```ts -mcpTools: { - requiredTools: REQUIRED_AGENT_TEAMS_APP_TOOL_IDS, - observedTools: input.readinessObservedTools, -}, -``` - -Guard the shape explicitly because `input.readinessObservedTools` comes from the orchestrator bridge: - -```ts -const missingObservedAppToolIds = REQUIRED_AGENT_TEAMS_APP_TOOL_IDS.filter( - (toolId) => !input.readinessObservedTools.includes(toolId) -); -expect(missingObservedAppToolIds).toEqual([]); -``` - -If that assertion fails after Step 10, the orchestrator is probably returning plain direct tool names in `readiness.evidence.observedMcpTools`. Fix the bridge output instead of weakening production evidence. - -Keep the evidence validator exact: - -```ts -const observedTools = new Set(evidence.mcpTools.observedTools); -const missingTools = requiredTools.filter((tool) => !observedTools.has(tool)); -``` - -Do not add alias fallback here. Evidence should prove the canonical OpenCode app ids that production readiness expects. Alias tolerance belongs in transcript/capture parsing, not in production artifact gating. - -Update live/evidence test fixtures: - -```ts -const requiredToolIds = REQUIRED_AGENT_TEAMS_APP_TOOL_IDS; -``` - -`scripts/prove-opencode-production.mjs` should not need evidence JSON logic changes because it only launches the live test. Do update its console copy only if it refers to runtime-only proof. The real acceptance point is the artifact written by `OpenCodeProductionGate.live.test.ts`. +- OpenCode launch readiness should not require a project-scoped proof artifact. +- App tool proof belongs in the live readiness path through capability, runtime-store, MCP tool, and execution checks. +- If tool requirements change, update `OpenCodeMcpToolAvailability` and readiness tests directly. Acceptance: -- Production evidence must include canonical ids for runtime and teammate-operational tools. -- `message_send`, `member_briefing`, `task_start`, and `cross_team_send` are explicitly covered by tests. -- Old runtime-only evidence fails production mode with a clear diagnostic listing missing app MCP tools. -- Dogfood mode can still warn instead of blocking if current policy already allows degraded evidence there. -- No schema validation is added for all operational tools in this step. That would be a separate hardening pass. +- `message_send`, `member_briefing`, `task_start`, and `cross_team_send` are covered by readiness tests. +- Missing app MCP tools fails readiness directly with a clear diagnostic. +- No project-specific artifact is required to create or launch a team. ### Step 13 - Resolve secondary lane current-run evidence from lane manifest @@ -2268,10 +2204,7 @@ currentRunId: this.getCurrentOpenCodeRuntimeRunId(input.teamName, input.laneId), Change to async durable resolution: ```ts -const currentRunId = await this.resolveDurableOpenCodeRuntimeRunId( - input.teamName, - input.laneId -); +const currentRunId = await this.resolveDurableOpenCodeRuntimeRunId(input.teamName, input.laneId); await store.assertEvidenceAccepted({ teamName: input.teamName, @@ -2384,7 +2317,7 @@ emit: (event) => { teamName: event.teamName, detail: typeof event.data?.detail === 'string' ? event.data.detail : undefined, }); -} +}; ``` Reason: @@ -2421,62 +2354,62 @@ Do not add a frontend fake "agent answered" path. Frontend may show "message sav These are the places most likely to produce regressions if implemented casually. -1. Canonical OpenCode MCP id spelling - 🎯 8 🛡️ 8 🧠 3, about 20-50 LOC in tests -`buildOpenCodeCanonicalMcpToolId('agent-teams', 'message_send')` keeps the dash in `agent-teams_message_send`. Direct MCP stdio proof uses plain `message_send`. Transcript parsing accepts aliases. Production E2E evidence should use the canonical dash form only. Add tests for all three contexts so nobody normalizes everything to underscore by accident. +1. Canonical OpenCode MCP id spelling - 🎯 8 🛡️ 8 🧠 3, about 20-50 LOC in tests + `buildOpenCodeCanonicalMcpToolId('agent-teams', 'message_send')` keeps the dash in `agent-teams_message_send`. Direct MCP stdio proof uses plain `message_send`. Transcript parsing accepts aliases. Add tests for these contexts so nobody normalizes everything to underscore by accident. -2. Orchestrator explicit teammate tool list drift - 🎯 7 🛡️ 7 🧠 4, about 40-90 LOC in tests -The v1 orchestrator list is duplicated by design to avoid adding a dependency. This is acceptable only if tests cover the current controller teammate-operational catalog snapshot, including attachment/link/create/cross-team tools. If this fails repeatedly, move to Option C from Step 10: generated shared protocol contract. +2. Orchestrator explicit teammate tool list drift - 🎯 7 🛡️ 7 🧠 4, about 40-90 LOC in tests + The v1 orchestrator list is duplicated by design to avoid adding a dependency. This is acceptable only if tests cover the current controller teammate-operational catalog snapshot, including attachment/link/create/cross-team tools. If this fails repeatedly, move to Option C from Step 10: generated shared protocol contract. -3. Runtime provider inference - 🎯 7 🛡️ 8 🧠 4, about 60-120 LOC with tests -`runtimeProvider: "opencode"` is the most reliable signal and should be sent by orchestrator. Provider metadata inference is a fallback for controller-generated messages and manual briefing calls. Native fallback must remain default when neither explicit runtimeProvider nor OpenCode metadata is present. +3. Runtime provider inference - 🎯 7 🛡️ 8 🧠 4, about 60-120 LOC with tests + `runtimeProvider: "opencode"` is the most reliable signal and should be sent by orchestrator. Provider metadata inference is a fallback for controller-generated messages and manual briefing calls. Native fallback must remain default when neither explicit runtimeProvider nor OpenCode metadata is present. -4. Production evidence freshness - 🎯 8 🛡️ 9 🧠 3, about 30-80 LOC in tests -Old evidence that proves runtime tools only must fail production gate after this change. This is intentional. The diagnostic must explain which app MCP tools are missing so regeneration is obvious. +4. Production evidence freshness - 🎯 8 🛡️ 9 🧠 3, about 30-80 LOC in tests + Old evidence that proves runtime tools only must fail production gate after this change. This is intentional. The diagnostic must explain which app MCP tools are missing so regeneration is obvious. -5. Model compliance versus protocol availability - 🎯 6 🛡️ 8 🧠 5, about 80-180 LOC with event tests -The protocol can make the correct tools visible and instruct the model correctly, but the model may still answer in plain text. The reliable app truth should be: runtime check-in proves the lane is alive, `message_send` proves visible user/team response, and tool-only assistant events still count as `latestAssistantMessageId` for launch liveness. +5. Model compliance versus protocol availability - 🎯 6 🛡️ 8 🧠 5, about 80-180 LOC with event tests + The protocol can make the correct tools visible and instruct the model correctly, but the model may still answer in plain text. The reliable app truth should be: runtime check-in proves the lane is alive, `message_send` proves visible user/team response, and tool-only assistant events still count as `latestAssistantMessageId` for launch liveness. -6. OpenCode send-message command durability - 🎯 7 🛡️ 7 🧠 4, about 40-120 LOC if kept direct, 140-260 LOC if moved into the state-changing bridge -`OpenCodeReadinessBridge.sendOpenCodeTeamMessage()` currently executes `opencode.sendMessage` directly, while launch/reconcile/stop go through `OpenCodeStateChangingBridgeCommandService`. For this seam, do not expand scope unless needed: keep direct send, require adapter callers to pass `runId`, and use the runId only for identity reminder/recovery. Treat `promptAsync()` success as delivery acceptance; post-send reconcile failure is a warning, not a false delivery failure. If stale-send bugs continue, promote `opencode.sendMessage` into the state-changing command service as a separate reliability pass. +6. OpenCode send-message command durability - 🎯 7 🛡️ 7 🧠 4, about 40-120 LOC if kept direct, 140-260 LOC if moved into the state-changing bridge + `OpenCodeReadinessBridge.sendOpenCodeTeamMessage()` currently executes `opencode.sendMessage` directly, while launch/reconcile/stop go through `OpenCodeStateChangingBridgeCommandService`. For this seam, do not expand scope unless needed: keep direct send, require adapter callers to pass `runId`, and use the runId only for identity reminder/recovery. Treat `promptAsync()` success as delivery acceptance; post-send reconcile failure is a warning, not a false delivery failure. If stale-send bugs continue, promote `opencode.sendMessage` into the state-changing command service as a separate reliability pass. -7. Cross-team transport split - 🎯 8 🛡️ 9 🧠 4, about 50-120 LOC in prompt helper/tests -OpenCode needs two visible messaging transports: `message_send` for local user/lead/member messages, and `cross_team_send` for remote teams. Collapsing both into `message_send` would resurrect the exact bug existing prompts warn about: treating `cross_team_send` as a recipient. The helper should expose both phrases/examples, but implementation remains narrow because it only affects wording and tests. +7. Cross-team transport split - 🎯 8 🛡️ 9 🧠 4, about 50-120 LOC in prompt helper/tests + OpenCode needs two visible messaging transports: `message_send` for local user/lead/member messages, and `cross_team_send` for remote teams. Collapsing both into `message_send` would resurrect the exact bug existing prompts warn about: treating `cross_team_send` as a recipient. The helper should expose both phrases/examples, but implementation remains narrow because it only affects wording and tests. -8. Direct-proof output shape - 🎯 8 🛡️ 9 🧠 4, about 40-100 LOC in orchestrator/tests -The orchestrator direct MCP proof must match plain names from `Client.listTools()`, but `readiness.evidence.observedMcpTools` should remain canonical ids because production evidence consumes it. This is a boundary-shape risk, not a model behavior risk. Add tests that plain direct names pass matching while public bridge evidence contains `agent-teams_message_send`. +8. Direct-proof output shape - 🎯 8 🛡️ 9 🧠 4, about 40-100 LOC in orchestrator/tests + The orchestrator direct MCP proof must match plain names from `Client.listTools()`, but `readiness.evidence.observedMcpTools` should remain canonical ids because production evidence consumes it. This is a boundary-shape risk, not a model behavior risk. Add tests that plain direct names pass matching while public bridge evidence contains `agent-teams_message_send`. -8.1 Plain tool-name false positives - 🎯 8 🛡️ 8 🧠 3, about 25-70 LOC with tests +8.1 Plain tool-name false positives - 🎯 8 🛡️ 8 🧠 3, about 25-70 LOC with tests Alias parsing must accept plain `message_send` for OpenCode direct MCP proof/capture, but a plain name alone is not enough in arbitrary transcripts. For capture/log paths, require Agent Teams payload shape and current team match before treating a plain short name as our tool. Canonical/prefixed names remain accepted directly. -9. Durable run id consumption - 🎯 9 🛡️ 9 🧠 5, about 90-180 LOC with tests -`activeRunId` already lives in the lane-scoped `RuntimeStoreManifest`; `lanes.json` only proves lane state. Bootstrap evidence acceptance, runtime delivery journaling, and message delivery must read the manifest when in-memory run maps are empty after app restart. Without this, OpenCode lanes can be active in `lanes.json` but still reject check-in or send messages without identity recovery. Do not add a duplicate run id field to `lanes.json` in v1. +9. Durable run id consumption - 🎯 9 🛡️ 9 🧠 5, about 90-180 LOC with tests + `activeRunId` already lives in the lane-scoped `RuntimeStoreManifest`; `lanes.json` only proves lane state. Bootstrap evidence acceptance, runtime delivery journaling, and message delivery must read the manifest when in-memory run maps are empty after app restart. Without this, OpenCode lanes can be active in `lanes.json` but still reject check-in or send messages without identity recovery. Do not add a duplicate run id field to `lanes.json` in v1. -10. Cross-team taskRefs mismatch - 🎯 7 🛡️ 8 🧠 4, about 0-25 LOC if forbidden in v1, 70-150 LOC if wired end-to-end -Shared types already include `taskRefs` for cross-team messages, but `cross_team_send` schema/controller do not persist them. The semantic helper must not generate unsupported fields. Either explicitly forbid cross-team taskRefs in v1 helper examples or wire schema/storage/tests now. +10. Cross-team taskRefs mismatch - 🎯 7 🛡️ 8 🧠 4, about 0-25 LOC if forbidden in v1, 70-150 LOC if wired end-to-end + Shared types already include `taskRefs` for cross-team messages, but `cross_team_send` schema/controller do not persist them. The semantic helper must not generate unsupported fields. Either explicitly forbid cross-team taskRefs in v1 helper examples or wire schema/storage/tests now. -11. OpenCode direct-message metadata - 🎯 9 🛡️ 9 🧠 5, about 120-220 LOC with tests -OpenCode runtime delivery should not parse native `SendMessage` prompt text to discover the reply recipient. Pass `replyRecipient`, `actionMode`, and `taskRefs` as explicit adapter metadata, and build a separate OpenCode-native delivery prompt. This lowers model confusion and removes regex coupling to `buildMessageDeliveryText()`. +11. OpenCode direct-message metadata - 🎯 9 🛡️ 9 🧠 5, about 120-220 LOC with tests + OpenCode runtime delivery should not parse native `SendMessage` prompt text to discover the reply recipient. Pass `replyRecipient`, `actionMode`, and `taskRefs` as explicit adapter metadata, and build a separate OpenCode-native delivery prompt. This lowers model confusion and removes regex coupling to `buildMessageDeliveryText()`. -12. Runtime delivery event adapter shape - 🎯 9 🛡️ 9 🧠 3, about 20-60 LOC in tests -`RuntimeDeliveryService` uses a local `data.detail` envelope, but the app uses `TeamChangeEvent.detail`. The existing adapter maps it correctly. Test this so a future refactor does not bypass the adapter and make OpenCode replies visible in some UI paths while relay/notification/detail-sensitive paths silently miss the change. +12. Runtime delivery event adapter shape - 🎯 9 🛡️ 9 🧠 3, about 20-60 LOC in tests + `RuntimeDeliveryService` uses a local `data.detail` envelope, but the app uses `TeamChangeEvent.detail`. The existing adapter maps it correctly. Test this so a future refactor does not bypass the adapter and make OpenCode replies visible in some UI paths while relay/notification/detail-sensitive paths silently miss the change. -13. User-directed `message_send` sender identity - 🎯 9 🛡️ 9 🧠 3, about 35-90 LOC with tests -The protocol cannot rely only on prompt text saying "include from". If `from` is absent for `to: "user"`, the controller currently creates a durable user-to-user row. Add a narrow guard that rejects missing/invalid sender only for user-directed MCP messages, while keeping legacy user-to-member `message_send` defaults intact. +13. User-directed `message_send` sender identity - 🎯 9 🛡️ 9 🧠 3, about 35-90 LOC with tests + The protocol cannot rely only on prompt text saying "include from". If `from` is absent for `to: "user"`, the controller currently creates a durable user-to-user row. Add a narrow guard that rejects missing/invalid sender only for user-directed MCP messages, while keeping legacy user-to-member `message_send` defaults intact. -14. OpenCode direct-message delivery acknowledgement - 🎯 9 🛡️ 9 🧠 5, about 130-260 LOC with tests -The send-message IPC path currently treats inbox persistence as success and starts OpenCode runtime delivery asynchronously. That is okay for native teammates because they watch/read inbox files, but not for OpenCode lanes. Add an additive runtime delivery result and fix the renderer store action to return/rethrow, so UI can distinguish "message saved" from "OpenCode runtime actually received the prompt" and pending replies do not hang on hidden failures. +14. OpenCode direct-message delivery acknowledgement - 🎯 9 🛡️ 9 🧠 5, about 130-260 LOC with tests + The send-message IPC path currently treats inbox persistence as success and starts OpenCode runtime delivery asynchronously. That is okay for native teammates because they watch/read inbox files, but not for OpenCode lanes. Add an additive runtime delivery result and fix the renderer store action to return/rethrow, so UI can distinguish "message saved" from "OpenCode runtime actually received the prompt" and pending replies do not hang on hidden failures. -15. Runtime delivery tool ambiguity - 🎯 8 🛡️ 8 🧠 3, about 25-70 LOC with tests -`runtime_deliver_message` can write real destinations, so it is not safe to rely on the name alone and hope the model chooses `message_send`. V1 should keep it available for runtime evidence but make descriptions/prompts explicit that normal visible replies use `message_send`, while runtime delivery is a low-level idempotent path only when explicitly requested. +15. Runtime delivery tool ambiguity - 🎯 8 🛡️ 8 🧠 3, about 25-70 LOC with tests + `runtime_deliver_message` can write real destinations, so it is not safe to rely on the name alone and hope the model chooses `message_send`. V1 should keep it available for runtime evidence but make descriptions/prompts explicit that normal visible replies use `message_send`, while runtime delivery is a low-level idempotent path only when explicitly requested. -16. OpenCode-targeted inbox relay - 🎯 9 🛡️ 9 🧠 6, about 240-440 LOC with tests -The app currently has special relay for native lead inboxes and native teammate file-watch behavior, but OpenCode teammates do not watch `inboxes/.json`. Any plan that only fixes UI direct-send still leaves OpenCode-to-OpenCode and system notification routes unreliable. Add recipient-provider-aware runtime relay with at-least-once semantics, read-flag commit, duplicate-event dedupe, and explicit unsupported OpenCode lead diagnostics. Do not reuse native `relayMemberInboxMessages()`. +16. OpenCode-targeted inbox relay - 🎯 9 🛡️ 9 🧠 6, about 240-440 LOC with tests + The app currently has special relay for native lead inboxes and native teammate file-watch behavior, but OpenCode teammates do not watch `inboxes/.json`. Any plan that only fixes UI direct-send still leaves OpenCode-to-OpenCode and system notification routes unreliable. Add recipient-provider-aware runtime relay with at-least-once semantics, read-flag commit, duplicate-event dedupe, and explicit unsupported OpenCode lead diagnostics. Do not reuse native `relayMemberInboxMessages()`. -17. `message_send` recipient canonicalization - 🎯 9 🛡️ 9 🧠 4, about 70-150 LOC with tests -Raw recipient names currently become inbox filenames. This is fragile because prompts and tests use lead aliases like `team-lead` while teams can have a custom lead name. Resolve `to` and `from` against configured members before persistence, with `user` as the only special local destination and cross-team tool names rejected clearly. +17. `message_send` recipient canonicalization - 🎯 9 🛡️ 9 🧠 4, about 70-150 LOC with tests + Raw recipient names currently become inbox filenames. This is fragile because prompts and tests use lead aliases like `team-lead` while teams can have a custom lead name. Resolve `to` and `from` against configured members before persistence, with `user` as the only special local destination and cross-team tool names rejected clearly. -18. OpenCode lead runtime session gap - 🎯 8 🛡️ 9 🧠 5, about 60-140 LOC for v1 diagnostics, 300-700 LOC if adding a real lead lane -The app-side OpenCode adapter passes `leadPrompt`, but the orchestrator launch handler currently creates sessions from `body.members` only. `relayLeadInboxMessages()` also requires native `run.child`. V1 must not pretend pure OpenCode lead inbox delivery works. Either route to an existing stored `team-lead` OpenCode session if one is later introduced, or leave rows unread with an explicit diagnostic. Creating a real OpenCode lead lane is a separate feature, not a hidden side effect of this messaging seam. +18. OpenCode lead runtime session gap - 🎯 8 🛡️ 9 🧠 5, about 60-140 LOC for v1 diagnostics, 300-700 LOC if adding a real lead lane + The app-side OpenCode adapter passes `leadPrompt`, but the orchestrator launch handler currently creates sessions from `body.members` only. `relayLeadInboxMessages()` also requires native `run.child`. V1 must not pretend pure OpenCode lead inbox delivery works. Either route to an existing stored `team-lead` OpenCode session if one is later introduced, or leave rows unread with an explicit diagnostic. Creating a real OpenCode lead lane is a separate feature, not a hidden side effect of this messaging seam. ## Tests @@ -2547,7 +2480,9 @@ it('persists taskRefs through message_send', async () => { taskRefs: [{ teamName, taskId: 'task-1', displayId: 'abcd1234' }], }); - const rows = JSON.parse(fs.readFileSync(path.join(claudeDir, 'teams', teamName, 'inboxes', 'user.json'), 'utf8')); + const rows = JSON.parse( + fs.readFileSync(path.join(claudeDir, 'teams', teamName, 'inboxes', 'user.json'), 'utf8') + ); expect(rows[0].taskRefs).toEqual([{ teamName, taskId: 'task-1', displayId: 'abcd1234' }]); }); ``` @@ -2574,7 +2509,9 @@ it('keeps legacy user-to-member message_send valid without from', async () => { text: 'Please check this', }); - const rows = JSON.parse(fs.readFileSync(path.join(claudeDir, 'teams', teamName, 'inboxes', 'alice.json'), 'utf8')); + const rows = JSON.parse( + fs.readFileSync(path.join(claudeDir, 'teams', teamName, 'inboxes', 'alice.json'), 'utf8') + ); expect(rows.at(-1)).toMatchObject({ from: 'user', to: 'alice', @@ -2609,7 +2546,9 @@ it('canonicalizes message_send lead aliases before writing inbox files', async ( }); expect(fs.existsSync(path.join(claudeDir, 'teams', teamName, 'inboxes', 'lead.json'))).toBe(true); - expect(fs.existsSync(path.join(claudeDir, 'teams', teamName, 'inboxes', 'team-lead.json'))).toBe(false); + expect(fs.existsSync(path.join(claudeDir, 'teams', teamName, 'inboxes', 'team-lead.json'))).toBe( + false + ); }); ``` @@ -2624,7 +2563,9 @@ it('canonicalizes message_send sender aliases before persistence', async () => { text: 'Please review', }); - const rows = JSON.parse(fs.readFileSync(path.join(claudeDir, 'teams', teamName, 'inboxes', 'alice.json'), 'utf8')); + const rows = JSON.parse( + fs.readFileSync(path.join(claudeDir, 'teams', teamName, 'inboxes', 'alice.json'), 'utf8') + ); expect(rows.at(-1)).toMatchObject({ from: 'lead', to: 'alice' }); }); ``` @@ -2682,7 +2623,9 @@ it('keeps configured dotted local members valid before applying cross-team heuri text: 'Local dotted member', }); - expect(fs.existsSync(path.join(claudeDir, 'teams', teamName, 'inboxes', 'qa.bot.json'))).toBe(true); + expect(fs.existsSync(path.join(claudeDir, 'teams', teamName, 'inboxes', 'qa.bot.json'))).toBe( + true + ); }); ``` @@ -2717,11 +2660,18 @@ it('persists taskRefs through cross_team_send when enabled', async () => { }); const targetInbox = JSON.parse( - fs.readFileSync(path.join(claudeDir, 'teams', 'review-team', 'inboxes', 'team-lead.json'), 'utf8') + fs.readFileSync( + path.join(claudeDir, 'teams', 'review-team', 'inboxes', 'team-lead.json'), + 'utf8' + ) ); - expect(targetInbox.at(-1).taskRefs).toEqual([{ teamName, taskId: 'task-1', displayId: 'abcd1234' }]); + expect(targetInbox.at(-1).taskRefs).toEqual([ + { teamName, taskId: 'task-1', displayId: 'abcd1234' }, + ]); - const outbox = JSON.parse(fs.readFileSync(path.join(claudeDir, 'teams', teamName, 'sent-cross-team.json'), 'utf8')); + const outbox = JSON.parse( + fs.readFileSync(path.join(claudeDir, 'teams', teamName, 'sent-cross-team.json'), 'utf8') + ); expect(outbox.at(-1).taskRefs).toEqual([{ teamName, taskId: 'task-1', displayId: 'abcd1234' }]); }); ``` @@ -2812,70 +2762,19 @@ it('does not classify unrelated plain message_send tool_use without Agent Teams }); ``` -Add app-side OpenCode readiness/evidence tests: +Add app-side OpenCode readiness tests: ```ts -it('uses full app tool ids for OpenCode production E2E gate expectations', async () => { - const evidence = buildEvidence({ - observedTools: REQUIRED_AGENT_TEAMS_APP_TOOL_IDS, - }); - +it('uses full app tool ids for OpenCode readiness expectations', async () => { const result = await bridge.runReadiness({ - launchMode: 'production', selectedModel: 'minimax-m2.5-free', // ... }); expect(result.supportLevel).toBe('production_supported'); - expect(productionE2eEvidence.read).toHaveBeenCalled(); -}); -``` - -```ts -it('rejects stale runtime-only OpenCode production evidence', async () => { - const runtimeOnlyToolIds = REQUIRED_AGENT_TEAMS_RUNTIME_PROOF_TOOLS.map((tool) => - buildOpenCodeCanonicalMcpToolId('agent-teams', tool) + expect(result.evidence.observedMcpTools).toEqual( + expect.arrayContaining(REQUIRED_AGENT_TEAMS_APP_TOOL_IDS) ); - const evidence = buildEvidence({ - observedTools: runtimeOnlyToolIds, - requiredTools: runtimeOnlyToolIds, - }); - - const gate = assertOpenCodeProductionE2EArtifactGate({ - evidence, - artifactPath: '/tmp/opencode-e2e.json', - expected: { - requiredMcpTools: REQUIRED_AGENT_TEAMS_APP_TOOL_IDS, - }, - }); - - expect(gate.ok).toBe(false); - expect(gate.diagnostics.join('\n')).toContain('agent-teams_message_send'); - expect(gate.diagnostics.join('\n')).toContain('agent-teams_member_briefing'); -}); -``` - -```ts -it('live production evidence builder writes full app tool ids', () => { - const evidence = buildCandidateEvidence({ - readinessObservedTools: REQUIRED_AGENT_TEAMS_APP_TOOL_IDS, - // other required live-test fields - }); - - expect(evidence.mcpTools.requiredTools).toEqual(REQUIRED_AGENT_TEAMS_APP_TOOL_IDS); - expect(evidence.mcpTools.observedTools).toContain('agent-teams_message_send'); - expect(evidence.mcpTools.observedTools).toContain('agent-teams_cross_team_send'); -}); -``` - -```ts -it('live production evidence builder rejects plain direct tool names for artifact output', () => { - expect(() => - buildCandidateEvidence({ - readinessObservedTools: ['message_send', 'member_briefing'], - // other required live-test fields - }) - ).toThrow(/agent-teams_message_send/); }); ``` @@ -3437,7 +3336,7 @@ Run targeted tests first: cd /Users/belief/dev/projects/claude/claude_team pnpm --filter agent-teams-controller test -- test/controller.test.js pnpm --filter agent-teams-mcp test -- test/tools.test.ts -pnpm vitest run test/main/services/team/OpenCodeTeamRuntimeAdapter.test.ts test/main/services/team/TeamProvisioningService.test.ts test/main/services/team/TeamProvisioningServiceRelay.test.ts test/main/services/team/TeamProvisioningServiceLiveMessages.test.ts test/main/ipc/teams.test.ts test/main/services/team/OpenCodeMcpToolAvailability.test.ts test/main/services/team/OpenCodeReadinessBridge.test.ts test/main/services/team/OpenCodeProductionE2EEvidence.test.ts test/renderer/store/teamChangeThrottle.test.ts test/renderer/store/teamSlice.test.ts test/renderer/components/team/messages/MessagesPanel.test.ts test/renderer/components/team/dialogs/SendMessageDialog.test.tsx +pnpm vitest run test/main/services/team/OpenCodeTeamRuntimeAdapter.test.ts test/main/services/team/TeamProvisioningService.test.ts test/main/services/team/TeamProvisioningServiceRelay.test.ts test/main/services/team/TeamProvisioningServiceLiveMessages.test.ts test/main/ipc/teams.test.ts test/main/services/team/OpenCodeMcpToolAvailability.test.ts test/main/services/team/OpenCodeReadinessBridge.test.ts test/renderer/store/teamChangeThrottle.test.ts test/renderer/store/teamSlice.test.ts test/renderer/components/team/messages/MessagesPanel.test.ts test/renderer/components/team/dialogs/SendMessageDialog.test.tsx ``` ```bash @@ -3499,7 +3398,7 @@ Avoid heavy E2E until targeted tests pass. 19. Add OpenCode-targeted inbox runtime relay with dedupe/read marking. 20. Expand orchestrator direct MCP proof with the explicit plain-name adapter list while keeping public observed evidence as canonical OpenCode ids. 21. Expand app-side OpenCode MCP availability proof from controller catalog. -22. Update production E2E gate and evidence fixtures to require the full app tool id list. +22. Keep OpenCode readiness requiring the full app tool id list without project-scoped artifacts. 23. Add lane-scoped manifest `activeRunId` recovery and consume it in evidence acceptance/message delivery/runtime delivery service. 24. Add runtime delivery `TeamChangeEvent.detail` adapter guard tests. 25. Add tests. @@ -3534,9 +3433,7 @@ Avoid heavy E2E until targeted tests pass. - Readiness passes while `message_send` is missing. This means proof list is still incomplete. - Readiness passes while review/process/task-set tools are missing. This means proof only checked a small subset instead of all teammate-operational briefing tools. - Direct MCP readiness fails even though `tools/list` contains `message_send`. This usually means direct stdio proof is incorrectly comparing plain names against OpenCode canonical ids. -- Production live evidence fails after direct MCP proof succeeds. This usually means the orchestrator started exposing plain names in `readiness.evidence.observedMcpTools`; keep plain names internal and expose canonical `agent-teams_*` ids there. -- Production mode passes with runtime-only evidence. This means `OpenCodeReadinessBridge` still uses `REQUIRED_AGENT_TEAMS_RUNTIME_TOOLS` instead of the full app tool id list. -- Production mode blocks with stale evidence after this change. That is expected until the OpenCode production E2E artifact is regenerated, but the diagnostic must list the missing app tools clearly. +- Readiness passes with runtime-only app tool coverage. This means `OpenCodeMcpToolAvailability` still uses only runtime tools instead of the full app tool id list. - App-side and orchestrator required tool lists drift. For v1, this is controlled by tests and explicit comments. If drift keeps recurring, move to a generated shared contract artifact. - OpenCode member stays `created` even though the prompt was accepted. This usually means `promptAsync()` was reconciled too early; use the bounded launch-settle helper before final launch mapping. - Preview observation times out and marks a teammate failed. That is wrong. Preview timeout should only fall back to reconcile and keep the member pending. @@ -3557,7 +3454,6 @@ Avoid heavy E2E until targeted tests pass. - OpenCode launch, briefing, assignment, completion, and clarification instructions consistently use `agent-teams_message_send`. - OpenCode cross-team instructions consistently use `agent-teams_cross_team_send`, not `message_send`. - OpenCode readiness fails if required app MCP tools are absent. -- OpenCode production E2E gate proves the same app MCP tools that readiness requires. - Orchestrator direct proof matches plain MCP names internally and emits canonical OpenCode ids in readiness evidence. - Runtime tool descriptions make `message_send` the normal visible reply API and keep `runtime_deliver_message` scoped to explicit low-level runtime delivery flows. - OpenCode can prove liveness through `runtime_bootstrap_checkin`. diff --git a/mcp-server/src/tools/crossTeamTools.ts b/mcp-server/src/tools/crossTeamTools.ts index 95d55d9b..e68ef5e5 100644 --- a/mcp-server/src/tools/crossTeamTools.ts +++ b/mcp-server/src/tools/crossTeamTools.ts @@ -3,6 +3,7 @@ import { z } from 'zod'; import { getController } from '../controller'; import { jsonTextContent } from '../utils/format'; +import { assertConfiguredTeam } from '../utils/teamConfig'; const toolContextSchema = { teamName: z.string().min(1), @@ -42,8 +43,9 @@ export function registerCrossTeamTools(server: Pick) { replyToConversationId, taskRefs, chainDepth, - }) => - await Promise.resolve( + }) => { + assertConfiguredTeam(teamName, claudeDir); + return await Promise.resolve( jsonTextContent( getController(teamName, claudeDir).crossTeam.sendCrossTeamMessage({ toTeam, @@ -56,7 +58,8 @@ export function registerCrossTeamTools(server: Pick) { ...(chainDepth !== undefined ? { chainDepth } : {}), }) ) - ), + ); + }, }); server.addTool({ @@ -66,14 +69,16 @@ export function registerCrossTeamTools(server: Pick) { ...toolContextSchema, excludeTeam: z.string().optional(), }), - execute: async ({ teamName, claudeDir, excludeTeam }) => - await Promise.resolve( + execute: async ({ teamName, claudeDir, excludeTeam }) => { + assertConfiguredTeam(teamName, claudeDir); + return await Promise.resolve( jsonTextContent( getController(teamName, claudeDir).crossTeam.listCrossTeamTargets({ ...(excludeTeam ? { excludeTeam } : {}), }) ) - ), + ); + }, }); server.addTool({ @@ -82,9 +87,11 @@ export function registerCrossTeamTools(server: Pick) { parameters: z.object({ ...toolContextSchema, }), - execute: async ({ teamName, claudeDir }) => - await Promise.resolve( + execute: async ({ teamName, claudeDir }) => { + assertConfiguredTeam(teamName, claudeDir); + return await Promise.resolve( jsonTextContent(getController(teamName, claudeDir).crossTeam.getCrossTeamOutbox()) - ), + ); + }, }); } diff --git a/mcp-server/src/tools/kanbanTools.ts b/mcp-server/src/tools/kanbanTools.ts index c9b53d4d..4d4fa851 100644 --- a/mcp-server/src/tools/kanbanTools.ts +++ b/mcp-server/src/tools/kanbanTools.ts @@ -3,6 +3,7 @@ import { z } from 'zod'; import { getController } from '../controller'; import { jsonTextContent } from '../utils/format'; +import { assertConfiguredTeam } from '../utils/teamConfig'; const toolContextSchema = { teamName: z.string().min(1), @@ -16,8 +17,10 @@ export function registerKanbanTools(server: Pick) { parameters: z.object({ ...toolContextSchema, }), - execute: async ({ teamName, claudeDir }) => - await Promise.resolve(jsonTextContent(getController(teamName, claudeDir).kanban.getKanbanState())), + execute: async ({ teamName, claudeDir }) => { + assertConfiguredTeam(teamName, claudeDir); + return await Promise.resolve(jsonTextContent(getController(teamName, claudeDir).kanban.getKanbanState())); + }, }); server.addTool({ @@ -29,10 +32,12 @@ export function registerKanbanTools(server: Pick) { taskId: z.string().min(1), column: z.enum(['review', 'approved']), }), - execute: async ({ teamName, claudeDir, taskId, column }) => - await Promise.resolve( + execute: async ({ teamName, claudeDir, taskId, column }) => { + assertConfiguredTeam(teamName, claudeDir); + return await Promise.resolve( jsonTextContent(getController(teamName, claudeDir).kanban.setKanbanColumn(taskId, column)) - ), + ); + }, }); server.addTool({ @@ -43,8 +48,10 @@ export function registerKanbanTools(server: Pick) { ...toolContextSchema, taskId: z.string().min(1), }), - execute: async ({ teamName, claudeDir, taskId }) => - await Promise.resolve(jsonTextContent(getController(teamName, claudeDir).kanban.clearKanban(taskId))), + execute: async ({ teamName, claudeDir, taskId }) => { + assertConfiguredTeam(teamName, claudeDir); + return await Promise.resolve(jsonTextContent(getController(teamName, claudeDir).kanban.clearKanban(taskId))); + }, }); server.addTool({ @@ -53,8 +60,10 @@ export function registerKanbanTools(server: Pick) { parameters: z.object({ ...toolContextSchema, }), - execute: async ({ teamName, claudeDir }) => - await Promise.resolve(jsonTextContent(getController(teamName, claudeDir).kanban.listReviewers())), + execute: async ({ teamName, claudeDir }) => { + assertConfiguredTeam(teamName, claudeDir); + return await Promise.resolve(jsonTextContent(getController(teamName, claudeDir).kanban.listReviewers())); + }, }); server.addTool({ @@ -64,8 +73,10 @@ export function registerKanbanTools(server: Pick) { ...toolContextSchema, reviewer: z.string().min(1), }), - execute: async ({ teamName, claudeDir, reviewer }) => - await Promise.resolve(jsonTextContent(getController(teamName, claudeDir).kanban.addReviewer(reviewer))), + execute: async ({ teamName, claudeDir, reviewer }) => { + assertConfiguredTeam(teamName, claudeDir); + return await Promise.resolve(jsonTextContent(getController(teamName, claudeDir).kanban.addReviewer(reviewer))); + }, }); server.addTool({ @@ -75,9 +86,11 @@ export function registerKanbanTools(server: Pick) { ...toolContextSchema, reviewer: z.string().min(1), }), - execute: async ({ teamName, claudeDir, reviewer }) => - await Promise.resolve( + execute: async ({ teamName, claudeDir, reviewer }) => { + assertConfiguredTeam(teamName, claudeDir); + return await Promise.resolve( jsonTextContent(getController(teamName, claudeDir).kanban.removeReviewer(reviewer)) - ), + ); + }, }); } diff --git a/mcp-server/src/tools/leadTools.ts b/mcp-server/src/tools/leadTools.ts index ed986169..ae8c8add 100644 --- a/mcp-server/src/tools/leadTools.ts +++ b/mcp-server/src/tools/leadTools.ts @@ -2,6 +2,7 @@ import type { FastMCP } from 'fastmcp'; import { z } from 'zod'; import { getController } from '../controller'; +import { assertConfiguredTeam } from '../utils/teamConfig'; const toolContextSchema = { teamName: z.string().min(1), @@ -20,13 +21,16 @@ export function registerLeadTools(server: Pick) { parameters: z.object({ ...toolContextSchema, }), - execute: async ({ teamName, claudeDir }) => ({ - content: [ - { - type: 'text' as const, - text: await getController(teamName, claudeDir).tasks.leadBriefing(), - }, - ], - }), + execute: async ({ teamName, claudeDir }) => { + assertConfiguredTeam(teamName, claudeDir); + return { + content: [ + { + type: 'text' as const, + text: await getController(teamName, claudeDir).tasks.leadBriefing(), + }, + ], + }; + }, }); } diff --git a/mcp-server/src/tools/messageTools.ts b/mcp-server/src/tools/messageTools.ts index c08256dd..56e8f46b 100644 --- a/mcp-server/src/tools/messageTools.ts +++ b/mcp-server/src/tools/messageTools.ts @@ -2,6 +2,7 @@ import type { FastMCP } from 'fastmcp'; import { z } from 'zod'; import { getController } from '../controller'; +import { assertConfiguredTeam } from '../utils/teamConfig'; import { jsonTextContent } from '../utils/format'; const toolContextSchema = { @@ -53,8 +54,9 @@ export function registerMessageTools(server: Pick) { leadSessionId, attachments, taskRefs, - }) => - await Promise.resolve( + }) => { + assertConfiguredTeam(teamName, claudeDir); + return await Promise.resolve( jsonTextContent( getController(teamName, claudeDir).messages.sendMessage({ to, @@ -67,6 +69,7 @@ export function registerMessageTools(server: Pick) { ...(taskRefs?.length ? { taskRefs } : {}), }) ) - ), + ); + }, }); } diff --git a/mcp-server/src/tools/processTools.ts b/mcp-server/src/tools/processTools.ts index ac40b4e3..4038c52a 100644 --- a/mcp-server/src/tools/processTools.ts +++ b/mcp-server/src/tools/processTools.ts @@ -3,6 +3,7 @@ import { z } from 'zod'; import { getController } from '../controller'; import { jsonTextContent } from '../utils/format'; +import { assertConfiguredTeam } from '../utils/teamConfig'; const toolContextSchema = { teamName: z.string().min(1), @@ -34,8 +35,9 @@ export function registerProcessTools(server: Pick) { port, url, claudeProcessId, - }) => - await Promise.resolve( + }) => { + assertConfiguredTeam(teamName, claudeDir); + return await Promise.resolve( jsonTextContent( getController(teamName, claudeDir).processes.registerProcess({ pid, @@ -47,7 +49,8 @@ export function registerProcessTools(server: Pick) { ...(claudeProcessId ? { 'claude-process-id': claudeProcessId } : {}), }) ) - ), + ); + }, }); server.addTool({ @@ -57,10 +60,12 @@ export function registerProcessTools(server: Pick) { parameters: z.object({ ...toolContextSchema, }), - execute: async ({ teamName, claudeDir }) => - await Promise.resolve( + execute: async ({ teamName, claudeDir }) => { + assertConfiguredTeam(teamName, claudeDir); + return await Promise.resolve( jsonTextContent(getController(teamName, claudeDir).processes.listProcesses()) - ), + ); + }, }); server.addTool({ @@ -71,10 +76,12 @@ export function registerProcessTools(server: Pick) { ...toolContextSchema, pid: z.number().int().positive(), }), - execute: async ({ teamName, claudeDir, pid }) => - await Promise.resolve( + execute: async ({ teamName, claudeDir, pid }) => { + assertConfiguredTeam(teamName, claudeDir); + return await Promise.resolve( jsonTextContent(getController(teamName, claudeDir).processes.unregisterProcess({ pid })) - ), + ); + }, }); server.addTool({ @@ -85,9 +92,11 @@ export function registerProcessTools(server: Pick) { ...toolContextSchema, pid: z.number().int().positive(), }), - execute: async ({ teamName, claudeDir, pid }) => - await Promise.resolve( + execute: async ({ teamName, claudeDir, pid }) => { + assertConfiguredTeam(teamName, claudeDir); + return await Promise.resolve( jsonTextContent(getController(teamName, claudeDir).processes.stopProcess({ pid })) - ), + ); + }, }); } diff --git a/mcp-server/src/tools/reviewTools.ts b/mcp-server/src/tools/reviewTools.ts index 7c0eb2ac..9c9546b0 100644 --- a/mcp-server/src/tools/reviewTools.ts +++ b/mcp-server/src/tools/reviewTools.ts @@ -3,6 +3,7 @@ import { z } from 'zod'; import { getController } from '../controller'; import { jsonTextContent, slimTask } from '../utils/format'; +import { assertConfiguredTeam } from '../utils/teamConfig'; const toolContextSchema = { teamName: z.string().min(1), @@ -20,8 +21,9 @@ export function registerReviewTools(server: Pick) { reviewer: z.string().optional(), leadSessionId: z.string().optional(), }), - execute: async ({ teamName, claudeDir, taskId, from, reviewer, leadSessionId }) => - await Promise.resolve( + execute: async ({ teamName, claudeDir, taskId, from, reviewer, leadSessionId }) => { + assertConfiguredTeam(teamName, claudeDir); + return await Promise.resolve( jsonTextContent( slimTask( getController(teamName, claudeDir).review.requestReview(taskId, { @@ -31,7 +33,8 @@ export function registerReviewTools(server: Pick) { }) as Record ) ) - ), + ); + }, }); server.addTool({ @@ -42,14 +45,16 @@ export function registerReviewTools(server: Pick) { taskId: z.string().min(1), from: z.string().optional(), }), - execute: async ({ teamName, claudeDir, taskId, from }) => - await Promise.resolve( + execute: async ({ teamName, claudeDir, taskId, from }) => { + assertConfiguredTeam(teamName, claudeDir); + return await Promise.resolve( jsonTextContent( getController(teamName, claudeDir).review.startReview(taskId, { ...(from ? { from } : {}), }) as Record ) - ), + ); + }, }); server.addTool({ @@ -63,8 +68,9 @@ export function registerReviewTools(server: Pick) { notifyOwner: z.boolean().optional(), leadSessionId: z.string().optional(), }), - execute: async ({ teamName, claudeDir, taskId, from, note, notifyOwner, leadSessionId }) => - await Promise.resolve( + execute: async ({ teamName, claudeDir, taskId, from, note, notifyOwner, leadSessionId }) => { + assertConfiguredTeam(teamName, claudeDir); + return await Promise.resolve( jsonTextContent( slimTask( getController(teamName, claudeDir).review.approveReview(taskId, { @@ -75,7 +81,8 @@ export function registerReviewTools(server: Pick) { }) as Record ) ) - ), + ); + }, }); server.addTool({ @@ -88,8 +95,9 @@ export function registerReviewTools(server: Pick) { comment: z.string().optional(), leadSessionId: z.string().optional(), }), - execute: async ({ teamName, claudeDir, taskId, from, comment, leadSessionId }) => - await Promise.resolve( + execute: async ({ teamName, claudeDir, taskId, from, comment, leadSessionId }) => { + assertConfiguredTeam(teamName, claudeDir); + return await Promise.resolve( jsonTextContent( slimTask( getController(teamName, claudeDir).review.requestChanges(taskId, { @@ -99,6 +107,7 @@ export function registerReviewTools(server: Pick) { }) as Record ) ) - ), + ); + }, }); } diff --git a/mcp-server/src/tools/runtimeTools.ts b/mcp-server/src/tools/runtimeTools.ts index 8e69f795..2f66baa1 100644 --- a/mcp-server/src/tools/runtimeTools.ts +++ b/mcp-server/src/tools/runtimeTools.ts @@ -3,6 +3,7 @@ import { z } from 'zod'; import { getController } from '../controller'; import { jsonTextContent } from '../utils/format'; +import { assertConfiguredTeam } from '../utils/teamConfig'; const toolContextSchema = { teamName: z.string().min(1), @@ -57,8 +58,9 @@ export function registerRuntimeTools(server: Pick) { worktree, extraCliArgs, waitForReady, - }) => - jsonTextContent( + }) => { + assertConfiguredTeam(teamName, claudeDir); + return jsonTextContent( await getController(teamName, claudeDir).runtime.launchTeam({ cwd, ...(prompt ? { prompt } : {}), @@ -72,7 +74,8 @@ export function registerRuntimeTools(server: Pick) { ...(waitTimeoutMs ? { waitTimeoutMs } : {}), ...(waitForReady !== undefined ? { waitForReady } : {}), }) - ), + ); + }, }); server.addTool({ @@ -82,14 +85,16 @@ export function registerRuntimeTools(server: Pick) { ...toolContextSchema, waitForStop: z.boolean().optional(), }), - execute: async ({ teamName, claudeDir, controlUrl, waitTimeoutMs, waitForStop }) => - jsonTextContent( + execute: async ({ teamName, claudeDir, controlUrl, waitTimeoutMs, waitForStop }) => { + assertConfiguredTeam(teamName, claudeDir); + return jsonTextContent( await getController(teamName, claudeDir).runtime.stopTeam({ ...(controlUrl ? { controlUrl } : {}), ...(waitTimeoutMs ? { waitTimeoutMs } : {}), ...(waitForStop !== undefined ? { waitForStop } : {}), }) - ), + ); + }, }); server.addTool({ @@ -112,8 +117,9 @@ export function registerRuntimeTools(server: Pick) { observedAt, diagnostics, metadata, - }) => - jsonTextContent( + }) => { + assertConfiguredTeam(teamName, claudeDir); + return jsonTextContent( await getController(teamName, claudeDir).runtime.runtimeBootstrapCheckin({ runId, memberName, @@ -124,7 +130,8 @@ export function registerRuntimeTools(server: Pick) { ...(controlUrl ? { controlUrl } : {}), ...(waitTimeoutMs ? { waitTimeoutMs } : {}), }) - ), + ); + }, }); server.addTool({ @@ -157,8 +164,9 @@ export function registerRuntimeTools(server: Pick) { createdAt, summary, taskRefs, - }) => - jsonTextContent( + }) => { + assertConfiguredTeam(teamName, claudeDir); + return jsonTextContent( await getController(teamName, claudeDir).runtime.runtimeDeliverMessage({ idempotencyKey, runId, @@ -172,7 +180,8 @@ export function registerRuntimeTools(server: Pick) { ...(controlUrl ? { controlUrl } : {}), ...(waitTimeoutMs ? { waitTimeoutMs } : {}), }) - ), + ); + }, }); server.addTool({ @@ -204,8 +213,9 @@ export function registerRuntimeTools(server: Pick) { createdAt, summary, metadata, - }) => - jsonTextContent( + }) => { + assertConfiguredTeam(teamName, claudeDir); + return jsonTextContent( await getController(teamName, claudeDir).runtime.runtimeTaskEvent({ idempotencyKey, runId, @@ -219,7 +229,8 @@ export function registerRuntimeTools(server: Pick) { ...(controlUrl ? { controlUrl } : {}), ...(waitTimeoutMs ? { waitTimeoutMs } : {}), }) - ), + ); + }, }); server.addTool({ @@ -242,8 +253,9 @@ export function registerRuntimeTools(server: Pick) { observedAt, status, metadata, - }) => - jsonTextContent( + }) => { + assertConfiguredTeam(teamName, claudeDir); + return jsonTextContent( await getController(teamName, claudeDir).runtime.runtimeHeartbeat({ runId, memberName, @@ -254,6 +266,7 @@ export function registerRuntimeTools(server: Pick) { ...(controlUrl ? { controlUrl } : {}), ...(waitTimeoutMs ? { waitTimeoutMs } : {}), }) - ), + ); + }, }); } diff --git a/mcp-server/src/tools/taskTools.ts b/mcp-server/src/tools/taskTools.ts index 10793091..0785923d 100644 --- a/mcp-server/src/tools/taskTools.ts +++ b/mcp-server/src/tools/taskTools.ts @@ -1,9 +1,8 @@ import type { FastMCP } from 'fastmcp'; -import fs from 'node:fs'; -import path from 'node:path'; import { z } from 'zod'; import { agentBlocks, getController } from '../controller'; +import { assertConfiguredTeam } from '../utils/teamConfig'; import { jsonTextContent, taskWriteResult, slimTask } from '../utils/format'; /** stripAgentBlocks from canonical agentBlocks module — single source of truth for the tag format. */ @@ -70,42 +69,6 @@ function buildCreateTaskPayload(params: { }; } -function resolveConfigPath(teamName: string, claudeDir?: string): string { - const controller = getController(teamName, claudeDir) as { - context?: { paths?: { teamDir?: string } }; - }; - const teamDir = controller.context?.paths?.teamDir; - if (typeof teamDir !== 'string' || teamDir.trim().length === 0) { - throw new Error( - `Unknown team "${teamName}". Board tools require an existing configured team with config.json. Use the real board teamName from durable team context - never use a member or lead name as teamName.` - ); - } - return path.join(teamDir, 'config.json'); -} - -function assertConfiguredTeam(teamName: string, claudeDir?: string): void { - const configPath = resolveConfigPath(teamName, claudeDir); - let raw = ''; - try { - raw = fs.readFileSync(configPath, 'utf8'); - } catch { - throw new Error( - `Unknown team "${teamName}". Board tools require an existing configured team with config.json. Use the real board teamName from durable team context - never use a member or lead name as teamName.` - ); - } - - try { - const parsed = JSON.parse(raw) as { name?: unknown }; - if (typeof parsed?.name !== 'string' || parsed.name.trim().length === 0) { - throw new Error('invalid'); - } - } catch { - throw new Error( - `Unknown team "${teamName}". Board tools require an existing configured team with config.json. Use the real board teamName from durable team context - never use a member or lead name as teamName.` - ); - } -} - export function registerTaskTools(server: Pick) { server.addTool({ name: 'task_create', @@ -288,8 +251,12 @@ export function registerTaskTools(server: Pick) { ...toolContextSchema, taskId: z.string().min(1), }), - execute: async ({ teamName, claudeDir, taskId }) => - await Promise.resolve(jsonTextContent(getController(teamName, claudeDir).tasks.getTask(taskId))), + execute: async ({ teamName, claudeDir, taskId }) => { + assertConfiguredTeam(teamName, claudeDir); + return await Promise.resolve( + jsonTextContent(getController(teamName, claudeDir).tasks.getTask(taskId)) + ); + }, }); server.addTool({ @@ -301,12 +268,12 @@ export function registerTaskTools(server: Pick) { taskId: z.string().min(1), commentId: z.string().min(1), }), - execute: async ({ teamName, claudeDir, taskId, commentId }) => - await Promise.resolve( - jsonTextContent( - getController(teamName, claudeDir).tasks.getTaskComment(taskId, commentId) - ) - ), + execute: async ({ teamName, claudeDir, taskId, commentId }) => { + assertConfiguredTeam(teamName, claudeDir); + return await Promise.resolve( + jsonTextContent(getController(teamName, claudeDir).tasks.getTaskComment(taskId, commentId)) + ); + }, }); server.addTool({ @@ -333,8 +300,9 @@ export function registerTaskTools(server: Pick) { relatedTo, blockedBy, limit, - }) => - await Promise.resolve( + }) => { + assertConfiguredTeam(teamName, claudeDir); + return await Promise.resolve( jsonTextContent( getController(teamName, claudeDir).tasks.listTaskInventory({ ...(owner ? { owner } : {}), @@ -346,7 +314,8 @@ export function registerTaskTools(server: Pick) { limit: normalizeTaskListLimit(limit), }) ) - ), + ); + }, }); server.addTool({ @@ -358,10 +327,42 @@ export function registerTaskTools(server: Pick) { status: z.enum(['pending', 'in_progress', 'completed', 'deleted']), actor: z.string().optional(), }), - execute: async ({ teamName, claudeDir, taskId, status, actor }) => - await Promise.resolve( - jsonTextContent(slimTask(getController(teamName, claudeDir).tasks.setTaskStatus(taskId, status, actor) as Record)) - ), + execute: async ({ teamName, claudeDir, taskId, status, actor }) => { + assertConfiguredTeam(teamName, claudeDir); + return await Promise.resolve( + jsonTextContent( + slimTask( + getController(teamName, claudeDir).tasks.setTaskStatus(taskId, status, actor) as Record< + string, + unknown + > + ) + ) + ); + }, + }); + + server.addTool({ + name: 'task_restore', + description: 'Restore a deleted task back to pending work state', + parameters: z.object({ + ...toolContextSchema, + taskId: z.string().min(1), + actor: z.string().optional(), + }), + execute: async ({ teamName, claudeDir, taskId, actor }) => { + assertConfiguredTeam(teamName, claudeDir); + return await Promise.resolve( + jsonTextContent( + slimTask( + getController(teamName, claudeDir).tasks.restoreTask(taskId, actor) as Record< + string, + unknown + > + ) + ) + ); + }, }); server.addTool({ @@ -372,8 +373,19 @@ export function registerTaskTools(server: Pick) { taskId: z.string().min(1), actor: z.string().optional(), }), - execute: async ({ teamName, claudeDir, taskId, actor }) => - await Promise.resolve(jsonTextContent(slimTask(getController(teamName, claudeDir).tasks.startTask(taskId, actor) as Record))), + execute: async ({ teamName, claudeDir, taskId, actor }) => { + assertConfiguredTeam(teamName, claudeDir); + return await Promise.resolve( + jsonTextContent( + slimTask( + getController(teamName, claudeDir).tasks.startTask(taskId, actor) as Record< + string, + unknown + > + ) + ) + ); + }, }); server.addTool({ @@ -384,10 +396,19 @@ export function registerTaskTools(server: Pick) { taskId: z.string().min(1), actor: z.string().optional(), }), - execute: async ({ teamName, claudeDir, taskId, actor }) => - await Promise.resolve( - jsonTextContent(slimTask(getController(teamName, claudeDir).tasks.completeTask(taskId, actor) as Record)) - ), + execute: async ({ teamName, claudeDir, taskId, actor }) => { + assertConfiguredTeam(teamName, claudeDir); + return await Promise.resolve( + jsonTextContent( + slimTask( + getController(teamName, claudeDir).tasks.completeTask(taskId, actor) as Record< + string, + unknown + > + ) + ) + ); + }, }); server.addTool({ @@ -398,10 +419,19 @@ export function registerTaskTools(server: Pick) { taskId: z.string().min(1), owner: z.string().nullable(), }), - execute: async ({ teamName, claudeDir, taskId, owner }) => - await Promise.resolve( - jsonTextContent(slimTask(getController(teamName, claudeDir).tasks.setTaskOwner(taskId, owner) as Record)) - ), + execute: async ({ teamName, claudeDir, taskId, owner }) => { + assertConfiguredTeam(teamName, claudeDir); + return await Promise.resolve( + jsonTextContent( + slimTask( + getController(teamName, claudeDir).tasks.setTaskOwner(taskId, owner) as Record< + string, + unknown + > + ) + ) + ); + }, }); server.addTool({ @@ -413,17 +443,19 @@ export function registerTaskTools(server: Pick) { text: z.string().min(1), from: z.string().optional(), }), - execute: async ({ teamName, claudeDir, taskId, text, from }) => - await Promise.resolve( + execute: async ({ teamName, claudeDir, taskId, text, from }) => { + assertConfiguredTeam(teamName, claudeDir); + return await Promise.resolve( jsonTextContent( taskWriteResult( getController(teamName, claudeDir).tasks.addTaskComment(taskId, { - text, - ...(from ? { from } : {}), + text, + ...(from ? { from } : {}), }) as Record ) ) - ), + ); + }, }); server.addTool({ @@ -448,20 +480,22 @@ export function registerTaskTools(server: Pick) { filename, mimeType, noFallback, - }) => - await Promise.resolve( + }) => { + assertConfiguredTeam(teamName, claudeDir); + return await Promise.resolve( jsonTextContent( taskWriteResult( getController(teamName, claudeDir).tasks.attachTaskFile(taskId, { - file: filePath, - ...(mode ? { mode } : {}), - ...(filename ? { filename } : {}), - ...(mimeType ? { 'mime-type': mimeType } : {}), - ...(noFallback ? { 'no-fallback': true } : {}), + file: filePath, + ...(mode ? { mode } : {}), + ...(filename ? { filename } : {}), + ...(mimeType ? { 'mime-type': mimeType } : {}), + ...(noFallback ? { 'no-fallback': true } : {}), }) as Record ) ) - ), + ); + }, }); server.addTool({ @@ -488,20 +522,22 @@ export function registerTaskTools(server: Pick) { filename, mimeType, noFallback, - }) => - await Promise.resolve( + }) => { + assertConfiguredTeam(teamName, claudeDir); + return await Promise.resolve( jsonTextContent( taskWriteResult( getController(teamName, claudeDir).tasks.attachCommentFile(taskId, commentId, { - file: filePath, - ...(mode ? { mode } : {}), - ...(filename ? { filename } : {}), - ...(mimeType ? { 'mime-type': mimeType } : {}), - ...(noFallback ? { 'no-fallback': true } : {}), + file: filePath, + ...(mode ? { mode } : {}), + ...(filename ? { filename } : {}), + ...(mimeType ? { 'mime-type': mimeType } : {}), + ...(noFallback ? { 'no-fallback': true } : {}), }) as Record ) ) - ), + ); + }, }); server.addTool({ @@ -512,17 +548,19 @@ export function registerTaskTools(server: Pick) { taskId: z.string().min(1), value: z.enum(['lead', 'user', 'clear']), }), - execute: async ({ teamName, claudeDir, taskId, value }) => - await Promise.resolve( + execute: async ({ teamName, claudeDir, taskId, value }) => { + assertConfiguredTeam(teamName, claudeDir); + return await Promise.resolve( jsonTextContent( slimTask( getController(teamName, claudeDir).tasks.setNeedsClarification( - taskId, - value === 'clear' ? null : value + taskId, + value === 'clear' ? null : value ) as Record ) ) - ), + ); + }, }); server.addTool({ @@ -534,10 +572,20 @@ export function registerTaskTools(server: Pick) { targetId: z.string().min(1), relationship: relationshipTypeSchema, }), - execute: async ({ teamName, claudeDir, taskId, targetId, relationship }) => - await Promise.resolve( - jsonTextContent(slimTask(getController(teamName, claudeDir).tasks.linkTask(taskId, targetId, relationship) as Record)) - ), + execute: async ({ teamName, claudeDir, taskId, targetId, relationship }) => { + assertConfiguredTeam(teamName, claudeDir); + return await Promise.resolve( + jsonTextContent( + slimTask( + getController(teamName, claudeDir).tasks.linkTask( + taskId, + targetId, + relationship + ) as Record + ) + ) + ); + }, }); server.addTool({ @@ -549,12 +597,20 @@ export function registerTaskTools(server: Pick) { targetId: z.string().min(1), relationship: relationshipTypeSchema, }), - execute: async ({ teamName, claudeDir, taskId, targetId, relationship }) => - await Promise.resolve( + execute: async ({ teamName, claudeDir, taskId, targetId, relationship }) => { + assertConfiguredTeam(teamName, claudeDir); + return await Promise.resolve( jsonTextContent( - slimTask(getController(teamName, claudeDir).tasks.unlinkTask(taskId, targetId, relationship) as Record) + slimTask( + getController(teamName, claudeDir).tasks.unlinkTask( + taskId, + targetId, + relationship + ) as Record + ) ) - ), + ); + }, }); server.addTool({ @@ -566,16 +622,19 @@ export function registerTaskTools(server: Pick) { memberName: z.string().min(1), runtimeProvider: z.enum(['native', 'opencode']).optional(), }), - execute: async ({ teamName, claudeDir, memberName, runtimeProvider }) => ({ - content: [ - { - type: 'text' as const, - text: await getController(teamName, claudeDir).tasks.memberBriefing(memberName, { - ...(runtimeProvider ? { runtimeProvider } : {}), - }), - }, - ], - }), + execute: async ({ teamName, claudeDir, memberName, runtimeProvider }) => { + assertConfiguredTeam(teamName, claudeDir); + return { + content: [ + { + type: 'text' as const, + text: await getController(teamName, claudeDir).tasks.memberBriefing(memberName, { + ...(runtimeProvider ? { runtimeProvider } : {}), + }), + }, + ], + }; + }, }); server.addTool({ @@ -585,13 +644,16 @@ export function registerTaskTools(server: Pick) { ...toolContextSchema, memberName: z.string().min(1), }), - execute: async ({ teamName, claudeDir, memberName }) => ({ - content: [ - { - type: 'text' as const, - text: await getController(teamName, claudeDir).tasks.taskBriefing(memberName), - }, - ], - }), + execute: async ({ teamName, claudeDir, memberName }) => { + assertConfiguredTeam(teamName, claudeDir); + return { + content: [ + { + type: 'text' as const, + text: await getController(teamName, claudeDir).tasks.taskBriefing(memberName), + }, + ], + }; + }, }); } diff --git a/mcp-server/src/utils/teamConfig.ts b/mcp-server/src/utils/teamConfig.ts new file mode 100644 index 00000000..1e9cb650 --- /dev/null +++ b/mcp-server/src/utils/teamConfig.ts @@ -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.` + ); + } +} diff --git a/mcp-server/test/stdio.e2e.test.ts b/mcp-server/test/stdio.e2e.test.ts index 8b99bb66..93a53ff4 100644 --- a/mcp-server/test/stdio.e2e.test.ts +++ b/mcp-server/test/stdio.e2e.test.ts @@ -487,6 +487,52 @@ describe('agent-teams-mcp stdio e2e', () => { } }); + it('fails closed for primary queue and inventory tools when team config is missing over stdio', async () => { + const client = new McpStdIoClient(serverPath, workspaceRoot); + const expected = + 'Unknown team "team-lead". Board tools require an existing configured team with config.json.'; + + try { + await client.initialize(); + + const leadBriefing = (await client.callTool( + 'lead_briefing', + { + claudeDir, + teamName: 'team-lead', + }, + 40 + )) as { result?: { isError?: boolean; content?: Array<{ text?: string }> } }; + expect(leadBriefing.result?.isError).toBe(true); + expect(leadBriefing.result?.content?.[0]?.text).toContain(expected); + + const taskBriefing = (await client.callTool( + 'task_briefing', + { + claudeDir, + teamName: 'team-lead', + memberName: 'alice', + }, + 41 + )) as { result?: { isError?: boolean; content?: Array<{ text?: string }> } }; + expect(taskBriefing.result?.isError).toBe(true); + expect(taskBriefing.result?.content?.[0]?.text).toContain(expected); + + const taskList = (await client.callTool( + 'task_list', + { + claudeDir, + teamName: 'team-lead', + }, + 42 + )) as { result?: { isError?: boolean; content?: Array<{ text?: string }> } }; + expect(taskList.result?.isError).toBe(true); + expect(taskList.result?.content?.[0]?.text).toContain(expected); + } finally { + await client.close(); + } + }); + it('caps high-volume task_list inventory over stdio and keeps rows compact', async () => { await writeTeamConfig(claudeDir, 'bulk-inventory-team'); await writeBulkTaskRows(claudeDir, 'bulk-inventory-team', 225); @@ -1909,6 +1955,38 @@ describe('agent-teams-mcp stdio e2e', () => { const startDeletedErrorText = startDeletedResponse.error?.message ?? (startDeletedResponse.result?.content?.[0]?.text ?? ''); expect(startDeletedErrorText).toContain('use task_restore before starting work'); + + const restoreResult = await client.callTool( + 'task_restore', + { + claudeDir, + teamName: 'stdio-hardening-team', + taskId: task.id, + actor: 'team-lead', + }, + 110 + ); + const restored = parseJsonToolResult((restoreResult as { result: unknown }).result); + expect(restored.status).toBe('pending'); + expect(restored.reviewState).toBe('none'); + + const restoreAgainResult = await client.callTool( + 'task_restore', + { + claudeDir, + teamName: 'stdio-hardening-team', + taskId: task.id, + actor: 'team-lead', + }, + 111 + ); + const restoreAgainResponse = restoreAgainResult as { + error?: { message?: string }; + result?: { content?: Array<{ text?: string }> }; + }; + const restoreAgainErrorText = + restoreAgainResponse.error?.message ?? (restoreAgainResponse.result?.content?.[0]?.text ?? ''); + expect(restoreAgainErrorText).toContain('task_restore only restores deleted tasks'); } finally { await client.close(); } diff --git a/mcp-server/test/tools.test.ts b/mcp-server/test/tools.test.ts index 8fd07b88..e1788d1b 100644 --- a/mcp-server/test/tools.test.ts +++ b/mcp-server/test/tools.test.ts @@ -88,7 +88,9 @@ describe('agent-teams-mcp tools', () => { res.end(JSON.stringify(result.body)); } catch (error) { res.writeHead(500, { 'content-type': 'application/json' }); - res.end(JSON.stringify({ error: error instanceof Error ? error.message : String(error) })); + res.end( + JSON.stringify({ error: error instanceof Error ? error.message : String(error) }) + ); } }); }); @@ -126,6 +128,10 @@ describe('agent-teams-mcp tools', () => { }); it('launches and stops teams through the runtime MCP tools', async () => { + const claudeDir = makeClaudeDir(); + writeTeamConfig(claudeDir, 'alpha', { + members: [{ name: 'lead', role: 'team-lead' }], + }); const calls: Array<{ method?: string; url?: string; body?: unknown }> = []; const server = await startControlServer(async ({ method, url, body }) => { calls.push({ method, url, body }); @@ -172,6 +178,7 @@ describe('agent-teams-mcp tools', () => { try { const launched = parseJsonToolResult( await getTool('team_launch').execute({ + claudeDir, teamName: 'alpha', cwd: '/tmp/project', controlUrl: server.baseUrl, @@ -183,6 +190,7 @@ describe('agent-teams-mcp tools', () => { const stopped = parseJsonToolResult( await getTool('team_stop').execute({ + claudeDir, teamName: 'alpha', controlUrl: server.baseUrl, }) @@ -217,6 +225,13 @@ describe('agent-teams-mcp tools', () => { }); it('forwards OpenCode runtime MCP tools through the runtime control bridge', async () => { + const claudeDir = makeClaudeDir(); + writeTeamConfig(claudeDir, 'alpha', { + members: [ + { name: 'lead', role: 'team-lead' }, + { name: 'alice', role: 'developer' }, + ], + }); const calls: Array<{ method?: string; url?: string; body?: unknown }> = []; const server = await startControlServer(async ({ method, url, body }) => { calls.push({ method, url, body }); @@ -225,6 +240,7 @@ describe('agent-teams-mcp tools', () => { try { await getTool('runtime_bootstrap_checkin').execute({ + claudeDir, teamName: 'alpha', controlUrl: server.baseUrl, runId: 'run-oc', @@ -232,6 +248,7 @@ describe('agent-teams-mcp tools', () => { runtimeSessionId: 'ses-1', }); await getTool('runtime_deliver_message').execute({ + claudeDir, teamName: 'alpha', controlUrl: server.baseUrl, idempotencyKey: 'idem-1', @@ -242,6 +259,7 @@ describe('agent-teams-mcp tools', () => { text: 'hello', }); await getTool('runtime_task_event').execute({ + claudeDir, teamName: 'alpha', controlUrl: server.baseUrl, idempotencyKey: 'idem-task-1', @@ -252,6 +270,7 @@ describe('agent-teams-mcp tools', () => { event: 'started', }); await getTool('runtime_heartbeat').execute({ + claudeDir, teamName: 'alpha', controlUrl: server.baseUrl, runId: 'run-oc', @@ -281,6 +300,9 @@ describe('agent-teams-mcp tools', () => { it('discovers the control endpoint from the published state file', async () => { const claudeDir = makeClaudeDir(); + writeTeamConfig(claudeDir, 'alpha', { + members: [{ name: 'lead', role: 'team-lead' }], + }); const statePath = path.join(claudeDir, 'team-control-api.json'); const server = await startControlServer(async ({ method, url }) => { @@ -665,12 +687,16 @@ describe('agent-teams-mcp tools', () => { expect(ownerInbox[0].text).toContain('task_start'); expect(ownerInbox[0].text).toContain('task_add_comment'); expect(ownerInbox[0].text).toContain('Read the plan before starting.'); - expect(ownerInbox[0].text).toContain('If you are idle and this task is ready to start, start it now.'); + expect(ownerInbox[0].text).toContain( + 'If you are idle and this task is ready to start, start it now.' + ); expect(ownerInbox[0].text).toContain( 'If you are busy, blocked, or still need more context, immediately add a short task comment' ); expect(ownerInbox[3].summary).toContain(`#${unassignedTask.displayId}`); - expect(ownerInbox[3].text).toContain('If you are idle and this task is ready to start, start it now.'); + expect(ownerInbox[3].text).toContain( + 'If you are idle and this task is ready to start, start it now.' + ); expect(ownerInbox[3].text).toContain('task_add_comment'); const briefing = (await getTool('task_briefing').execute({ @@ -712,14 +738,22 @@ describe('agent-teams-mcp tools', () => { expect(memberBriefingText).toContain( 'Awareness items are watch-only context and do not authorize you to start work unless the lead reroutes the task or you become the actionOwner.' ); - expect(memberBriefingText).toContain('TURN ACTION MODE PROTOCOL (HIGHEST PRIORITY FOR EACH USER TURN):'); + expect(memberBriefingText).toContain( + 'TURN ACTION MODE PROTOCOL (HIGHEST PRIORITY FOR EACH USER TURN):' + ); expect(memberBriefingText).toContain('Task briefing for alice:'); expect(memberBriefingText).toContain(`#${activeTask.displayId}`); fs.mkdirSync(path.join(claudeDir, 'teams', teamName, 'inboxes'), { recursive: true }); fs.writeFileSync(path.join(claudeDir, 'teams', teamName, 'inboxes', 'carol.json'), '[]'); - fs.writeFileSync(path.join(claudeDir, 'teams', teamName, 'inboxes', 'cross_team_send.json'), '[]'); - fs.writeFileSync(path.join(claudeDir, 'teams', teamName, 'inboxes', 'other-team.alice.json'), '[]'); + fs.writeFileSync( + path.join(claudeDir, 'teams', teamName, 'inboxes', 'cross_team_send.json'), + '[]' + ); + fs.writeFileSync( + path.join(claudeDir, 'teams', teamName, 'inboxes', 'other-team.alice.json'), + '[]' + ); const inboxResolvedBriefing = (await getTool('member_briefing').execute({ claudeDir, @@ -727,7 +761,9 @@ describe('agent-teams-mcp tools', () => { memberName: 'carol', })) as { content: Array<{ text: string }> }; const inboxResolvedBriefingText = inboxResolvedBriefing.content[0]?.text ?? ''; - expect(inboxResolvedBriefingText).toContain('Member briefing for carol on team "gamma" (gamma).'); + expect(inboxResolvedBriefingText).toContain( + 'Member briefing for carol on team "gamma" (gamma).' + ); expect(inboxResolvedBriefingText).toContain('Role: team member.'); await expect( @@ -914,9 +950,9 @@ describe('agent-teams-mcp tools', () => { teamName, }) ); - expect(listedTasks.find((task: { id: string }) => task.id === createdTask.id)?.reviewState).toBe( - 'needsFix' - ); + expect( + listedTasks.find((task: { id: string }) => task.id === createdTask.id)?.reviewState + ).toBe('needsFix'); const kanbanCleared = parseJsonToolResult( await getTool('kanban_clear').execute({ @@ -1061,6 +1097,26 @@ describe('agent-teams-mcp tools', () => { ); expect(kanbanState.tasks[reviewTask.id]).toBeUndefined(); expect(JSON.stringify(kanbanState.columnOrder ?? {})).not.toContain(reviewTask.id); + + const restored = parseJsonToolResult( + await getTool('task_restore').execute({ + claudeDir, + teamName, + taskId: reviewTask.id, + actor: 'lead', + }) + ); + expect(restored.status).toBe('pending'); + expect(restored.reviewState).toBe('none'); + + await expect( + getTool('task_restore').execute({ + claudeDir, + teamName, + taskId: reviewTask.id, + actor: 'lead', + }) + ).rejects.toThrow('task_restore only restores deleted tasks'); }); it('only notifies the owner on review_approve when notifyOwner is explicit', async () => { @@ -1222,6 +1278,41 @@ describe('agent-teams-mcp tools', () => { } }); + it('rejects non-configured teams before MCP side-effect writes', async () => { + const claudeDir = makeClaudeDir(); + writeTeamConfig(claudeDir, 'real-team', { + members: [{ name: 'lead', role: 'team-lead' }], + }); + + await expect( + getTool('message_send').execute({ + claudeDir, + teamName: 'typo-team', + to: 'alice', + text: 'Should not create inbox', + }) + ).rejects.toThrow('Unknown team "typo-team"'); + await expect( + getTool('process_register').execute({ + claudeDir, + teamName: 'typo-team', + pid: process.pid, + label: 'watcher', + }) + ).rejects.toThrow('Unknown team "typo-team"'); + await expect( + getTool('cross_team_send').execute({ + claudeDir, + teamName: 'typo-team', + toTeam: 'real-team', + text: 'Should not deliver', + }) + ).rejects.toThrow('Unknown team "typo-team"'); + + expect(fs.existsSync(path.join(claudeDir, 'teams', 'typo-team'))).toBe(false); + expect(fs.existsSync(path.join(claudeDir, 'teams', 'real-team', 'inboxes', 'lead.json'))).toBe(false); + }); + it('exposes zod schemas that reject obviously invalid payloads', () => { expect( getTool('task_create').parameters?.safeParse({ @@ -1370,9 +1461,7 @@ describe('agent-teams-mcp tools', () => { expect(completed.comments).toBeUndefined(); // task_list: explicit inventory shape only - const listed = parseJsonToolResult( - await getTool('task_list').execute({ claudeDir, teamName }) - ); + const listed = parseJsonToolResult(await getTool('task_list').execute({ claudeDir, teamName })); const listedTask = listed.find((t: { id: string }) => t.id === task.id); expect(listedTask).toBeDefined(); expect(listedTask).toEqual({ @@ -1412,9 +1501,7 @@ describe('agent-teams-mcp tools', () => { const sentPath = path.join(claudeDir, 'teams', teamName, 'sentMessages.json'); const teamDir = path.join(claudeDir, 'teams', teamName); fs.mkdirSync(teamDir, { recursive: true }); - const existing = fs.existsSync(sentPath) - ? JSON.parse(fs.readFileSync(sentPath, 'utf8')) - : []; + const existing = fs.existsSync(sentPath) ? JSON.parse(fs.readFileSync(sentPath, 'utf8')) : []; existing.push(message); fs.writeFileSync(sentPath, JSON.stringify(existing, null, 2)); } @@ -1760,9 +1847,7 @@ describe('agent-teams-mcp tools', () => { text: 'Roundtrip test message', timestamp: '2026-03-15T16:00:00.000Z', source: 'user_sent', - attachments: [ - { id: 'att-rt', filename: 'data.csv', mimeType: 'text/csv', size: 1024 }, - ], + attachments: [{ id: 'att-rt', filename: 'data.csv', mimeType: 'text/csv', size: 1024 }], }); const created = parseJsonToolResult( @@ -1880,4 +1965,20 @@ describe('agent-teams-mcp tools', () => { 'Unknown team "team-lead". Board tools require an existing configured team with config.json.' ); }); + + it('fails closed for primary queue and inventory tools when team config does not exist', async () => { + const claudeDir = makeClaudeDir(); + const params = { claudeDir, teamName: 'team-lead' }; + const expected = + 'Unknown team "team-lead". Board tools require an existing configured team with config.json.'; + + await expect(getTool('lead_briefing').execute(params)).rejects.toThrow(expected); + await expect( + getTool('task_briefing').execute({ + ...params, + memberName: 'alice', + }) + ).rejects.toThrow(expected); + await expect(getTool('task_list').execute(params)).rejects.toThrow(expected); + }); }); diff --git a/package.json b/package.json index 3d17935f..acb8da72 100644 --- a/package.json +++ b/package.json @@ -21,7 +21,6 @@ "dev": "node ./scripts/dev-with-runtime.mjs", "dev:web": "node ./scripts/dev-web.mjs", "dev:kill": "node bin/kill-dev.js", - "opencode:prove-production": "node ./scripts/prove-opencode-production.mjs", "opencode:prove-mixed-recovery": "node ./scripts/prove-opencode-mixed-recovery.mjs", "opencode:prove-team-provisioning": "node ./scripts/prove-opencode-team-provisioning.mjs", "team:prove-launch-matrix": "pnpm exec vitest run --maxWorkers 1 --minWorkers 1 test/main/services/team/TeamAgentLaunchMatrix.safe-e2e.test.ts", diff --git a/packages/agent-graph/src/canvas/draw-agents.ts b/packages/agent-graph/src/canvas/draw-agents.ts index 0e844e9d..e530b971 100644 --- a/packages/agent-graph/src/canvas/draw-agents.ts +++ b/packages/agent-graph/src/canvas/draw-agents.ts @@ -279,7 +279,13 @@ function drawLaunchStage( for (let index = 0; index < 3; index += 1) { const angle = time * 1.2 + (Math.PI * 2 * index) / 3; ctx.beginPath(); - ctx.arc(x + Math.cos(angle) * dotOrbit, y + Math.sin(angle) * dotOrbit, 1.7, 0, Math.PI * 2); + ctx.arc( + x + Math.cos(angle) * dotOrbit, + y + Math.sin(angle) * dotOrbit, + 1.7, + 0, + Math.PI * 2 + ); ctx.fillStyle = hexWithAlpha('#e4e4e7', 0.72); ctx.fill(); } @@ -736,6 +742,13 @@ function getLaunchStatusColor(visualState: GraphNode['launchVisualState']): stri return hexWithAlpha('#f59e0b', 0.92); case 'runtime_pending': return hexWithAlpha('#67e8f9', 0.9); + case 'shell_only': + case 'runtime_candidate': + return hexWithAlpha('#f97316', 0.9); + case 'registered_only': + return hexWithAlpha('#a1a1aa', 0.82); + case 'stale_runtime': + return hexWithAlpha('#ef4444', 0.82); case 'settling': return hexWithAlpha('#22c55e', 0.9); case 'error': diff --git a/packages/agent-graph/src/ports/types.ts b/packages/agent-graph/src/ports/types.ts index fa7461bc..cf7b21cc 100644 --- a/packages/agent-graph/src/ports/types.ts +++ b/packages/agent-graph/src/ports/types.ts @@ -22,8 +22,13 @@ export type GraphLaunchVisualState = | 'spawning' | 'permission_pending' | 'runtime_pending' + | 'shell_only' + | 'runtime_candidate' + | 'registered_only' + | 'stale_runtime' | 'settling' - | 'error'; + | 'error' + | 'skipped'; // ─── Edge & Particle Types ─────────────────────────────────────────────────── @@ -82,7 +87,7 @@ export interface GraphNode { /** Avatar image URL (e.g., robohash) */ avatarUrl?: string; /** Spawn lifecycle status */ - spawnStatus?: 'offline' | 'waiting' | 'spawning' | 'online' | 'error'; + spawnStatus?: 'offline' | 'waiting' | 'spawning' | 'online' | 'error' | 'skipped'; /** Shared launch-stage visual derived by the host app */ launchVisualState?: GraphLaunchVisualState; /** Shared launch-stage text shown beside the node during launch only */ diff --git a/scripts/dev-with-runtime.mjs b/scripts/dev-with-runtime.mjs index b0baa0d1..ebd5db07 100644 --- a/scripts/dev-with-runtime.mjs +++ b/scripts/dev-with-runtime.mjs @@ -19,6 +19,7 @@ const runtimeCacheRoot = process.env.CLAUDE_DEV_RUNTIME_CACHE_ROOT?.trim() ? path.resolve(process.env.CLAUDE_DEV_RUNTIME_CACHE_ROOT.trim()) : defaultRuntimeCacheRoot; const shouldPrintRuntimePath = process.argv.includes('--print-runtime-path'); +const runtimeDisplayName = 'teams orchestrator'; const WINDOWS_SHELL_COMMANDS = new Set(['pnpm', 'npm', 'npx', 'yarn', 'yarnpkg', 'corepack']); function shouldUseWindowsShell(cmd) { @@ -108,9 +109,10 @@ function getPlatformAssetKey() { } function getReleaseAssetUrl(runtimeLock, asset) { - const releaseTag = typeof runtimeLock.releaseTag === 'string' && runtimeLock.releaseTag.trim().length > 0 - ? runtimeLock.releaseTag.trim() - : runtimeLock.sourceRef; + const releaseTag = + typeof runtimeLock.releaseTag === 'string' && runtimeLock.releaseTag.trim().length > 0 + ? runtimeLock.releaseTag.trim() + : runtimeLock.sourceRef; return `https://github.com/${runtimeLock.releaseRepository}/releases/download/${releaseTag}/${encodeURIComponent(asset.file)}`; } @@ -152,9 +154,7 @@ function truncateMiddle(value, maxLength) { function buildProgressBar(progressRatio, width) { const safeWidth = Math.max(10, width); - const clampedRatio = Number.isFinite(progressRatio) - ? Math.min(1, Math.max(0, progressRatio)) - : 0; + const clampedRatio = Number.isFinite(progressRatio) ? Math.min(1, Math.max(0, progressRatio)) : 0; const filledWidth = Math.round(safeWidth * clampedRatio); return `${'='.repeat(filledWidth)}${'-'.repeat(safeWidth - filledWidth)}`; } @@ -164,7 +164,8 @@ function supportsProgressRedraw() { } function formatProgressLine(label, writtenBytes, totalBytes, hasTotal) { - const columns = process.stdout.columns && process.stdout.columns > 0 ? process.stdout.columns : 100; + const columns = + process.stdout.columns && process.stdout.columns > 0 ? process.stdout.columns : 100; const ratio = hasTotal ? writtenBytes / totalBytes : 0; const percentText = hasTotal ? ` ${Math.floor(ratio * 100)}%` : ''; const bytesText = hasTotal @@ -196,6 +197,16 @@ function readBinaryVersion(binaryPath) { return runAndCapture(binaryPath, ['--version']); } +function formatRuntimeVersionForDisplay(versionText) { + const trimmed = versionText.trim(); + if (!trimmed) { + return runtimeDisplayName; + } + + const versionOnly = trimmed.replace(/\s*\([^)]*\)\s*$/, ''); + return `${versionOnly} (${runtimeDisplayName})`; +} + function isExecutable(filePath) { if (!fs.existsSync(filePath)) { return false; @@ -305,7 +316,10 @@ async function downloadWithProgress(url, destinationPath) { readline.clearLine(process.stdout, 0); readline.cursorTo(process.stdout, 0); process.stdout.write(`${formatProgressLine(label, writtenBytes, totalBytes, hasTotal)}\n`); - } else if ((hasTotal && lastLoggedPercent < 100) || (!hasTotal && writtenBytes !== lastLoggedBytes)) { + } else if ( + (hasTotal && lastLoggedPercent < 100) || + (!hasTotal && writtenBytes !== lastLoggedBytes) + ) { process.stdout.write(`${formatProgressSummary(writtenBytes, totalBytes, hasTotal)}\n`); } } @@ -511,7 +525,9 @@ async function main() { if ('cacheDir' in resolvedRuntime && resolvedRuntime.cacheDir) { process.stdout.write(`Runtime cache: ${resolvedRuntime.cacheDir}\n`); } - process.stdout.write(`Runtime version: ${resolvedRuntime.versionText}\n`); + process.stdout.write( + `Runtime version: ${formatRuntimeVersionForDisplay(resolvedRuntime.versionText)}\n` + ); const uiEnv = { ...process.env, diff --git a/scripts/prove-opencode-production.mjs b/scripts/prove-opencode-production.mjs deleted file mode 100644 index 6c3c221c..00000000 --- a/scripts/prove-opencode-production.mjs +++ /dev/null @@ -1,72 +0,0 @@ -#!/usr/bin/env node - -import { spawnSync } from 'node:child_process'; -import os from 'node:os'; -import path from 'node:path'; -import process from 'node:process'; -import { fileURLToPath } from 'node:url'; - -const scriptDir = path.dirname(fileURLToPath(import.meta.url)); -const repoRoot = path.resolve(scriptDir, '..'); -const defaultEvidencePath = path.join( - resolveAppDataDir(), - 'Agent Teams UI', - 'opencode-bridge', - 'production-e2e-evidence.json' -); -const orchestratorRoot = process.env.CLAUDE_DEV_RUNTIME_ROOT?.trim(); -const siblingOrchestrator = path.resolve(repoRoot, '..', 'agent_teams_orchestrator'); - -const env = { - ...process.env, - OPENCODE_E2E: '1', - OPENCODE_E2E_PROJECT_PATH: process.env.OPENCODE_E2E_PROJECT_PATH?.trim() || repoRoot, - OPENCODE_E2E_MODEL: process.env.OPENCODE_E2E_MODEL?.trim() || 'opencode/big-pickle', - OPENCODE_E2E_WRITE_APP_EVIDENCE: '1', - OPENCODE_E2E_WRITE_EVIDENCE_PATH: - process.env.OPENCODE_E2E_WRITE_EVIDENCE_PATH?.trim() || - process.env.CLAUDE_TEAM_OPENCODE_PRODUCTION_E2E_EVIDENCE_PATH?.trim() || - defaultEvidencePath, - OPENCODE_DISABLE_AUTOUPDATE: process.env.OPENCODE_DISABLE_AUTOUPDATE ?? '1', -}; - -if (!env.CLAUDE_AGENT_TEAMS_ORCHESTRATOR_CLI_PATH?.trim()) { - const runtimeRoot = orchestratorRoot ? path.resolve(orchestratorRoot) : siblingOrchestrator; - env.CLAUDE_AGENT_TEAMS_ORCHESTRATOR_CLI_PATH = path.join(runtimeRoot, 'cli'); -} - -console.log('Running OpenCode production proof'); -console.log(`Model: ${env.OPENCODE_E2E_MODEL}`); -console.log(`Project: ${env.OPENCODE_E2E_PROJECT_PATH}`); -console.log(`Evidence: ${env.OPENCODE_E2E_WRITE_EVIDENCE_PATH}`); -console.log(`Orchestrator CLI: ${env.CLAUDE_AGENT_TEAMS_ORCHESTRATOR_CLI_PATH}`); - -const result = spawnSync( - 'pnpm', - ['exec', 'vitest', 'run', 'test/main/services/team/OpenCodeProductionGate.live.test.ts'], - { - cwd: repoRoot, - env, - stdio: 'inherit', - shell: process.platform === 'win32', - } -); - -if (result.error) { - console.error(`Failed to run OpenCode production proof: ${result.error.message}`); - process.exit(1); -} - -process.exit(result.status ?? 1); - -function resolveAppDataDir() { - if (process.platform === 'darwin') { - return path.join(os.homedir(), 'Library', 'Application Support'); - } - - if (process.platform === 'win32') { - return process.env.APPDATA || path.join(os.homedir(), 'AppData', 'Roaming'); - } - - return process.env.XDG_CONFIG_HOME || path.join(os.homedir(), '.config'); -} diff --git a/src/features/recent-projects/core/application/use-cases/ListDashboardRecentProjectsUseCase.ts b/src/features/recent-projects/core/application/use-cases/ListDashboardRecentProjectsUseCase.ts index 348fc1b4..f1429e4e 100644 --- a/src/features/recent-projects/core/application/use-cases/ListDashboardRecentProjectsUseCase.ts +++ b/src/features/recent-projects/core/application/use-cases/ListDashboardRecentProjectsUseCase.ts @@ -13,7 +13,7 @@ import type { } from '../ports/RecentProjectsSourcePort'; const DEFAULT_CACHE_TTL_MS = 10_000; -const DEFAULT_DEGRADED_CACHE_TTL_MS = 1_500; +const DEFAULT_DEGRADED_CACHE_TTL_MS = 30_000; interface SourceLoadResult { candidates: RecentProjectCandidate[]; @@ -99,9 +99,7 @@ export class ListDashboardRecentProjectsUseCase { } const viewModel = this.deps.output.present(response); - const cacheTtlMs = hasDegradedSources - ? Math.min(this.#cacheTtlMs, this.#degradedCacheTtlMs) - : this.#cacheTtlMs; + const cacheTtlMs = hasDegradedSources ? this.#degradedCacheTtlMs : this.#cacheTtlMs; await this.deps.cache.set(cacheKey, viewModel, cacheTtlMs); this.deps.logger.info('recent-projects loaded', { diff --git a/src/features/recent-projects/main/adapters/output/sources/CodexRecentProjectsSourceAdapter.ts b/src/features/recent-projects/main/adapters/output/sources/CodexRecentProjectsSourceAdapter.ts index 19c2ad0c..113eae5a 100644 --- a/src/features/recent-projects/main/adapters/output/sources/CodexRecentProjectsSourceAdapter.ts +++ b/src/features/recent-projects/main/adapters/output/sources/CodexRecentProjectsSourceAdapter.ts @@ -16,16 +16,26 @@ import type { import type { RecentProjectIdentityResolver } from '@features/recent-projects/main/infrastructure/identity/RecentProjectIdentityResolver'; import type { ServiceContext } from '@main/services'; -const CODEX_THREAD_LIMIT = 40; +const CODEX_THREAD_LIMIT = 20; const CODEX_INITIALIZE_TIMEOUT_MS = 6_000; -const CODEX_LIVE_FETCH_TIMEOUT_MS = 4_500; -const CODEX_ARCHIVED_FETCH_TIMEOUT_MS = 2_500; +const CODEX_LIVE_FETCH_TIMEOUT_MS = 12_000; +const CODEX_ARCHIVED_FETCH_TIMEOUT_MS = 4_000; const CODEX_SESSION_OVERHEAD_TIMEOUT_MS = 1_500; const CODEX_TOTAL_FETCH_TIMEOUT_MS = - CODEX_INITIALIZE_TIMEOUT_MS + CODEX_LIVE_FETCH_TIMEOUT_MS + CODEX_SESSION_OVERHEAD_TIMEOUT_MS; + CODEX_INITIALIZE_TIMEOUT_MS + + CODEX_ARCHIVED_FETCH_TIMEOUT_MS + + CODEX_LIVE_FETCH_TIMEOUT_MS + + CODEX_SESSION_OVERHEAD_TIMEOUT_MS; const CODEX_SOURCE_TIMEOUT_MS = CODEX_TOTAL_FETCH_TIMEOUT_MS + 500; const CODEX_LIVE_ONLY_FALLBACK_TOTAL_TIMEOUT_MS = CODEX_INITIALIZE_TIMEOUT_MS + CODEX_LIVE_FETCH_TIMEOUT_MS + CODEX_SESSION_OVERHEAD_TIMEOUT_MS; +const CODEX_STALE_CANDIDATES_TTL_MS = 5 * 60_000; +const CODEX_FULL_FAILURE_COOLDOWN_MS = 30_000; + +interface StaleCodexCandidatesSnapshot { + candidates: RecentProjectCandidate[]; + capturedAt: number; +} function isInteractiveSource(source: unknown): boolean { return source === 'vscode' || source === 'cli'; @@ -42,9 +52,24 @@ function isDegradedThreadResult(result: CodexRecentThreadsResult): boolean { return Boolean(result.live.error || result.archived.error); } +function getFullFailureReason(result: CodexRecentThreadsResult): string | null { + if (!result.live.error || !result.archived.error) { + return null; + } + + if (result.live.error === result.archived.error) { + return result.live.error; + } + + return `live: ${result.live.error}; archived: ${result.archived.error}`; +} + export class CodexRecentProjectsSourceAdapter implements RecentProjectsSourcePort { readonly sourceId = 'codex'; readonly timeoutMs = CODEX_SOURCE_TIMEOUT_MS; + #staleCandidatesSnapshot: StaleCodexCandidatesSnapshot | null = null; + #fullFailureCooldownUntil = 0; + #fullFailureCooldownReason: string | null = null; constructor( private readonly deps: { @@ -77,8 +102,19 @@ export class CodexRecentProjectsSourceAdapter implements RecentProjectsSourcePor }; } + const cooldown = this.#getActiveCooldown(); + if (cooldown) { + this.deps.logger.info('codex recent-projects source cooldown active', cooldown); + return { + candidates: this.#getFreshStaleCandidates() ?? [], + degraded: true, + }; + } + const threadSegments = await this.#listRecentThreadsSafe(binaryPath); const degraded = isDegradedThreadResult(threadSegments); + const fullFailureReason = getFullFailureReason(threadSegments); + this.#updateFullFailureCooldown(fullFailureReason); this.#logSegmentFailure(threadSegments, 'live'); this.#logSegmentFailure(threadSegments, 'archived'); const liveThreads = threadSegments.live.threads; @@ -92,6 +128,25 @@ export class CodexRecentProjectsSourceAdapter implements RecentProjectsSourcePor await Promise.all(interactiveThreads.map((thread) => this.#toCandidate(thread))) ).filter((candidate): candidate is RecentProjectCandidate => candidate !== null); + if (!degraded) { + this.#rememberHealthyCandidates(candidates); + } + + if (degraded && candidates.length === 0) { + const staleCandidates = this.#getFreshStaleCandidates(); + if (staleCandidates) { + this.deps.logger.info('codex recent-projects served stale candidates', { + count: staleCandidates.length, + reason: fullFailureReason ?? 'degraded-empty-result', + }); + + return { + candidates: staleCandidates, + degraded: true, + }; + } + } + this.deps.logger.info('codex recent-projects source loaded', { count: candidates.length, degraded, @@ -103,6 +158,53 @@ export class CodexRecentProjectsSourceAdapter implements RecentProjectsSourcePor }; } + #getActiveCooldown(): { retryAfterMs: number; reason: string | null } | null { + const retryAfterMs = this.#fullFailureCooldownUntil - Date.now(); + if (retryAfterMs <= 0) { + return null; + } + + return { + retryAfterMs, + reason: this.#fullFailureCooldownReason, + }; + } + + #updateFullFailureCooldown(reason: string | null): void { + if (!reason) { + this.#fullFailureCooldownUntil = 0; + this.#fullFailureCooldownReason = null; + return; + } + + this.#fullFailureCooldownUntil = Date.now() + CODEX_FULL_FAILURE_COOLDOWN_MS; + this.#fullFailureCooldownReason = reason; + } + + #rememberHealthyCandidates(candidates: RecentProjectCandidate[]): void { + this.#staleCandidatesSnapshot = + candidates.length > 0 + ? { + candidates, + capturedAt: Date.now(), + } + : null; + } + + #getFreshStaleCandidates(): RecentProjectCandidate[] | null { + const snapshot = this.#staleCandidatesSnapshot; + if (!snapshot) { + return null; + } + + if (Date.now() - snapshot.capturedAt > CODEX_STALE_CANDIDATES_TTL_MS) { + this.#staleCandidatesSnapshot = null; + return null; + } + + return [...snapshot.candidates]; + } + async #listRecentThreads(binaryPath: string): Promise { const result = await this.deps.appServerClient.listRecentThreads(binaryPath, { limit: CODEX_THREAD_LIMIT, diff --git a/src/features/recent-projects/main/infrastructure/codex/CodexAppServerClient.ts b/src/features/recent-projects/main/infrastructure/codex/CodexAppServerClient.ts index 5cf0b6df..cc1d6975 100644 --- a/src/features/recent-projects/main/infrastructure/codex/CodexAppServerClient.ts +++ b/src/features/recent-projects/main/infrastructure/codex/CodexAppServerClient.ts @@ -74,10 +74,7 @@ export class CodexAppServerClient { } ): Promise { const requestTimeoutMs = options.requestTimeoutMs ?? DEFAULT_REQUEST_TIMEOUT_MS; - const initializeTimeoutMs = Math.max( - options.initializeTimeoutMs ?? DEFAULT_INITIALIZE_TIMEOUT_MS, - requestTimeoutMs - ); + const initializeTimeoutMs = options.initializeTimeoutMs ?? DEFAULT_INITIALIZE_TIMEOUT_MS; const totalTimeoutMs = Math.max( options.totalTimeoutMs ?? DEFAULT_TOTAL_TIMEOUT_MS, initializeTimeoutMs + requestTimeoutMs + MIN_SESSION_OVERHEAD_TIMEOUT_MS @@ -122,13 +119,13 @@ export class CodexAppServerClient { const liveRequestTimeoutMs = options.liveRequestTimeoutMs ?? DEFAULT_REQUEST_TIMEOUT_MS; const archivedRequestTimeoutMs = options.archivedRequestTimeoutMs ?? DEFAULT_REQUEST_TIMEOUT_MS; const sessionRequestTimeoutMs = Math.max(liveRequestTimeoutMs, archivedRequestTimeoutMs); - const initializeTimeoutMs = Math.max( - options.initializeTimeoutMs ?? DEFAULT_INITIALIZE_TIMEOUT_MS, - sessionRequestTimeoutMs - ); + const initializeTimeoutMs = options.initializeTimeoutMs ?? DEFAULT_INITIALIZE_TIMEOUT_MS; const totalTimeoutMs = Math.max( options.totalTimeoutMs ?? DEFAULT_TOTAL_TIMEOUT_MS, - initializeTimeoutMs + sessionRequestTimeoutMs + MIN_SESSION_OVERHEAD_TIMEOUT_MS + initializeTimeoutMs + + liveRequestTimeoutMs + + archivedRequestTimeoutMs + + MIN_SESSION_OVERHEAD_TIMEOUT_MS ); return this.#withThreadListSession( @@ -140,50 +137,55 @@ export class CodexAppServerClient { label: 'codex app-server thread/list', }, async (session) => { - const [live, archived] = await Promise.allSettled([ - session.request( - 'thread/list', - { - archived: false, - limit: options.limit, - sortKey: 'updated_at', - }, - liveRequestTimeoutMs - ), - session.request( - 'thread/list', - { - archived: true, - limit: options.limit, - sortKey: 'updated_at', - }, - archivedRequestTimeoutMs - ), - ]); + const live = await this.#requestThreadListSegment(session, { + archived: false, + limit: options.limit, + timeoutMs: liveRequestTimeoutMs, + }); + const archived = await this.#requestThreadListSegment(session, { + archived: true, + limit: options.limit, + timeoutMs: archivedRequestTimeoutMs, + }); return { - live: - live.status === 'fulfilled' - ? { threads: live.value.data ?? [] } - : { - threads: [], - error: live.reason instanceof Error ? live.reason.message : String(live.reason), - }, - archived: - archived.status === 'fulfilled' - ? { threads: archived.value.data ?? [] } - : { - threads: [], - error: - archived.reason instanceof Error - ? archived.reason.message - : String(archived.reason), - }, + live, + archived, }; } ); } + async #requestThreadListSegment( + session: JsonRpcSession, + options: { + archived: boolean; + limit: number; + timeoutMs: number; + } + ): Promise { + try { + const response = await session.request( + '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( options: ThreadListSessionOptions, handler: (session: JsonRpcSession) => Promise diff --git a/src/features/recent-projects/renderer/hooks/useRecentProjectsSection.ts b/src/features/recent-projects/renderer/hooks/useRecentProjectsSection.ts index 79ae9ce3..27f7c5d9 100644 --- a/src/features/recent-projects/renderer/hooks/useRecentProjectsSection.ts +++ b/src/features/recent-projects/renderer/hooks/useRecentProjectsSection.ts @@ -25,8 +25,8 @@ import type { TeamSummary } from '@shared/types'; const INITIAL_RECENT_PROJECTS = 11; const LOAD_MORE_STEP = 8; -const DEGRADED_RECENT_PROJECTS_FAST_RETRY_DELAY_MS = 1_500; -const DEGRADED_RECENT_PROJECTS_STEADY_RETRY_DELAY_MS = 5_000; +const DEGRADED_RECENT_PROJECTS_FAST_RETRY_DELAY_MS = 30_000; +const DEGRADED_RECENT_PROJECTS_STEADY_RETRY_DELAY_MS = 120_000; const DEGRADED_RECENT_PROJECTS_FAST_RETRY_LIMIT = 3; function matchesSearch(project: DashboardRecentProject, query: string): boolean { diff --git a/src/features/recent-projects/renderer/utils/recentProjectsClientCache.ts b/src/features/recent-projects/renderer/utils/recentProjectsClientCache.ts index 40cdbf9e..e6c18df8 100644 --- a/src/features/recent-projects/renderer/utils/recentProjectsClientCache.ts +++ b/src/features/recent-projects/renderer/utils/recentProjectsClientCache.ts @@ -6,7 +6,7 @@ import type { } from '@features/recent-projects/contracts'; const RECENT_PROJECTS_CLIENT_CACHE_TTL_MS = 15_000; -const RECENT_PROJECTS_CLIENT_DEGRADED_CACHE_TTL_MS = 1_500; +const RECENT_PROJECTS_CLIENT_DEGRADED_CACHE_TTL_MS = 30_000; let cachedPayload: DashboardRecentProjectsPayloadLike = null; let cachedAt = 0; diff --git a/src/features/team-runtime-lanes/core/domain/__tests__/buildMixedPersistedLaunchSnapshot.test.ts b/src/features/team-runtime-lanes/core/domain/__tests__/buildMixedPersistedLaunchSnapshot.test.ts index f39822c6..dc6fb0f7 100644 --- a/src/features/team-runtime-lanes/core/domain/__tests__/buildMixedPersistedLaunchSnapshot.test.ts +++ b/src/features/team-runtime-lanes/core/domain/__tests__/buildMixedPersistedLaunchSnapshot.test.ts @@ -76,6 +76,11 @@ describe('buildMixedPersistedLaunchSnapshot', () => { pendingCount: 1, failedCount: 0, runtimeAlivePendingCount: 0, + shellOnlyPendingCount: 0, + runtimeProcessPendingCount: 0, + runtimeCandidatePendingCount: 0, + noRuntimePendingCount: 0, + permissionPendingCount: 0, }); expect(snapshot.teamLaunchState).toBe('partial_pending'); }); @@ -130,6 +135,11 @@ describe('buildMixedPersistedLaunchSnapshot', () => { bootstrapConfirmed: true, hardFailure: false, runtimePid: 333, + runtimeSessionId: 'session-bob', + livenessKind: 'confirmed_bootstrap', + pidSource: 'runtime_bootstrap', + runtimeDiagnostic: 'OpenCode runtime bootstrap check-in accepted', + runtimeDiagnosticSeverity: 'info', diagnostics: ['spawn accepted', 'late heartbeat received'], }, }, @@ -145,12 +155,22 @@ describe('buildMixedPersistedLaunchSnapshot', () => { runtimeAlive: true, bootstrapConfirmed: true, runtimePid: 333, + runtimeSessionId: 'session-bob', + livenessKind: 'confirmed_bootstrap', + pidSource: 'runtime_bootstrap', + runtimeDiagnostic: 'OpenCode runtime bootstrap check-in accepted', + runtimeDiagnosticSeverity: 'info', }); expect(snapshot.summary).toEqual({ confirmedCount: 2, pendingCount: 0, failedCount: 0, runtimeAlivePendingCount: 0, + shellOnlyPendingCount: 0, + runtimeProcessPendingCount: 0, + runtimeCandidatePendingCount: 0, + noRuntimePendingCount: 0, + permissionPendingCount: 0, }); expect(snapshot.teamLaunchState).toBe('clean_success'); }); @@ -229,6 +249,11 @@ describe('buildMixedPersistedLaunchSnapshot', () => { pendingCount: 0, failedCount: 1, runtimeAlivePendingCount: 0, + shellOnlyPendingCount: 0, + runtimeProcessPendingCount: 0, + runtimeCandidatePendingCount: 0, + noRuntimePendingCount: 0, + permissionPendingCount: 0, }); expect(snapshot.teamLaunchState).toBe('partial_failure'); }); @@ -279,9 +304,12 @@ describe('buildMixedPersistedLaunchSnapshot', () => { evidence: { launchState: 'runtime_pending_permission', agentToolAccepted: true, - runtimeAlive: true, + runtimeAlive: false, bootstrapConfirmed: false, hardFailure: false, + livenessKind: 'permission_blocked', + runtimeDiagnostic: 'OpenCode runtime is waiting for permission approval', + runtimeDiagnosticSeverity: 'warning', pendingPermissionRequestIds: ['opencode:run-1:perm_1'], }, }, @@ -292,9 +320,12 @@ describe('buildMixedPersistedLaunchSnapshot', () => { laneKind: 'secondary', laneOwnerProviderId: 'opencode', launchState: 'runtime_pending_permission', - runtimeAlive: true, + runtimeAlive: false, agentToolAccepted: true, bootstrapConfirmed: false, + livenessKind: 'permission_blocked', + runtimeDiagnostic: 'OpenCode runtime is waiting for permission approval', + runtimeDiagnosticSeverity: 'warning', pendingPermissionRequestIds: ['opencode:run-1:perm_1'], hardFailure: false, }); @@ -303,7 +334,12 @@ describe('buildMixedPersistedLaunchSnapshot', () => { confirmedCount: 1, pendingCount: 1, failedCount: 0, - runtimeAlivePendingCount: 1, + runtimeAlivePendingCount: 0, + shellOnlyPendingCount: 0, + runtimeProcessPendingCount: 0, + runtimeCandidatePendingCount: 0, + noRuntimePendingCount: 0, + permissionPendingCount: 1, }); expect(snapshot.teamLaunchState).toBe('partial_pending'); }); diff --git a/src/features/team-runtime-lanes/core/domain/buildMixedPersistedLaunchSnapshot.ts b/src/features/team-runtime-lanes/core/domain/buildMixedPersistedLaunchSnapshot.ts index 651a0640..546fd3ff 100644 --- a/src/features/team-runtime-lanes/core/domain/buildMixedPersistedLaunchSnapshot.ts +++ b/src/features/team-runtime-lanes/core/domain/buildMixedPersistedLaunchSnapshot.ts @@ -11,6 +11,9 @@ import type { PersistedTeamLaunchPhase, PersistedTeamLaunchSnapshot, ProviderModelLaunchIdentity, + TeamAgentRuntimeDiagnosticSeverity, + TeamAgentRuntimeLivenessKind, + TeamAgentRuntimePidSource, TeamFastMode, TeamProviderBackendId, TeamProviderId, @@ -38,6 +41,12 @@ export interface MixedSecondaryLaneMemberStateInput { hardFailureReason?: string; pendingPermissionRequestIds?: string[]; runtimePid?: number; + runtimeSessionId?: string; + sessionId?: string; + livenessKind?: TeamAgentRuntimeLivenessKind; + pidSource?: TeamAgentRuntimePidSource; + runtimeDiagnostic?: string; + runtimeDiagnosticSeverity?: TeamAgentRuntimeDiagnosticSeverity; diagnostics?: string[]; } | null; pendingReason?: string; @@ -65,6 +74,19 @@ function deriveMemberLaunchState(params: { return 'starting'; } +function preservesStrongRuntimeAlive(value: { + runtimeAlive?: boolean; + bootstrapConfirmed?: boolean; + livenessKind?: TeamAgentRuntimeLivenessKind; +}): boolean { + return ( + value.runtimeAlive === true && + (value.bootstrapConfirmed === true || + value.livenessKind === 'confirmed_bootstrap' || + value.livenessKind === 'runtime_process') + ); +} + function buildDiagnostics( member: Pick< PersistedTeamLaunchMemberState, @@ -93,14 +115,17 @@ function buildDiagnostics( } function createSourcesFromStatus( - status: Pick + status: Pick< + MemberSpawnStatusEntry, + 'livenessSource' | 'runtimeAlive' | 'bootstrapConfirmed' | 'livenessKind' + > ): PersistedTeamLaunchMemberSources | undefined { const sources: PersistedTeamLaunchMemberSources = {}; if (status.livenessSource === 'heartbeat') { sources.nativeHeartbeat = true; sources.inboxHeartbeat = true; } - if (status.livenessSource === 'process' || status.runtimeAlive) { + if (status.livenessSource === 'process' && preservesStrongRuntimeAlive(status)) { sources.processAlive = true; } return Object.values(sources).some(Boolean) ? sources : undefined; @@ -119,6 +144,7 @@ function createPrimaryLaneMemberState(params: { const providerId = normalizeOptionalTeamProviderId(params.member.providerId) ?? params.leadDefaults.providerId; const runtime = params.status; + const strongRuntimeAlive = preservesStrongRuntimeAlive(runtime ?? {}); const sources = runtime ? createSourcesFromStatus(runtime) : undefined; const base: PersistedTeamLaunchMemberState = { name: params.member.name.trim(), @@ -151,21 +177,25 @@ function createPrimaryLaneMemberState(params: { deriveMemberLaunchState({ hardFailure: runtime?.hardFailure, bootstrapConfirmed: runtime?.bootstrapConfirmed, - runtimeAlive: runtime?.runtimeAlive, + runtimeAlive: strongRuntimeAlive, agentToolAccepted: runtime?.agentToolAccepted, pendingPermissionRequestIds: runtime?.pendingPermissionRequestIds, }), agentToolAccepted: runtime?.agentToolAccepted === true, - runtimeAlive: runtime?.runtimeAlive === true, + runtimeAlive: strongRuntimeAlive, bootstrapConfirmed: runtime?.bootstrapConfirmed === true, hardFailure: runtime?.hardFailure === true || runtime?.launchState === 'failed_to_start', hardFailureReason: runtime?.hardFailureReason ?? runtime?.error, pendingPermissionRequestIds: runtime?.pendingPermissionRequestIds?.length ? [...new Set(runtime.pendingPermissionRequestIds)] : undefined, + livenessKind: runtime?.livenessKind, + runtimeDiagnostic: runtime?.runtimeDiagnostic, + runtimeDiagnosticSeverity: runtime?.runtimeDiagnosticSeverity, firstSpawnAcceptedAt: runtime?.firstSpawnAcceptedAt, lastHeartbeatAt: runtime?.lastHeartbeatAt, - lastRuntimeAliveAt: runtime?.runtimeAlive ? params.updatedAt : undefined, + runtimeLastSeenAt: runtime?.livenessLastCheckedAt, + lastRuntimeAliveAt: preservesStrongRuntimeAlive(runtime ?? {}) ? params.updatedAt : undefined, lastEvaluatedAt: runtime?.updatedAt ?? params.updatedAt, sources, diagnostics: undefined, @@ -180,13 +210,14 @@ function createSecondaryLaneMemberState( const providerId = normalizeOptionalTeamProviderId(params.member.providerId) ?? params.leadDefaults.providerId; const evidence = params.evidence; + const strongRuntimeAlive = preservesStrongRuntimeAlive(evidence ?? {}); const hardFailureReason = evidence?.hardFailureReason; const launchState = evidence?.launchState ?? deriveMemberLaunchState({ hardFailure: evidence?.hardFailure, bootstrapConfirmed: evidence?.bootstrapConfirmed, - runtimeAlive: evidence?.runtimeAlive, + runtimeAlive: strongRuntimeAlive, agentToolAccepted: evidence?.agentToolAccepted, pendingPermissionRequestIds: evidence?.pendingPermissionRequestIds, }); @@ -214,7 +245,7 @@ function createSecondaryLaneMemberState( laneOwnerProviderId: providerId, launchState, agentToolAccepted: evidence?.agentToolAccepted === true, - runtimeAlive: evidence?.runtimeAlive === true, + runtimeAlive: strongRuntimeAlive, bootstrapConfirmed: evidence?.bootstrapConfirmed === true, hardFailure: evidence?.hardFailure === true || launchState === 'failed_to_start', hardFailureReason, @@ -227,15 +258,21 @@ function createSecondaryLaneMemberState( evidence.runtimePid > 0 ? Math.trunc(evidence.runtimePid) : undefined, + runtimeSessionId: evidence?.runtimeSessionId ?? evidence?.sessionId, + livenessKind: evidence?.livenessKind, + pidSource: evidence?.pidSource, + runtimeDiagnostic: evidence?.runtimeDiagnostic, + runtimeDiagnosticSeverity: evidence?.runtimeDiagnosticSeverity, firstSpawnAcceptedAt: evidence?.agentToolAccepted ? params.updatedAt : undefined, lastHeartbeatAt: evidence?.bootstrapConfirmed ? params.updatedAt : undefined, - lastRuntimeAliveAt: evidence?.runtimeAlive ? params.updatedAt : undefined, + runtimeLastSeenAt: strongRuntimeAlive ? params.updatedAt : undefined, + lastRuntimeAliveAt: strongRuntimeAlive ? params.updatedAt : undefined, lastEvaluatedAt: params.updatedAt, - sources: evidence?.runtimeAlive + sources: strongRuntimeAlive ? { processAlive: true, - nativeHeartbeat: evidence.bootstrapConfirmed === true || undefined, - inboxHeartbeat: evidence.bootstrapConfirmed === true || undefined, + nativeHeartbeat: evidence?.bootstrapConfirmed === true || undefined, + inboxHeartbeat: evidence?.bootstrapConfirmed === true || undefined, } : undefined, diagnostics: evidence?.diagnostics?.length @@ -256,6 +293,11 @@ function summarizeMembers( let pendingCount = 0; let failedCount = 0; let runtimeAlivePendingCount = 0; + let shellOnlyPendingCount = 0; + let runtimeProcessPendingCount = 0; + let runtimeCandidatePendingCount = 0; + let noRuntimePendingCount = 0; + let permissionPendingCount = 0; for (const memberName of expectedMembers) { const entry = members[memberName]; @@ -275,6 +317,22 @@ function summarizeMembers( if (entry.runtimeAlive) { runtimeAlivePendingCount += 1; } + if (entry.launchState === 'runtime_pending_permission') { + permissionPendingCount += 1; + } + if (entry.livenessKind === 'shell_only') { + shellOnlyPendingCount += 1; + } else if (entry.livenessKind === 'runtime_process') { + runtimeProcessPendingCount += 1; + } else if (entry.livenessKind === 'runtime_process_candidate') { + runtimeCandidatePendingCount += 1; + } else if ( + entry.livenessKind === 'not_found' || + entry.livenessKind === 'stale_metadata' || + entry.livenessKind === 'registered_only' + ) { + noRuntimePendingCount += 1; + } } return { @@ -282,6 +340,11 @@ function summarizeMembers( pendingCount, failedCount, runtimeAlivePendingCount, + shellOnlyPendingCount, + runtimeProcessPendingCount, + runtimeCandidatePendingCount, + noRuntimePendingCount, + permissionPendingCount, }; } diff --git a/src/features/tmux-installer/main/composition/runtimeSupport.ts b/src/features/tmux-installer/main/composition/runtimeSupport.ts index 183b918b..0ae0c94e 100644 --- a/src/features/tmux-installer/main/composition/runtimeSupport.ts +++ b/src/features/tmux-installer/main/composition/runtimeSupport.ts @@ -1,5 +1,9 @@ import { TmuxStatusSourceAdapter } from '../adapters/output/sources/TmuxStatusSourceAdapter'; -import { TmuxPlatformCommandExecutor } from '../infrastructure/runtime/TmuxPlatformCommandExecutor'; +import { + type RuntimeProcessTableRow, + type TmuxPaneRuntimeInfo, + TmuxPlatformCommandExecutor, +} from '../infrastructure/runtime/TmuxPlatformCommandExecutor'; const runtimeStatusSource = new TmuxStatusSourceAdapter(); const runtimeCommandExecutor = new TmuxPlatformCommandExecutor(); @@ -24,6 +28,18 @@ export async function listTmuxPanePidsForCurrentPlatform( return runtimeCommandExecutor.listPanePids(paneIds); } +export async function listTmuxPaneRuntimeInfoForCurrentPlatform( + paneIds: readonly string[] +): Promise> { + return runtimeCommandExecutor.listPaneRuntimeInfo(paneIds); +} + +export async function listRuntimeProcessesForCurrentTmuxPlatform(): Promise< + RuntimeProcessTableRow[] +> { + return runtimeCommandExecutor.listRuntimeProcesses(); +} + export function killTmuxPaneForCurrentPlatformSync(paneId: string): void { runtimeCommandExecutor.killPaneSync(paneId); invalidateTmuxRuntimeStatusCache(); diff --git a/src/features/tmux-installer/main/index.ts b/src/features/tmux-installer/main/index.ts index 21c41e9f..d18d99ea 100644 --- a/src/features/tmux-installer/main/index.ts +++ b/src/features/tmux-installer/main/index.ts @@ -9,5 +9,12 @@ export { isTmuxRuntimeReadyForCurrentPlatform, killTmuxPaneForCurrentPlatform, killTmuxPaneForCurrentPlatformSync, + listRuntimeProcessesForCurrentTmuxPlatform, listTmuxPanePidsForCurrentPlatform, + listTmuxPaneRuntimeInfoForCurrentPlatform, } from './composition/runtimeSupport'; +export type { + RuntimeProcessTableRow, + TmuxPaneRuntimeInfo, +} from './infrastructure/runtime/TmuxPlatformCommandExecutor'; +export { parseRuntimeProcessTable } from './infrastructure/runtime/TmuxPlatformCommandExecutor'; diff --git a/src/features/tmux-installer/main/infrastructure/runtime/TmuxPlatformCommandExecutor.ts b/src/features/tmux-installer/main/infrastructure/runtime/TmuxPlatformCommandExecutor.ts index 4b062134..0500d252 100644 --- a/src/features/tmux-installer/main/infrastructure/runtime/TmuxPlatformCommandExecutor.ts +++ b/src/features/tmux-installer/main/infrastructure/runtime/TmuxPlatformCommandExecutor.ts @@ -12,6 +12,43 @@ interface ExecResult { stderr: string; } +export interface TmuxPaneRuntimeInfo { + paneId: string; + panePid: number; + currentCommand?: string; + currentPath?: string; + sessionName?: string; + windowName?: string; +} + +export interface RuntimeProcessTableRow { + pid: number; + ppid: number; + command: string; +} + +export function parseRuntimeProcessTable(output: string): RuntimeProcessTableRow[] { + const rows: RuntimeProcessTableRow[] = []; + for (const line of output.split('\n')) { + const match = /^\s*(\d+)\s+(\d+)\s+(.*)$/.exec(line); + if (!match) continue; + + const pid = Number.parseInt(match[1], 10); + const ppid = Number.parseInt(match[2], 10); + const command = match[3]?.trim() ?? ''; + if ( + Number.isFinite(pid) && + pid > 0 && + Number.isFinite(ppid) && + ppid >= 0 && + command.length > 0 + ) { + rows.push({ pid, ppid, command }); + } + } + return rows; +} + export class TmuxPlatformCommandExecutor { readonly #wslService: TmuxWslService; readonly #packageManagerResolver: TmuxPackageManagerResolver; @@ -54,34 +91,70 @@ export class TmuxPlatformCommandExecutor { } } - async listPanePids(paneIds: readonly string[]): Promise> { + async listPaneRuntimeInfo(paneIds: readonly string[]): Promise> { const normalizedPaneIds = [...new Set(paneIds.map((paneId) => paneId.trim()).filter(Boolean))]; if (normalizedPaneIds.length === 0) { return new Map(); } - const result = await this.execTmux( - ['list-panes', '-a', '-F', '#{pane_id}\t#{pane_pid}'], - 3_000 - ); + const format = [ + '#{pane_id}', + '#{pane_pid}', + '#{pane_current_command}', + '#{pane_current_path}', + '#{session_name}', + '#{window_name}', + ].join('\t'); + + const result = await this.execTmux(['list-panes', '-a', '-F', format], 3_000); if (result.exitCode !== 0) { throw new Error(result.stderr || 'Failed to list tmux panes'); } const wanted = new Set(normalizedPaneIds); - const panePidById = new Map(); + const paneInfoById = new Map(); for (const line of result.stdout.split('\n')) { const trimmed = line.trim(); if (!trimmed) continue; - const [paneId = '', rawPid = ''] = trimmed.split('\t'); + const [ + paneId = '', + rawPid = '', + currentCommand = '', + currentPath = '', + sessionName = '', + windowName = '', + ] = trimmed.split('\t'); const normalizedPaneId = paneId.trim(); if (!wanted.has(normalizedPaneId)) continue; const pid = Number.parseInt(rawPid.trim(), 10); if (Number.isFinite(pid) && pid > 0) { - panePidById.set(normalizedPaneId, pid); + paneInfoById.set(normalizedPaneId, { + paneId: normalizedPaneId, + panePid: pid, + currentCommand: currentCommand.trim() || undefined, + currentPath: currentPath.trim() || undefined, + sessionName: sessionName.trim() || undefined, + windowName: windowName.trim() || undefined, + }); } } - return panePidById; + return paneInfoById; + } + + async listPanePids(paneIds: readonly string[]): Promise> { + const info = await this.listPaneRuntimeInfo(paneIds); + return new Map([...info.entries()].map(([paneId, pane]) => [paneId, pane.panePid])); + } + + async listRuntimeProcesses(): Promise { + const result = + process.platform === 'win32' + ? await this.#wslService.execInPreferredDistro(['ps', '-ax', '-o', 'pid=,ppid=,command=']) + : await this.#execNativePs(); + if (result.exitCode !== 0) { + throw new Error(result.stderr || 'Failed to list runtime processes'); + } + return parseRuntimeProcessTable(result.stdout); } killPaneSync(paneId: string): void { @@ -125,6 +198,29 @@ export class TmuxPlatformCommandExecutor { return [...candidates]; } + async #execNativePs(): Promise { + await resolveInteractiveShellEnv(); + const env = buildEnrichedEnv(); + return new Promise((resolve) => { + execFile( + 'ps', + ['-ax', '-o', 'pid=,ppid=,command='], + { env, timeout: 3_000, maxBuffer: 2 * 1024 * 1024 }, + (error, stdout, stderr) => { + const errorCode = + typeof error === 'object' && error !== null && 'code' in error + ? (error as NodeJS.ErrnoException).code + : undefined; + resolve({ + exitCode: typeof errorCode === 'number' ? errorCode : error ? 1 : 0, + stdout: String(stdout), + stderr: String(stderr) || (error instanceof Error ? error.message : ''), + }); + } + ); + }); + } + async #resolveNativeTmuxExecutable(env: NodeJS.ProcessEnv): Promise { const platform = process.platform === 'darwin' || process.platform === 'linux' || process.platform === 'win32' diff --git a/src/features/tmux-installer/main/infrastructure/runtime/__tests__/TmuxPlatformCommandExecutor.test.ts b/src/features/tmux-installer/main/infrastructure/runtime/__tests__/TmuxPlatformCommandExecutor.test.ts index 376afc79..ded52232 100644 --- a/src/features/tmux-installer/main/infrastructure/runtime/__tests__/TmuxPlatformCommandExecutor.test.ts +++ b/src/features/tmux-installer/main/infrastructure/runtime/__tests__/TmuxPlatformCommandExecutor.test.ts @@ -78,7 +78,8 @@ describe('TmuxPlatformCommandExecutor', () => { ); vi.spyOn(executor, 'execTmux').mockResolvedValue({ exitCode: 0, - stdout: '%1\t111\n%2\t222\n%3\tnot-a-pid\n', + stdout: + '%1\t111\tzsh\t/tmp\tteam\tmain\n%2\t222\tnode\t/project\tteam\tworker\n%3\tnot-a-pid\tzsh\t/tmp\tteam\tmain\n', stderr: '', }); @@ -86,8 +87,35 @@ describe('TmuxPlatformCommandExecutor', () => { new Map([['%2', 222]]) ); expect(executor.execTmux).toHaveBeenCalledWith( - ['list-panes', '-a', '-F', '#{pane_id}\t#{pane_pid}'], + [ + 'list-panes', + '-a', + '-F', + '#{pane_id}\t#{pane_pid}\t#{pane_current_command}\t#{pane_current_path}\t#{session_name}\t#{window_name}', + ], 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(); + }); }); diff --git a/src/features/tmux-installer/main/infrastructure/wsl/TmuxWslService.ts b/src/features/tmux-installer/main/infrastructure/wsl/TmuxWslService.ts index 335a5e69..84cc73a1 100644 --- a/src/features/tmux-installer/main/infrastructure/wsl/TmuxWslService.ts +++ b/src/features/tmux-installer/main/infrastructure/wsl/TmuxWslService.ts @@ -268,6 +268,23 @@ export class TmuxWslService { return this.#run(['-d', distroName, '-e', 'tmux', ...args], timeout); } + async execInPreferredDistro( + args: string[], + preferredDistroName?: string | null, + timeout = 5_000 + ): Promise { + const distroName = preferredDistroName ?? (await this.probe()).preference?.preferredDistroName; + if (!distroName) { + return { + exitCode: 1, + stdout: '', + stderr: 'No WSL distribution is available.', + }; + } + + return this.#run(['-d', distroName, '-e', ...args], timeout); + } + getPersistedPreferredDistroSync(): string | null { return this.#preferenceStore.getPreferredDistroSync(); } diff --git a/src/main/index.ts b/src/main/index.ts index 1613889d..d7ef63f1 100644 --- a/src/main/index.ts +++ b/src/main/index.ts @@ -17,8 +17,6 @@ process.env.UV_THREADPOOL_SIZE ??= '16'; // Keep userData stable before any integration can initialize Electron storage. -import { earlyElectronUserDataMigrationResult } from './bootstrapUserDataMigration'; - // Sentry must stay near the top to capture early errors after storage migration. import './sentry'; @@ -118,9 +116,6 @@ import { OpenCodeBridgeCommandHandshakePort, } from './services/team/opencode/bridge/OpenCodeBridgeHandshakeClient'; import { OpenCodeStateChangingBridgeCommandService } from './services/team/opencode/bridge/OpenCodeStateChangingBridgeCommandService'; -import { resolveOpenCodeTeamLaunchModeFromEnv } from './services/team/opencode/config/OpenCodeLaunchModeEnv'; -import { resolveOpenCodeProductionE2EEvidencePath } from './services/team/opencode/e2e/OpenCodeProductionE2EEvidencePath'; -import { OpenCodeProductionE2EEvidenceStore } from './services/team/opencode/e2e/OpenCodeProductionE2EEvidenceStore'; import { OpenCodeRuntimeManifestEvidenceReader } from './services/team/opencode/store/OpenCodeRuntimeManifestEvidenceReader'; import { buildTeamControlApiBaseUrl, @@ -147,6 +142,7 @@ import { markRendererUnavailable, safeSendToRenderer, } from './utils/safeWebContentsSend'; +import { earlyElectronUserDataMigrationResult } from './bootstrapUserDataMigration'; import { syncTelemetryFlag } from './sentry'; import { ActiveTeamRegistry, @@ -280,13 +276,7 @@ async function createOpenCodeRuntimeAdapterRegistry(): Promise> { + const validatedTeamName = validateTeamName(teamName); + if (!validatedTeamName.valid) { + return { success: false, error: validatedTeamName.error ?? 'Invalid teamName' }; + } + const validatedMemberName = validateMemberName(memberName); + if (!validatedMemberName.valid) { + return { success: false, error: validatedMemberName.error ?? 'Invalid memberName' }; + } + return wrapTeamHandler('skipMemberForLaunch', async () => + getTeamProvisioningService().skipMemberForLaunch( + validatedTeamName.value!, + validatedMemberName.value! + ) + ); +} + async function handleStopTeam( _event: IpcMainInvokeEvent, teamName: unknown diff --git a/src/main/services/infrastructure/ConfigManager.ts b/src/main/services/infrastructure/ConfigManager.ts index 332a3616..9e0d1b04 100644 --- a/src/main/services/infrastructure/ConfigManager.ts +++ b/src/main/services/infrastructure/ConfigManager.ts @@ -570,6 +570,7 @@ export class ConfigManager { ...DEFAULT_CONFIG.general, ...(loaded.general ?? {}), }; + mergedGeneral.multimodelEnabled = true; mergedGeneral.claudeRootPath = normalizeConfiguredClaudeRootPath(mergedGeneral.claudeRootPath); // Merge triggers: preserve existing triggers, add missing builtin ones diff --git a/src/main/services/infrastructure/FileWatcher.ts b/src/main/services/infrastructure/FileWatcher.ts index 30dcd444..1146cf47 100644 --- a/src/main/services/infrastructure/FileWatcher.ts +++ b/src/main/services/infrastructure/FileWatcher.ts @@ -52,6 +52,8 @@ const CATCH_UP_SESSION_RETENTION_MS = 20 * 60 * 1000; // 20 minutes const CATCH_UP_SUBAGENT_RETENTION_MS = 5 * 60 * 1000; // 5 minutes /** Bound best-effort catch-up work per tick so it cannot monopolize the event loop. */ const CATCH_UP_SCAN_BUDGET = 24; +/** Retire one file from catch-up after repeated local stat timeouts. */ +const CATCH_UP_STAT_TIMEOUT_RETIRE_COUNT = 3; interface AppendedParseResult { messages: ParsedMessage[]; @@ -93,6 +95,8 @@ export class FileWatcher extends EventEmitter { private catchUpInProgress = false; /** Round-robin cursor so catch-up work is spread across tracked files. */ private catchUpCursor = 0; + /** Consecutive catch-up stat timeouts per file. */ + private catchUpStatFailures = new Map(); /** Timer for SSH polling mode (replaces fs.watch) */ private pollingTimer: NodeJS.Timeout | null = null; /** Polling interval for SSH mode */ @@ -232,6 +236,7 @@ export class FileWatcher extends EventEmitter { this.lastProcessedLineCount.clear(); this.lastProcessedSize.clear(); this.activeSessionFiles.clear(); + this.catchUpStatFailures.clear(); this.processingInProgress.clear(); this.pendingReprocess.clear(); @@ -284,6 +289,7 @@ export class FileWatcher extends EventEmitter { this.lastProcessedLineCount.clear(); this.lastProcessedSize.clear(); this.activeSessionFiles.clear(); + this.catchUpStatFailures.clear(); this.polledFileSizes.clear(); this.processingInProgress.clear(); this.pendingReprocess.clear(); @@ -867,6 +873,7 @@ export class FileWatcher extends EventEmitter { this.lastProcessedLineCount.delete(filePath); this.lastProcessedSize.delete(filePath); this.activeSessionFiles.delete(filePath); + this.catchUpStatFailures.delete(filePath); } /** @@ -876,6 +883,7 @@ export class FileWatcher extends EventEmitter { this.lastProcessedLineCount.clear(); this.lastProcessedSize.clear(); this.activeSessionFiles.clear(); + this.catchUpStatFailures.clear(); this.catchUpCursor = 0; this.catchUpInProgress = false; } @@ -1119,6 +1127,7 @@ export class FileWatcher extends EventEmitter { } const stats = await this.fsProvider.stat(filePath); + this.catchUpStatFailures.delete(filePath); // Skip files not modified recently if (now - stats.mtimeMs > CATCH_UP_MAX_AGE_MS) { @@ -1145,6 +1154,8 @@ export class FileWatcher extends EventEmitter { // File may have been deleted between iterations if ((err as NodeJS.ErrnoException).code === 'ENOENT') { this.clearErrorTracking(filePath); + } else if (this.isStatTimeoutError(err)) { + this.handleCatchUpStatTimeout(filePath); } else { logger.error(`FileWatcher: Error during catch-up stat for ${filePath}:`, err); } @@ -1156,6 +1167,32 @@ export class FileWatcher extends EventEmitter { } } + private isStatTimeoutError(err: unknown): boolean { + return err instanceof Error && err.message === 'stat timeout'; + } + + private handleCatchUpStatTimeout(filePath: string): void { + const failures = (this.catchUpStatFailures.get(filePath) ?? 0) + 1; + + if (failures >= CATCH_UP_STAT_TIMEOUT_RETIRE_COUNT) { + logger.warn( + `FileWatcher: Retiring ${filePath} from catch-up after ${failures} stat timeouts` + ); + this.retireCatchUpFile(filePath); + return; + } + + this.catchUpStatFailures.set(filePath, failures); + logger.debug( + `FileWatcher: Catch-up stat timeout for ${filePath} (${failures}/${CATCH_UP_STAT_TIMEOUT_RETIRE_COUNT})` + ); + } + + private retireCatchUpFile(filePath: string): void { + this.activeSessionFiles.delete(filePath); + this.catchUpStatFailures.delete(filePath); + } + // =========================================================================== // Debouncing // =========================================================================== diff --git a/src/main/services/infrastructure/codexAppServer/JsonRpcStdioClient.ts b/src/main/services/infrastructure/codexAppServer/JsonRpcStdioClient.ts index fd7d29a6..5b09a217 100644 --- a/src/main/services/infrastructure/codexAppServer/JsonRpcStdioClient.ts +++ b/src/main/services/infrastructure/codexAppServer/JsonRpcStdioClient.ts @@ -1,4 +1,3 @@ -import { once } from 'node:events'; import readline from 'node:readline'; import { killProcessTree, spawnCli } from '@main/utils/childProcess'; @@ -49,6 +48,9 @@ function withTimeout(promise: Promise, timeoutMs: number, label: string): const DEFAULT_REQUEST_TIMEOUT_MS = 3_000; const DEFAULT_TOTAL_TIMEOUT_MS = 8_000; +const DEFAULT_STDIN_CLOSE_TIMEOUT_MS = 250; +const DEFAULT_CLOSE_TIMEOUT_MS = 1_000; +const DEFAULT_FORCE_CLOSE_TIMEOUT_MS = 1_000; export class JsonRpcRequestError extends Error { readonly code: number | null; @@ -76,6 +78,9 @@ export class JsonRpcStdioClient { env?: NodeJS.ProcessEnv; requestTimeoutMs?: number; totalTimeoutMs?: number; + stdinCloseTimeoutMs?: number; + closeTimeoutMs?: number; + forceCloseTimeoutMs?: number; label: string; }, handler: (session: JsonRpcSession) => Promise @@ -95,8 +100,14 @@ export class JsonRpcStdioClient { args: string[]; env?: NodeJS.ProcessEnv; requestTimeoutMs?: number; + stdinCloseTimeoutMs?: number; + closeTimeoutMs?: number; + forceCloseTimeoutMs?: number; }): Promise { const requestTimeoutMs = options.requestTimeoutMs ?? DEFAULT_REQUEST_TIMEOUT_MS; + const stdinCloseTimeoutMs = options.stdinCloseTimeoutMs ?? DEFAULT_STDIN_CLOSE_TIMEOUT_MS; + const closeTimeoutMs = options.closeTimeoutMs ?? DEFAULT_CLOSE_TIMEOUT_MS; + const forceCloseTimeoutMs = options.forceCloseTimeoutMs ?? DEFAULT_FORCE_CLOSE_TIMEOUT_MS; const child = spawnCli(options.binaryPath, options.args, { env: options.env, stdio: ['pipe', 'pipe', 'pipe'], @@ -194,6 +205,58 @@ export class JsonRpcStdioClient { ); }); + const waitForChildClose = (timeoutMs: number): Promise => { + if (child.exitCode !== null || child.signalCode !== null) { + return Promise.resolve(true); + } + + return new Promise((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 => { + if (!child.stdin || child.stdin.destroyed || child.stdin.writableEnded) { + return; + } + + await new Promise((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 => { if (closed) { return; @@ -204,21 +267,26 @@ export class JsonRpcStdioClient { notificationListeners.clear(); lineReader.close(); - if (child.stdin && !child.stdin.destroyed && !child.stdin.writableEnded) { - await new Promise((resolve) => { - try { - child.stdin!.end(() => resolve()); - } catch { - resolve(); - } - }); + await closeStdin(); + + const gracefulClose = waitForChildClose(closeTimeoutMs); + killProcessTree(child, 'SIGTERM'); + if (await gracefulClose) { + return; } - killProcessTree(child); - try { - await once(child, 'close'); - } catch { - this.logger.warn('json-rpc close wait failed'); + this.logger.warn('json-rpc close timed out; force killing process', { + pid: child.pid, + timeoutMs: closeTimeoutMs, + }); + + const forcedClose = waitForChildClose(forceCloseTimeoutMs); + killProcessTree(child, 'SIGKILL'); + if (!(await forcedClose)) { + this.logger.warn('json-rpc force close timed out', { + pid: child.pid, + timeoutMs: forceCloseTimeoutMs, + }); } }; diff --git a/src/main/services/infrastructure/codexAppServer/__tests__/JsonRpcStdioClient.test.ts b/src/main/services/infrastructure/codexAppServer/__tests__/JsonRpcStdioClient.test.ts index 5e6e308b..cabac893 100644 --- a/src/main/services/infrastructure/codexAppServer/__tests__/JsonRpcStdioClient.test.ts +++ b/src/main/services/infrastructure/codexAppServer/__tests__/JsonRpcStdioClient.test.ts @@ -2,7 +2,7 @@ import fs from 'node:fs'; import os from 'node:os'; import path from 'node:path'; -import { afterEach, describe, expect, it } from 'vitest'; +import { afterEach, describe, expect, it, vi } from 'vitest'; import { JsonRpcStdioClient } from '../JsonRpcStdioClient'; @@ -42,6 +42,35 @@ rl.on('line', (line) => { return scriptPath; } +function createSignalIgnoringJsonRpcServerScript(): string { + const tempDir = fs.mkdtempSync(path.join(os.tmpdir(), 'json-rpc-stdio-client-')); + tempDirs.push(tempDir); + const scriptPath = path.join(tempDir, 'server.cjs'); + fs.writeFileSync( + scriptPath, + ` +const readline = require('node:readline'); +process.on('SIGTERM', () => {}); +setInterval(() => {}, 10_000); + +const rl = readline.createInterface({ input: process.stdin }); +rl.on('line', (line) => { + const message = JSON.parse(line); + if (message.jsonrpc !== '2.0') { + return; + } + process.stdout.write(JSON.stringify({ + jsonrpc: '2.0', + id: message.id, + result: { ok: true }, + }) + '\\n'); +}); +`, + 'utf8' + ); + return scriptPath; +} + afterEach(() => { for (const dir of tempDirs.splice(0)) { fs.rmSync(dir, { recursive: true, force: true }); @@ -76,4 +105,30 @@ describe('JsonRpcStdioClient', () => { } ); }); + + it('force kills the child when session close does not finish gracefully', async () => { + const scriptPath = createSignalIgnoringJsonRpcServerScript(); + const warn = vi.fn(); + const client = new JsonRpcStdioClient({ warn }); + + await client.withSession( + { + binaryPath: process.execPath, + args: [scriptPath], + label: 'stubborn json-rpc close', + requestTimeoutMs: 1_000, + totalTimeoutMs: 2_000, + closeTimeoutMs: 25, + forceCloseTimeoutMs: 1_000, + }, + async (session) => { + await expect(session.request('ping')).resolves.toEqual({ ok: true }); + } + ); + + expect(warn).toHaveBeenCalledWith('json-rpc close timed out; force killing process', { + pid: expect.any(Number), + timeoutMs: 25, + }); + }); }); diff --git a/src/main/services/team/TeamBootstrapStateReader.ts b/src/main/services/team/TeamBootstrapStateReader.ts index 4937e3ba..66144d3d 100644 --- a/src/main/services/team/TeamBootstrapStateReader.ts +++ b/src/main/services/team/TeamBootstrapStateReader.ts @@ -337,10 +337,11 @@ function normalizeBootstrapMemberState( const status = typeof raw.status === 'string' ? raw.status : 'pending'; const hardFailure = status === 'failed'; const bootstrapConfirmed = status === 'bootstrap_confirmed'; - const runtimeAlive = bootstrapConfirmed || status === 'runtime_alive'; + const bootstrapReportedRuntimeAlive = status === 'runtime_alive'; + const runtimeAlive = bootstrapConfirmed; const agentToolAccepted = bootstrapConfirmed || - runtimeAlive || + bootstrapReportedRuntimeAlive || status === 'registered' || status === 'spawn_started' || hardFailure; @@ -351,7 +352,7 @@ function normalizeBootstrapMemberState( ? 'failed_to_start' : bootstrapConfirmed ? 'confirmed_alive' - : runtimeAlive || agentToolAccepted + : agentToolAccepted ? 'runtime_pending_bootstrap' : 'starting', agentToolAccepted, @@ -381,13 +382,13 @@ function normalizeBootstrapMemberState( ? raw.failureReason.trim() : 'deterministic bootstrap failed', ] - : runtimeAlive - ? bootstrapConfirmed - ? ['late heartbeat received'] - : ['runtime alive', 'waiting for teammate check-in'] - : agentToolAccepted - ? ['spawn accepted'] - : undefined, + : bootstrapConfirmed + ? ['late heartbeat received'] + : bootstrapReportedRuntimeAlive + ? ['runtime alive reported by bootstrap state', 'waiting for strict live verification'] + : agentToolAccepted + ? ['spawn accepted'] + : undefined, }; } diff --git a/src/main/services/team/TeamDataService.ts b/src/main/services/team/TeamDataService.ts index 5341ee36..3c2d8cef 100644 --- a/src/main/services/team/TeamDataService.ts +++ b/src/main/services/team/TeamDataService.ts @@ -14,7 +14,7 @@ import { classifyIdleNotificationText } from '@shared/utils/idleNotificationSema import { isLeadMember } from '@shared/utils/leadDetection'; import { createLogger } from '@shared/utils/logger'; import { migrateProviderBackendId } from '@shared/utils/providerBackend'; -import { getKanbanColumnFromReviewState, normalizeReviewState } from '@shared/utils/reviewState'; +import { getKanbanColumnFromReviewState, getReviewStateFromTask } from '@shared/utils/reviewState'; import { buildStandaloneSlashCommandMeta } from '@shared/utils/slashCommands'; import { formatTaskDisplayLabel } from '@shared/utils/taskIdentity'; import { buildTeamMemberColorMap } from '@shared/utils/teamMemberColors'; @@ -265,6 +265,11 @@ function extractPassiveUserPeerSummaryBody(text: string): string | null { return body.length > 0 ? body : null; } +function isExplicitLeadRole(role: string | undefined): boolean { + const normalized = role?.trim().toLowerCase(); + return normalized === 'lead' || normalized === 'team lead' || normalized === 'team-lead'; +} + function hasVisibleLeadMember(members: readonly TeamMemberSnapshot[]): boolean { return members.some((member) => { if (isLeadMember(member)) { @@ -274,7 +279,7 @@ function hasVisibleLeadMember(members: readonly TeamMemberSnapshot[]): boolean { if (normalizedName === 'lead') { return true; } - return member.role?.toLowerCase().includes('lead') === true; + return isExplicitLeadRole(member.role); }); } @@ -287,7 +292,7 @@ function hasExplicitLeadInConfig(config: TeamConfig): boolean { if (normalizedName === 'lead') { return true; } - return member.role?.toLowerCase().includes('lead') === true; + return isExplicitLeadRole(member.role); }); } @@ -530,16 +535,22 @@ export class TeamDataService { } private resolveTaskReviewState( - task: Pick + task: Pick, + kanbanTaskState?: KanbanState['tasks'][string] ): 'none' | 'review' | 'needsFix' | 'approved' { - return normalizeReviewState(task.reviewState); + return getReviewStateFromTask({ + historyEvents: task.historyEvents, + reviewState: task.reviewState, + status: task.status, + kanbanColumn: kanbanTaskState?.column, + }); } private attachKanbanCompatibility( task: TeamTask, kanbanTaskState?: KanbanState['tasks'][string] ): TeamTaskWithKanban { - const reviewState = this.resolveTaskReviewState(task); + const reviewState = this.resolveTaskReviewState(task, kanbanTaskState); const reviewer = this.resolveReviewerFromHistory(task, kanbanTaskState, reviewState) ?? null; return { ...task, @@ -557,8 +568,15 @@ export class TeamDataService { private resolveReviewerFromHistory( task: TeamTask, kanbanTaskState?: KanbanState['tasks'][string], - reviewState: 'none' | 'review' | 'needsFix' | 'approved' = this.resolveTaskReviewState(task) + reviewState: 'none' | 'review' | 'needsFix' | 'approved' = this.resolveTaskReviewState( + task, + kanbanTaskState + ) ): string | null { + if (reviewState !== 'review') { + return null; + } + if (task.historyEvents?.length) { for (let i = task.historyEvents.length - 1; i >= 0; i--) { const event = task.historyEvents[i]; @@ -571,7 +589,10 @@ export class TeamDataService { if (event.type === 'review_approved' || event.type === 'review_changes_requested') { break; } - if (event.type === 'status_changed' && event.to === 'in_progress') { + if ( + event.type === 'status_changed' && + (event.to === 'in_progress' || event.to === 'pending' || event.to === 'deleted') + ) { break; } if (event.type === 'task_created') { @@ -894,7 +915,8 @@ export class TeamDataService { continue; } const info = teamInfoMap.get(task.teamName)!; - const reviewState = this.resolveTaskReviewState(task); + const kanbanTaskState = kanbanByTeam.get(task.teamName)?.tasks[task.id]; + const reviewState = this.resolveTaskReviewState(task, kanbanTaskState); const kanbanColumn = getKanbanColumnFromReviewState(reviewState); // IPC payload safety: GlobalTask lists can be enormous (especially comments and large nested fields). @@ -2158,7 +2180,11 @@ export class TeamDataService { private resolveLeadNameFromConfig(config: TeamConfig | null): string { if (!config) return 'team-lead'; - const lead = config.members?.find((m) => m.role?.toLowerCase().includes('lead')); + const members = config.members ?? []; + const lead = + members.find((member) => isLeadMember(member)) ?? + members.find((member) => member.name?.trim().toLowerCase() === 'lead') ?? + members.find((member) => isExplicitLeadRole(member.role)); return lead?.name ?? config.members?.[0]?.name ?? 'team-lead'; } @@ -2731,9 +2757,9 @@ export class TeamDataService { } async requestReview(teamName: string, taskId: string): Promise { - const { leadSessionId } = await this.resolveLeadRuntimeContext(teamName); + const { leadName, leadSessionId } = await this.resolveLeadRuntimeContext(teamName); this.getController(teamName).review.requestReview(taskId, { - from: 'user', + from: leadName, ...(leadSessionId ? { leadSessionId } : {}), }); } @@ -3196,15 +3222,15 @@ export class TeamDataService { if (patch.op === 'set_column') { if (patch.column === 'review') { - const { leadSessionId } = await this.resolveLeadRuntimeContext(teamName); + const { leadName, leadSessionId } = await this.resolveLeadRuntimeContext(teamName); controller.review.requestReview(taskId, { - from: 'user', + from: leadName, ...(leadSessionId ? { leadSessionId } : {}), }); } else { - const { leadSessionId } = await this.resolveLeadRuntimeContext(teamName); + const { leadName, leadSessionId } = await this.resolveLeadRuntimeContext(teamName); controller.review.approveReview(taskId, { - from: 'user', + from: leadName, suppressTaskComment: true, 'notify-owner': true, ...(leadSessionId ? { leadSessionId } : {}), @@ -3213,9 +3239,9 @@ export class TeamDataService { return; } - const { leadSessionId } = await this.resolveLeadRuntimeContext(teamName); + const { leadName, leadSessionId } = await this.resolveLeadRuntimeContext(teamName); controller.review.requestChanges(taskId, { - from: 'user', + from: leadName, comment: patch.comment?.trim() || 'Reviewer requested changes.', ...(patch.op === 'request_changes' && patch.taskRefs?.length ? { taskRefs: patch.taskRefs } diff --git a/src/main/services/team/TeamLaunchStateEvaluator.ts b/src/main/services/team/TeamLaunchStateEvaluator.ts index 6171c037..a6f65031 100644 --- a/src/main/services/team/TeamLaunchStateEvaluator.ts +++ b/src/main/services/team/TeamLaunchStateEvaluator.ts @@ -12,6 +12,9 @@ import type { PersistedTeamLaunchSnapshot, PersistedTeamLaunchSummary, ProviderModelLaunchIdentity, + TeamAgentRuntimeDiagnosticSeverity, + TeamAgentRuntimeLivenessKind, + TeamAgentRuntimePidSource, TeamLaunchAggregateState, } from '@shared/types'; @@ -36,9 +39,17 @@ type RuntimeMemberSpawnState = Pick< | 'runtimeAlive' | 'bootstrapConfirmed' | 'hardFailure' + | 'skippedForLaunch' + | 'skipReason' + | 'skippedAt' | 'pendingPermissionRequestIds' + | 'livenessKind' + | 'runtimeDiagnostic' + | 'runtimeDiagnosticSeverity' + | 'livenessLastCheckedAt' | 'firstSpawnAcceptedAt' | 'lastHeartbeatAt' + | 'runtimeModel' | 'updatedAt' >; @@ -59,6 +70,58 @@ function normalizeRuntimePid(value: unknown): number | undefined { : undefined; } +function normalizeLivenessKind(value: unknown): TeamAgentRuntimeLivenessKind | undefined { + return value === 'confirmed_bootstrap' || + value === 'runtime_process' || + value === 'runtime_process_candidate' || + value === 'permission_blocked' || + value === 'shell_only' || + value === 'registered_only' || + value === 'stale_metadata' || + value === 'not_found' + ? value + : undefined; +} + +function preservesStrongRuntimeAlive( + value: + | { + runtimeAlive?: boolean; + bootstrapConfirmed?: boolean; + livenessKind?: TeamAgentRuntimeLivenessKind; + } + | undefined +): boolean { + return ( + value?.runtimeAlive === true && + (value.bootstrapConfirmed === true || + value.livenessKind === 'confirmed_bootstrap' || + value.livenessKind === 'runtime_process') + ); +} + +function normalizePidSource(value: unknown): TeamAgentRuntimePidSource | undefined { + return value === 'lead_process' || + value === 'tmux_pane' || + value === 'tmux_child' || + value === 'agent_process_table' || + value === 'opencode_bridge' || + value === 'runtime_bootstrap' || + value === 'persisted_metadata' + ? value + : undefined; +} + +function normalizeDiagnosticSeverity( + value: unknown +): TeamAgentRuntimeDiagnosticSeverity | undefined { + return value === 'info' || value === 'warning' || value === 'error' ? value : undefined; +} + +function normalizeOptionalString(value: unknown): string | undefined { + return typeof value === 'string' && value.trim().length > 0 ? value.trim() : undefined; +} + function normalizeMemberName(name: string): string { return name.trim(); } @@ -70,6 +133,8 @@ function buildDiagnostics( | 'runtimeAlive' | 'bootstrapConfirmed' | 'hardFailureReason' + | 'skippedForLaunch' + | 'skipReason' | 'sources' | 'pendingPermissionRequestIds' > @@ -85,6 +150,13 @@ function buildDiagnostics( } if (member.hardFailureReason) diagnostics.push(`hard failure reason: ${member.hardFailureReason}`); + if (member.skippedForLaunch) { + diagnostics.push( + member.skipReason + ? `skipped for this launch: ${member.skipReason}` + : 'skipped for this launch' + ); + } if (member.sources?.duplicateRespawnBlocked) diagnostics.push('respawn blocked as duplicate'); if (member.sources?.configDrift) diagnostics.push('config drift detected'); return diagnostics; @@ -99,6 +171,9 @@ export function deriveTeamLaunchAggregateState( if (summary.pendingCount > 0) { return 'partial_pending'; } + if ((summary.skippedCount ?? 0) > 0) { + return 'partial_skipped'; + } return 'clean_success'; } @@ -109,7 +184,13 @@ export function summarizePersistedLaunchMembers( let confirmedCount = 0; let pendingCount = 0; let failedCount = 0; + let skippedCount = 0; let runtimeAlivePendingCount = 0; + let shellOnlyPendingCount = 0; + let runtimeProcessPendingCount = 0; + let runtimeCandidatePendingCount = 0; + let noRuntimePendingCount = 0; + let permissionPendingCount = 0; const normalizedExpected = expectedMembers.map(normalizeMemberName).filter(Boolean); const memberNames = Array.from( new Set([ @@ -128,17 +209,48 @@ export function summarizePersistedLaunchMembers( confirmedCount += 1; continue; } + if (entry.launchState === 'skipped_for_launch' || entry.skippedForLaunch === true) { + skippedCount += 1; + continue; + } if (entry.launchState === 'failed_to_start') { failedCount += 1; continue; } pendingCount += 1; - if (entry.runtimeAlive) { + if (preservesStrongRuntimeAlive(entry)) { 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 { confirmedCount, pendingCount, failedCount, runtimeAlivePendingCount }; + return { + confirmedCount, + pendingCount, + failedCount, + skippedCount, + runtimeAlivePendingCount, + shellOnlyPendingCount, + runtimeProcessPendingCount, + runtimeCandidatePendingCount, + noRuntimePendingCount, + permissionPendingCount, + }; } export function hasMixedPersistedLaunchMetadata( @@ -169,9 +281,13 @@ function deriveMemberLaunchState( | 'bootstrapConfirmed' | 'runtimeAlive' | 'agentToolAccepted' + | 'skippedForLaunch' | 'pendingPermissionRequestIds' > ): MemberLaunchState { + if (member.skippedForLaunch) { + return 'skipped_for_launch'; + } if (member.hardFailure) { return 'failed_to_start'; } @@ -299,6 +415,23 @@ function normalizePersistedMemberState( return null; } const providerId = normalizeOptionalTeamProviderId(parsed.providerId); + const skippedForLaunch = + toBoolean(parsed.skippedForLaunch) || parsed.launchState === 'skipped_for_launch'; + const bootstrapConfirmed = + !skippedForLaunch && + (toBoolean(parsed.bootstrapConfirmed) || parsed.launchState === 'confirmed_alive'); + const livenessKind = normalizeLivenessKind(parsed.livenessKind); + const runtimeAlive = skippedForLaunch + ? false + : preservesStrongRuntimeAlive({ + runtimeAlive: toBoolean(parsed.runtimeAlive), + bootstrapConfirmed, + livenessKind, + }); + const sources = normalizeSources(parsed.sources) ?? {}; + if (!runtimeAlive) { + sources.processAlive = undefined; + } const next: PersistedTeamLaunchMemberState = { name: normalizedName, providerId, @@ -328,18 +461,28 @@ function normalizePersistedMemberState( laneOwnerProviderId: normalizeOptionalTeamProviderId(parsed.laneOwnerProviderId), launchIdentity: normalizeLaunchIdentity(parsed.launchIdentity, providerId), launchState: 'starting', - agentToolAccepted: toBoolean(parsed.agentToolAccepted), - runtimeAlive: toBoolean(parsed.runtimeAlive), - bootstrapConfirmed: toBoolean(parsed.bootstrapConfirmed), - hardFailure: toBoolean(parsed.hardFailure), - hardFailureReason: - typeof parsed.hardFailureReason === 'string' && parsed.hardFailureReason.trim().length > 0 + skippedForLaunch, + skipReason: normalizeOptionalString(parsed.skipReason), + skippedAt: normalizeOptionalString(parsed.skippedAt), + agentToolAccepted: skippedForLaunch ? false : toBoolean(parsed.agentToolAccepted), + runtimeAlive, + bootstrapConfirmed, + hardFailure: skippedForLaunch ? false : toBoolean(parsed.hardFailure), + hardFailureReason: skippedForLaunch + ? undefined + : typeof parsed.hardFailureReason === 'string' && parsed.hardFailureReason.trim().length > 0 ? parsed.hardFailureReason.trim() : undefined, pendingPermissionRequestIds: normalizePendingPermissionRequestIds( parsed.pendingPermissionRequestIds ), runtimePid: normalizeRuntimePid(parsed.runtimePid), + runtimeSessionId: normalizeOptionalString(parsed.runtimeSessionId), + livenessKind, + pidSource: normalizePidSource(parsed.pidSource), + runtimeDiagnostic: normalizeOptionalString(parsed.runtimeDiagnostic), + runtimeDiagnosticSeverity: normalizeDiagnosticSeverity(parsed.runtimeDiagnosticSeverity), + runtimeLastSeenAt: normalizeOptionalString(parsed.runtimeLastSeenAt), firstSpawnAcceptedAt: typeof parsed.firstSpawnAcceptedAt === 'string' ? parsed.firstSpawnAcceptedAt : undefined, lastHeartbeatAt: @@ -348,7 +491,7 @@ function normalizePersistedMemberState( typeof parsed.lastRuntimeAliveAt === 'string' ? parsed.lastRuntimeAliveAt : undefined, lastEvaluatedAt: typeof parsed.lastEvaluatedAt === 'string' ? parsed.lastEvaluatedAt : updatedAtFallback, - sources: normalizeSources(parsed.sources), + sources: Object.values(sources).some(Boolean) ? sources : undefined, diagnostics: Array.isArray(parsed.diagnostics) ? parsed.diagnostics.filter( (item): item is string => typeof item === 'string' && item.trim().length > 0 @@ -360,7 +503,8 @@ function normalizePersistedMemberState( parsed.launchState === 'runtime_pending_bootstrap' || parsed.launchState === 'runtime_pending_permission' || parsed.launchState === 'confirmed_alive' || - parsed.launchState === 'failed_to_start' + parsed.launchState === 'failed_to_start' || + parsed.launchState === 'skipped_for_launch' ? parsed.launchState : deriveMemberLaunchState(next); next.launchState = launchState; @@ -402,6 +546,7 @@ export function createPersistedLaunchSnapshot(params: { members[name] = { name, launchState: 'starting', + skippedForLaunch: false, agentToolAccepted: false, runtimeAlive: false, bootstrapConfirmed: false, @@ -478,23 +623,39 @@ export function snapshotFromRuntimeMemberStatuses(params: { sources.nativeHeartbeat = true; sources.inboxHeartbeat = true; } - if (runtime?.livenessSource === 'process' || runtime?.runtimeAlive) { + const skippedForLaunch = + runtime?.skippedForLaunch === true || runtime?.launchState === 'skipped_for_launch'; + const runtimeAlive = skippedForLaunch ? false : preservesStrongRuntimeAlive(runtime); + if (runtime?.livenessSource === 'process' && runtimeAlive) { sources.processAlive = true; } const entry: PersistedTeamLaunchMemberState = { name, launchState: runtime?.launchState ?? 'starting', - agentToolAccepted: runtime?.agentToolAccepted === true, - runtimeAlive: runtime?.runtimeAlive === true, - bootstrapConfirmed: runtime?.bootstrapConfirmed === true, - hardFailure: runtime?.hardFailure === true || runtime?.launchState === 'failed_to_start', - hardFailureReason: runtime?.hardFailureReason ?? runtime?.error, + skippedForLaunch, + skipReason: runtime?.skipReason, + skippedAt: runtime?.skippedAt, + agentToolAccepted: skippedForLaunch ? false : runtime?.agentToolAccepted === true, + runtimeAlive, + bootstrapConfirmed: skippedForLaunch ? false : runtime?.bootstrapConfirmed === true, + hardFailure: + runtime?.launchState === 'skipped_for_launch' + ? false + : runtime?.hardFailure === true || runtime?.launchState === 'failed_to_start', + hardFailureReason: + runtime?.launchState === 'skipped_for_launch' + ? undefined + : (runtime?.hardFailureReason ?? runtime?.error), pendingPermissionRequestIds: runtime?.pendingPermissionRequestIds?.length ? [...new Set(runtime.pendingPermissionRequestIds)] : undefined, + livenessKind: runtime?.livenessKind, + runtimeDiagnostic: runtime?.runtimeDiagnostic, + runtimeDiagnosticSeverity: runtime?.runtimeDiagnosticSeverity, + runtimeLastSeenAt: runtime?.livenessLastCheckedAt, firstSpawnAcceptedAt: runtime?.firstSpawnAcceptedAt, lastHeartbeatAt: runtime?.lastHeartbeatAt, - lastRuntimeAliveAt: runtime?.runtimeAlive ? updatedAt : undefined, + lastRuntimeAliveAt: runtimeAlive ? updatedAt : undefined, lastEvaluatedAt: runtime?.updatedAt ?? updatedAt, sources: Object.values(sources).some(Boolean) ? sources : undefined, diagnostics: undefined, @@ -530,8 +691,13 @@ export function snapshotToMemberSpawnStatuses( if (!entry) continue; let status: MemberSpawnStatusEntry['status'] = 'offline'; let livenessSource: MemberSpawnLivenessSource | undefined; + const skippedForLaunch = + entry.launchState === 'skipped_for_launch' || entry.skippedForLaunch === true; + const runtimeAlive = skippedForLaunch ? false : preservesStrongRuntimeAlive(entry); if (entry.launchState === 'failed_to_start') { status = 'error'; + } else if (entry.launchState === 'skipped_for_launch') { + status = 'skipped'; } else if (entry.launchState === 'confirmed_alive') { status = 'online'; livenessSource = 'heartbeat'; @@ -539,8 +705,8 @@ export function snapshotToMemberSpawnStatuses( entry.launchState === 'runtime_pending_permission' || entry.launchState === 'runtime_pending_bootstrap' ) { - status = entry.runtimeAlive ? 'online' : 'waiting'; - livenessSource = entry.runtimeAlive ? 'process' : undefined; + status = runtimeAlive ? 'online' : 'waiting'; + livenessSource = runtimeAlive ? 'process' : undefined; } else { status = entry.agentToolAccepted ? 'waiting' : 'spawning'; } @@ -549,12 +715,19 @@ export function snapshotToMemberSpawnStatuses( launchState: entry.launchState, error: entry.hardFailure ? entry.hardFailureReason : undefined, hardFailureReason: entry.hardFailureReason, + skippedForLaunch: entry.skippedForLaunch, + skipReason: entry.skipReason, + skippedAt: entry.skippedAt, livenessSource, - agentToolAccepted: entry.agentToolAccepted, - runtimeAlive: entry.runtimeAlive, - bootstrapConfirmed: entry.bootstrapConfirmed, - hardFailure: entry.hardFailure, + agentToolAccepted: skippedForLaunch ? false : entry.agentToolAccepted, + runtimeAlive, + bootstrapConfirmed: skippedForLaunch ? false : entry.bootstrapConfirmed, + hardFailure: skippedForLaunch ? false : entry.hardFailure, pendingPermissionRequestIds: entry.pendingPermissionRequestIds, + livenessKind: entry.livenessKind, + runtimeDiagnostic: entry.runtimeDiagnostic, + runtimeDiagnosticSeverity: entry.runtimeDiagnosticSeverity, + livenessLastCheckedAt: entry.runtimeLastSeenAt ?? entry.lastEvaluatedAt, firstSpawnAcceptedAt: entry.firstSpawnAcceptedAt, lastHeartbeatAt: entry.lastHeartbeatAt, updatedAt: entry.lastEvaluatedAt, diff --git a/src/main/services/team/TeamLaunchSummaryProjection.ts b/src/main/services/team/TeamLaunchSummaryProjection.ts index da1fe435..48345c60 100644 --- a/src/main/services/team/TeamLaunchSummaryProjection.ts +++ b/src/main/services/team/TeamLaunchSummaryProjection.ts @@ -13,12 +13,19 @@ export interface LaunchStateSummary { expectedMemberCount?: number; confirmedMemberCount?: number; missingMembers?: string[]; + skippedMembers?: string[]; teamLaunchState?: TeamSummary['teamLaunchState']; launchUpdatedAt?: string; confirmedCount?: number; pendingCount?: number; failedCount?: number; + skippedCount?: number; runtimeAlivePendingCount?: number; + shellOnlyPendingCount?: number; + runtimeProcessPendingCount?: number; + runtimeCandidatePendingCount?: number; + noRuntimePendingCount?: number; + permissionPendingCount?: number; } export interface PersistedTeamLaunchSummaryProjection extends LaunchStateSummary { @@ -55,6 +62,10 @@ export function createLaunchStateSummary( const member = snapshot.members[name]; return member?.launchState === 'failed_to_start'; }); + const skippedMembers = persistedMemberNames.filter((name) => { + const member = snapshot.members[name]; + return member?.launchState === 'skipped_for_launch' || member?.skippedForLaunch === true; + }); return { ...(snapshot.teamLaunchState === 'partial_failure' @@ -67,12 +78,19 @@ export function createLaunchStateSummary( ? { confirmedMemberCount: snapshot.summary.confirmedCount } : {}), ...(missingMembers.length > 0 ? { missingMembers } : {}), + ...(skippedMembers.length > 0 ? { skippedMembers } : {}), teamLaunchState: snapshot.teamLaunchState, launchUpdatedAt: snapshot.updatedAt, confirmedCount: snapshot.summary.confirmedCount, pendingCount: snapshot.summary.pendingCount, failedCount: snapshot.summary.failedCount, + skippedCount: snapshot.summary.skippedCount, runtimeAlivePendingCount: snapshot.summary.runtimeAlivePendingCount, + shellOnlyPendingCount: snapshot.summary.shellOnlyPendingCount, + runtimeProcessPendingCount: snapshot.summary.runtimeProcessPendingCount, + runtimeCandidatePendingCount: snapshot.summary.runtimeCandidatePendingCount, + noRuntimePendingCount: snapshot.summary.noRuntimePendingCount, + permissionPendingCount: snapshot.summary.permissionPendingCount, }; } @@ -128,8 +146,17 @@ export function normalizePersistedLaunchSummaryProjection( normalized.missingMembers = missingMembers; } } + if (Array.isArray(record.skippedMembers)) { + const skippedMembers = record.skippedMembers.filter( + (member): member is string => typeof member === 'string' && member.trim().length > 0 + ); + if (skippedMembers.length > 0) { + normalized.skippedMembers = skippedMembers; + } + } if ( record.teamLaunchState === 'partial_failure' || + record.teamLaunchState === 'partial_skipped' || record.teamLaunchState === 'partial_pending' || record.teamLaunchState === 'clean_success' ) { @@ -144,9 +171,33 @@ export function normalizePersistedLaunchSummaryProjection( if (typeof record.failedCount === 'number' && record.failedCount >= 0) { normalized.failedCount = record.failedCount; } + if (typeof record.skippedCount === 'number' && record.skippedCount >= 0) { + normalized.skippedCount = record.skippedCount; + } if (typeof record.runtimeAlivePendingCount === 'number' && record.runtimeAlivePendingCount >= 0) { normalized.runtimeAlivePendingCount = record.runtimeAlivePendingCount; } + if (typeof record.shellOnlyPendingCount === 'number' && record.shellOnlyPendingCount >= 0) { + normalized.shellOnlyPendingCount = record.shellOnlyPendingCount; + } + if ( + typeof record.runtimeProcessPendingCount === 'number' && + record.runtimeProcessPendingCount >= 0 + ) { + normalized.runtimeProcessPendingCount = record.runtimeProcessPendingCount; + } + if ( + typeof record.runtimeCandidatePendingCount === 'number' && + record.runtimeCandidatePendingCount >= 0 + ) { + normalized.runtimeCandidatePendingCount = record.runtimeCandidatePendingCount; + } + if (typeof record.noRuntimePendingCount === 'number' && record.noRuntimePendingCount >= 0) { + normalized.noRuntimePendingCount = record.noRuntimePendingCount; + } + if (typeof record.permissionPendingCount === 'number' && record.permissionPendingCount >= 0) { + normalized.permissionPendingCount = record.permissionPendingCount; + } normalized.launchUpdatedAt = updatedAt; return normalized; } diff --git a/src/main/services/team/TeamMcpConfigBuilder.ts b/src/main/services/team/TeamMcpConfigBuilder.ts index 5251334c..6f91ea84 100644 --- a/src/main/services/team/TeamMcpConfigBuilder.ts +++ b/src/main/services/team/TeamMcpConfigBuilder.ts @@ -227,17 +227,7 @@ export async function resolveAgentTeamsMcpLaunchSpec(): Promise { logger.warn(`Packaged MCP entry not found at ${packagedEntry}, falling back to workspace`); } - // 2. Dev mode — prefer built dist for reliable direct execution - const builtEntry = getBuiltServerEntry(); - checked.push(builtEntry); - if (await pathExists(builtEntry)) { - return { - command: await resolveNodePath(), - args: [builtEntry], - }; - } - - // 3. Dev mode fallback — run source directly through a local tsx binary + // 2. Dev mode — prefer source so pnpm dev always sees current MCP tools const sourceEntry = getSourceServerEntry(); checked.push(sourceEntry); if (await pathExists(sourceEntry)) { @@ -252,6 +242,16 @@ export async function resolveAgentTeamsMcpLaunchSpec(): Promise { } } + // 3. Dev mode fallback — use built dist when source execution is unavailable + const builtEntry = getBuiltServerEntry(); + checked.push(builtEntry); + if (await pathExists(builtEntry)) { + return { + command: await resolveNodePath(), + args: [builtEntry], + }; + } + throw new Error( `agent-teams-mcp entrypoint not found. Checked paths:\n${checked.map((p) => ` - ${p}`).join('\n')}` ); diff --git a/src/main/services/team/TeamProvisioningService.ts b/src/main/services/team/TeamProvisioningService.ts index 1db3874c..d375803e 100644 --- a/src/main/services/team/TeamProvisioningService.ts +++ b/src/main/services/team/TeamProvisioningService.ts @@ -16,7 +16,10 @@ import { import { createTeamRuntimeLaneCoordinator } from '@features/team-runtime-lanes/main'; import { killTmuxPaneForCurrentPlatformSync, + listRuntimeProcessesForCurrentTmuxPlatform, listTmuxPanePidsForCurrentPlatform, + listTmuxPaneRuntimeInfoForCurrentPlatform, + type TmuxPaneRuntimeInfo, } from '@features/tmux-installer/main'; import { ConfigManager } from '@main/services/infrastructure/ConfigManager'; import { NotificationManager } from '@main/services/infrastructure/NotificationManager'; @@ -151,7 +154,11 @@ import { } from './idleNotificationMainProcessSemantics'; import { withInboxLock } from './inboxLock'; import { getEffectiveInboxMessageId } from './inboxMessageIdentity'; -import { buildProgressAssistantOutput, buildProgressLogsTail } from './progressPayload'; +import { + boundLaunchDiagnostics, + buildProgressAssistantOutput, + buildProgressLogsTail, +} from './progressPayload'; import { resolveDesktopTeammateModeDecision } from './runtimeTeammateMode'; import { choosePreferredLaunchSnapshot, @@ -175,6 +182,11 @@ import { TeamMcpConfigBuilder } from './TeamMcpConfigBuilder'; import { TeamMemberLogsFinder } from './TeamMemberLogsFinder'; import { TeamMembersMetaStore } from './TeamMembersMetaStore'; import { TeamMetaStore } from './TeamMetaStore'; +import { + isStrongRuntimeEvidence, + resolveTeamMemberRuntimeLiveness, + sanitizeProcessCommandForDiagnostics, +} from './TeamRuntimeLivenessResolver'; import { TeamSentMessagesStore } from './TeamSentMessagesStore'; import { TeamTaskReader } from './TeamTaskReader'; import { TeamTranscriptProjectResolver } from './TeamTranscriptProjectResolver'; @@ -221,6 +233,9 @@ interface PersistedRuntimeMemberLike { agentId?: string; tmuxPaneId?: string; backendType?: string; + providerId?: string; + runtimePid?: number; + runtimeSessionId?: string; } type RelayInboxMessage = InboxMessage & { messageId: string }; @@ -276,7 +291,10 @@ import type { PersistedTeamLaunchSummary, ProviderModelLaunchIdentity, TeamAgentRuntimeBackendType, + TeamAgentRuntimeDiagnosticSeverity, TeamAgentRuntimeEntry, + TeamAgentRuntimeLivenessKind, + TeamAgentRuntimePidSource, TeamAgentRuntimeSnapshot, TeamChangeEvent, TeamConfig, @@ -284,6 +302,7 @@ import type { TeamCreateResponse, TeamFastMode, TeamLaunchAggregateState, + TeamLaunchDiagnosticItem, TeamLaunchRequest, TeamLaunchResponse, TeamMember, @@ -367,6 +386,98 @@ function normalizeRuntimeStringArray(value: unknown): string[] { : []; } +interface RuntimeToolMetadata { + runtimePid?: number; + processCommand?: string; + runtimeVersion?: string; + hostPid?: number; + cwd?: string; +} + +function normalizeRuntimePositiveInteger(value: unknown): number | undefined { + return typeof value === 'number' && Number.isFinite(value) && value > 0 + ? Math.trunc(value) + : undefined; +} + +function normalizeRuntimeMetadataString(value: unknown, maxLength: number): string | undefined { + return typeof value === 'string' && value.trim().length > 0 + ? value.trim().slice(0, maxLength) + : undefined; +} + +function parseRuntimeToolMetadata(value: unknown): RuntimeToolMetadata { + if (!value || typeof value !== 'object' || Array.isArray(value)) { + return {}; + } + const raw = value as Record; + return { + ...(normalizeRuntimePositiveInteger(raw.runtimePid) + ? { runtimePid: normalizeRuntimePositiveInteger(raw.runtimePid) } + : {}), + ...(normalizeRuntimeMetadataString(raw.processCommand, 500) + ? { processCommand: normalizeRuntimeMetadataString(raw.processCommand, 500) } + : {}), + ...(normalizeRuntimeMetadataString(raw.runtimeVersion, 80) + ? { runtimeVersion: normalizeRuntimeMetadataString(raw.runtimeVersion, 80) } + : {}), + ...(normalizeRuntimePositiveInteger(raw.hostPid) + ? { hostPid: normalizeRuntimePositiveInteger(raw.hostPid) } + : {}), + ...(normalizeRuntimeMetadataString(raw.cwd, 500) + ? { cwd: normalizeRuntimeMetadataString(raw.cwd, 500) } + : {}), + }; +} + +function mentionsProcessTableUnavailable(value: string | undefined): boolean { + return /\bprocess table\b.*\bunavailable\b/i.test(value ?? ''); +} + +function buildRuntimeToolMetadataDiagnostics(metadata: RuntimeToolMetadata | undefined): string[] { + if (!metadata) { + return []; + } + const diagnostics: string[] = []; + if (metadata.runtimePid != null) { + diagnostics.push(`runtime pid: ${metadata.runtimePid}`); + } + if (metadata.processCommand) { + const processCommand = sanitizeProcessCommandForDiagnostics(metadata.processCommand); + if (processCommand) { + diagnostics.push(`runtime process command: ${processCommand}`); + } + } + if (metadata.runtimeVersion) { + diagnostics.push(`runtime version: ${metadata.runtimeVersion}`); + } + if (metadata.hostPid != null) { + diagnostics.push(`runtime host pid: ${metadata.hostPid}`); + } + if (metadata.cwd) { + diagnostics.push(`runtime cwd: ${metadata.cwd}`); + } + return diagnostics; +} + +function buildRuntimeDiagnosticForSpawn( + metadata: LiveTeamAgentRuntimeMetadata +): string | undefined { + const baseDiagnostic = metadata.runtimeDiagnostic; + const processTableUnavailable = + mentionsProcessTableUnavailable(baseDiagnostic) || + metadata.diagnostics?.some((diagnostic) => mentionsProcessTableUnavailable(diagnostic)); + if (!processTableUnavailable) { + return baseDiagnostic; + } + if (mentionsProcessTableUnavailable(baseDiagnostic)) { + return baseDiagnostic; + } + return baseDiagnostic + ? `${baseDiagnostic}; process table unavailable` + : 'process table unavailable'; +} + function runtimeTaskRefs(teamName: string, value: unknown): InboxMessage['taskRefs'] | undefined { const refs = normalizeRuntimeStringArray(value); return refs.length > 0 @@ -1238,23 +1349,7 @@ interface ProvisioningRun { geminiPostLaunchHydrationSent: boolean; suppressGeminiPostLaunchHydrationOutput: boolean; /** Per-member spawn lifecycle statuses tracked from stream-json output. */ - memberSpawnStatuses: Map< - string, - { - status: MemberSpawnStatus; - launchState: MemberLaunchState; - error?: string; - hardFailureReason?: string; - livenessSource?: MemberSpawnLivenessSource; - agentToolAccepted?: boolean; - runtimeAlive?: boolean; - bootstrapConfirmed?: boolean; - hardFailure?: boolean; - firstSpawnAcceptedAt?: string; - lastHeartbeatAt?: string; - updatedAt: string; - } - >; + memberSpawnStatuses: Map; /** Agent tool_use_id -> teammate name for persistent teammate spawns. */ memberSpawnToolUseIds: Map; /** Explicit restart requests awaiting teammate rejoin or failure. */ @@ -1335,6 +1430,7 @@ interface PromptSizeSummary { } const MEMBER_LAUNCH_GRACE_MS = 90_000; +const MEMBER_BOOTSTRAP_STALL_MS = 5 * 60_000; export function shouldWarnOnUnreadableMemberAuditConfig(params: { nowMs: number; @@ -1388,27 +1484,22 @@ function createInitialMemberSpawnStatusEntry(): MemberSpawnStatusEntry { interface LiveTeamAgentRuntimeMetadata { alive: boolean; backendType?: TeamAgentRuntimeBackendType; + providerId?: TeamProviderId; agentId?: string; pid?: number; metricsPid?: number; model?: string; tmuxPaneId?: string; -} - -function escapeRegexLiteral(value: string): string { - return value.replace(/[.*+?^${}()|[\]\\]/g, '\\$&'); -} - -function commandContainsCliArgValue(command: string, argName: string, value: string): boolean { - const normalizedCommand = command.trim(); - const normalizedValue = value.trim(); - if (!normalizedCommand || !normalizedValue) { - return false; - } - const pattern = new RegExp( - `(?:^|\\s)${escapeRegexLiteral(argName)}(?:=|\\s+)${escapeRegexLiteral(normalizedValue)}(?:\\s|$)` - ); - return pattern.test(normalizedCommand); + livenessKind?: TeamAgentRuntimeLivenessKind; + pidSource?: TeamAgentRuntimePidSource; + processCommand?: string; + panePid?: number; + paneCurrentCommand?: string; + runtimeSessionId?: string; + runtimeLastSeenAt?: string; + runtimeDiagnostic?: string; + runtimeDiagnosticSeverity?: TeamAgentRuntimeDiagnosticSeverity; + diagnostics?: string[]; } function isNeverSpawnedDuringLaunchReason(reason?: string): boolean { @@ -1487,7 +1578,13 @@ function summarizeMemberSpawnStatusRecord( let confirmedCount = 0; let pendingCount = 0; let failedCount = 0; + let skippedCount = 0; let runtimeAlivePendingCount = 0; + let shellOnlyPendingCount = 0; + let runtimeProcessPendingCount = 0; + let runtimeCandidatePendingCount = 0; + let noRuntimePendingCount = 0; + let permissionPendingCount = 0; const memberNames = Array.from(new Set([...expectedMembers, ...Object.keys(statuses)])); for (const memberName of memberNames) { @@ -1500,6 +1597,10 @@ function summarizeMemberSpawnStatusRecord( confirmedCount += 1; continue; } + if (entry.launchState === 'skipped_for_launch' || entry.skippedForLaunch === true) { + skippedCount += 1; + continue; + } if (entry.launchState === 'failed_to_start') { failedCount += 1; continue; @@ -1508,9 +1609,36 @@ function summarizeMemberSpawnStatusRecord( if (entry.runtimeAlive) { runtimeAlivePendingCount += 1; } + if (entry.launchState === 'runtime_pending_permission') { + permissionPendingCount += 1; + } + if (entry.livenessKind === 'shell_only') { + shellOnlyPendingCount += 1; + } else if (entry.livenessKind === 'runtime_process') { + runtimeProcessPendingCount += 1; + } else if (entry.livenessKind === 'runtime_process_candidate') { + runtimeCandidatePendingCount += 1; + } else if ( + entry.livenessKind === 'not_found' || + entry.livenessKind === 'stale_metadata' || + entry.livenessKind === 'registered_only' + ) { + noRuntimePendingCount += 1; + } } - return { confirmedCount, pendingCount, failedCount, runtimeAlivePendingCount }; + return { + confirmedCount, + pendingCount, + failedCount, + skippedCount, + runtimeAlivePendingCount, + shellOnlyPendingCount, + runtimeProcessPendingCount, + runtimeCandidatePendingCount, + noRuntimePendingCount, + permissionPendingCount, + }; } function buildRestartStillRunningReason(memberName: string): string { @@ -1708,8 +1836,12 @@ function deriveMemberLaunchState(entry: { runtimeAlive?: boolean; bootstrapConfirmed?: boolean; hardFailure?: boolean; + skippedForLaunch?: boolean; pendingPermissionRequestIds?: string[]; }): MemberLaunchState { + if (entry.skippedForLaunch) { + return 'skipped_for_launch'; + } if (entry.hardFailure) { return 'failed_to_start'; } @@ -2244,7 +2376,9 @@ ${getAgentLanguageInstruction()} Your FIRST action: call MCP tool member_briefing with: { teamName: "${teamName}", memberName: "${member.name}" } Call member_briefing directly. Do NOT use Agent, any subagent, or any delegated helper for this step. -If member_briefing fails, SendMessage "${leadName}" one short natural-language sentence with the exact error text. Do NOT send only "bootstrap failed". +If tool search says agent-teams is still connecting, wait briefly and retry tool search at most once. +If member_briefing is still unavailable after that one retry, SendMessage "${leadName}" exactly one short natural-language sentence with the exact error text, then stop this turn and wait. Do NOT send only "bootstrap failed". +Do NOT keep searching for member_briefing, check tasks, or send repeated status/idle messages after reporting the bootstrap failure. ${getCanonicalSendMessageFieldRule()} Correct example: ${buildCanonicalSendMessageExample({ to: leadName, summary: 'bootstrap error', message: 'exact error text' })} @@ -2273,7 +2407,9 @@ The team has just been reconnected after a restart. Your FIRST action: call MCP tool member_briefing with: { teamName: "${teamName}", memberName: "${member.name}" } Call member_briefing directly. Do NOT use Agent, any subagent, or any delegated helper for this step. -If member_briefing fails, SendMessage "${leadName}" one short natural-language sentence with the exact error text. Do NOT send only "bootstrap failed". +If tool search says agent-teams is still connecting, wait briefly and retry tool search at most once. +If member_briefing is still unavailable after that one retry, SendMessage "${leadName}" exactly one short natural-language sentence with the exact error text, then stop this turn and wait. Do NOT send only "bootstrap failed". +Do NOT keep searching for member_briefing, check tasks, or send repeated status/idle messages after reporting the bootstrap failure. ${getCanonicalSendMessageFieldRule()} Correct example: ${buildCanonicalSendMessageExample({ to: leadName, summary: 'bootstrap error', message: 'exact error text' })} @@ -2319,7 +2455,9 @@ Your FIRST action: call MCP tool member_briefing with: Call member_briefing directly as your own MCP tool call. Do NOT use the Agent tool, any subagent, or any delegated helper for this step. member_briefing is expected to be available in your initial MCP tool list. If it is missing or unavailable, treat that as a real bootstrap error and report the exact error text to your team lead. Do NOT start work, claim tasks, or improvise workflow/task/process rules before member_briefing succeeds. -If member_briefing fails, send one short natural-language message to your team lead "${leadName}" that includes the exact failure reason (for example the API error, validation error, or lookup failure), then wait. Do NOT send only "bootstrap failed". +If tool search says agent-teams is still connecting, wait briefly and retry tool search at most once. +If member_briefing is still unavailable after that one retry, send exactly one short natural-language message to your team lead "${leadName}" that includes the exact failure reason (for example the API error, validation error, or lookup failure), then stop this turn and wait. Do NOT send only "bootstrap failed". +Do NOT keep searching for member_briefing, check tasks, or send repeated status/idle messages after reporting the bootstrap failure. IMPORTANT: When sending messages to the team lead, always use the exact name "${leadName}" in the \`to\` field of SendMessage. Never abbreviate or shorten it (e.g. do NOT use "lead" instead of "team-lead"). ${getCanonicalSendMessageFieldRule()} Correct example: @@ -2393,7 +2531,9 @@ ${providerArgLine}${modelArgLine}${effortArgLine} - prompt: Call member_briefing directly as your own MCP tool call. Do NOT use the Agent tool, any subagent, or any delegated helper for this step. member_briefing is expected to be available in your initial MCP tool list. If it is missing or unavailable, treat that as a real bootstrap error and report the exact error text to your team lead. Do NOT start work, claim tasks, or improvise workflow/task/process rules before member_briefing succeeds. - If member_briefing fails, send one short natural-language message to your team lead "${leadName}" that includes the exact failure reason (for example the API error, validation error, or lookup failure), then wait. Do NOT send only "bootstrap failed". + If tool search says agent-teams is still connecting, wait briefly and retry tool search at most once. + If member_briefing is still unavailable after that one retry, send exactly one short natural-language message to your team lead "${leadName}" that includes the exact failure reason (for example the API error, validation error, or lookup failure), then stop this turn and wait. Do NOT send only "bootstrap failed". + Do NOT keep searching for member_briefing, check tasks, or send repeated status/idle messages after reporting the bootstrap failure. IMPORTANT: When sending messages to the team lead, always use the exact name "${leadName}" in the \`to\` field of SendMessage. Never abbreviate or shorten it (e.g. do NOT use "lead" instead of "team-lead"). ${buildTeammateAgentBlockReminder()} ${actionModeProtocol} @@ -3164,7 +3304,13 @@ function updateProgress( message: string, extras?: Pick< TeamProvisioningProgress, - 'pid' | 'error' | 'warnings' | 'cliLogsTail' | 'configReady' | 'messageSeverity' + | 'pid' + | 'error' + | 'warnings' + | 'cliLogsTail' + | 'configReady' + | 'messageSeverity' + | 'launchDiagnostics' > ): TeamProvisioningProgress { // Cap assistant output on every progress tick. `updateProgress` is invoked @@ -3185,10 +3331,139 @@ function updateProgress( assistantOutput, configReady: extras?.configReady ?? run.progress.configReady, messageSeverity: extras?.messageSeverity, + launchDiagnostics: boundLaunchDiagnostics( + extras?.launchDiagnostics ?? + buildLaunchDiagnosticsFromRun(run) ?? + run.progress.launchDiagnostics + ), }; return run.progress; } +function buildLaunchDiagnosticsFromRun( + run: ProvisioningRun +): TeamLaunchDiagnosticItem[] | undefined { + const memberSpawnStatuses = run.memberSpawnStatuses; + if (!run.isLaunch || !memberSpawnStatuses || memberSpawnStatuses.size === 0) { + return undefined; + } + const observedAt = nowIso(); + const items: TeamLaunchDiagnosticItem[] = []; + for (const [memberName, entry] of memberSpawnStatuses.entries()) { + if (entry.launchState === 'confirmed_alive') { + items.push({ + id: `${memberName}:bootstrap_confirmed`, + memberName, + severity: 'info', + code: 'bootstrap_confirmed', + label: `${memberName} - bootstrap confirmed`, + observedAt, + }); + continue; + } + if (entry.launchState === 'failed_to_start') { + items.push({ + id: `${memberName}:bootstrap_stalled`, + memberName, + severity: 'error', + code: 'bootstrap_stalled', + label: `${memberName} - failed to start`, + detail: entry.hardFailureReason ?? entry.error, + observedAt, + }); + continue; + } + if (entry.launchState === 'runtime_pending_permission') { + items.push({ + id: `${memberName}:permission_pending`, + memberName, + severity: 'warning', + code: 'permission_pending', + label: `${memberName} - awaiting permission`, + detail: entry.runtimeDiagnostic, + observedAt, + }); + continue; + } + if (mentionsProcessTableUnavailable(entry.runtimeDiagnostic)) { + items.push({ + id: `${memberName}:process_table_unavailable`, + memberName, + severity: 'warning', + code: 'process_table_unavailable', + label: `${memberName} - process table unavailable`, + detail: entry.runtimeDiagnostic, + observedAt, + }); + continue; + } + if (entry.livenessKind === 'shell_only') { + items.push({ + id: `${memberName}:tmux_shell_only`, + memberName, + severity: 'warning', + code: 'tmux_shell_only', + label: `${memberName} - shell only`, + detail: entry.runtimeDiagnostic, + observedAt, + }); + continue; + } + if (entry.livenessKind === 'runtime_process_candidate') { + items.push({ + id: `${memberName}:runtime_process_candidate`, + memberName, + severity: 'warning', + code: 'runtime_process_candidate', + label: `${memberName} - process candidate`, + detail: entry.runtimeDiagnostic, + observedAt, + }); + continue; + } + if (entry.livenessKind === 'runtime_process') { + items.push({ + id: `${memberName}:runtime_process_detected`, + memberName, + severity: 'info', + code: 'runtime_process_detected', + label: `${memberName} - waiting for bootstrap`, + detail: entry.runtimeDiagnostic, + observedAt, + }); + continue; + } + if ( + entry.livenessKind === 'registered_only' || + entry.livenessKind === 'stale_metadata' || + entry.livenessKind === 'not_found' + ) { + items.push({ + id: `${memberName}:runtime_not_found`, + memberName, + severity: 'warning', + code: 'runtime_not_found', + label: `${memberName} - no runtime found`, + detail: entry.runtimeDiagnostic, + observedAt, + }); + continue; + } + if (entry.agentToolAccepted) { + items.push({ + id: `${memberName}:spawn_accepted`, + memberName, + severity: 'info', + code: 'spawn_accepted', + label: `${memberName} - spawn accepted`, + detail: entry.runtimeDiagnostic, + observedAt, + }); + } + } + return items.length > 0 ? items : undefined; +} + function buildCombinedLogs( stdoutBuffer: string | undefined, stderrBuffer: string | undefined @@ -3568,7 +3843,12 @@ export class TeamProvisioningService { private readonly runtimeAdapterProgressByRunId = new Map(); private readonly runtimeAdapterRunByTeam = new Map< string, - { runId: string; providerId: TeamProviderId; cwd?: string } + { + runId: string; + providerId: TeamProviderId; + cwd?: string; + members?: Record; + } >(); private readonly cancelledRuntimeAdapterRunIds = new Set(); private stopAllTeamsGeneration = 0; @@ -5664,6 +5944,7 @@ export class TeamProvisioningService { runtimeSessionId, observedAt, diagnostics: payload.diagnostics, + metadata: parseRuntimeToolMetadata(payload.metadata), reason: 'OpenCode runtime bootstrap check-in accepted', }); @@ -5794,6 +6075,7 @@ export class TeamProvisioningService { runtimeSessionId, observedAt, diagnostics: undefined, + metadata: parseRuntimeToolMetadata(payload.metadata), reason: `OpenCode runtime heartbeat accepted${optionalRuntimeString(payload.status) ? ` (${optionalRuntimeString(payload.status)})` : ''}`, }); @@ -5838,6 +6120,7 @@ export class TeamProvisioningService { runtimeSessionId: string; observedAt: string; diagnostics: unknown; + metadata?: RuntimeToolMetadata; reason: string; }): Promise { const previous = await this.launchStateStore.read(input.teamName); @@ -5861,6 +6144,13 @@ export class TeamProvisioningService { runtimeAlive: true, bootstrapConfirmed: true, hardFailure: false, + ...(input.metadata?.runtimePid ? { runtimePid: input.metadata.runtimePid } : {}), + runtimeSessionId: input.runtimeSessionId, + livenessKind: 'confirmed_bootstrap', + ...(input.metadata?.runtimePid ? { pidSource: 'runtime_bootstrap' as const } : {}), + runtimeDiagnostic: input.reason, + runtimeDiagnosticSeverity: 'info', + runtimeLastSeenAt: input.observedAt, firstSpawnAcceptedAt: previousMember?.firstSpawnAcceptedAt ?? input.observedAt, lastHeartbeatAt: input.observedAt, lastRuntimeAliveAt: input.observedAt, @@ -5872,7 +6162,10 @@ export class TeamProvisioningService { }, diagnostics: mergeRuntimeDiagnostics( previousMember?.diagnostics, - input.diagnostics, + [ + ...normalizeRuntimeStringArray(input.diagnostics), + ...buildRuntimeToolMetadataDiagnostics(input.metadata), + ], input.reason ), }; @@ -5888,6 +6181,8 @@ export class TeamProvisioningService { updatedAt: input.observedAt, }); await this.launchStateStore.write(input.teamName, snapshot); + this.agentRuntimeSnapshotCache.delete(input.teamName); + this.liveTeamAgentRuntimeMetadataCache.delete(input.teamName); this.teamChangeEmitter?.({ type: 'member-spawn', teamName: input.teamName, @@ -6488,7 +6783,13 @@ export class TeamProvisioningService { } this.agentRuntimeSnapshotCache.delete(run.teamName); this.liveTeamAgentRuntimeMetadataCache.delete(run.teamName); - this.setMemberSpawnStatus(run, spawnedMemberName, 'online', undefined, 'process'); + this.setMemberSpawnStatus(run, spawnedMemberName, 'waiting'); + this.appendMemberBootstrapDiagnostic( + run, + spawnedMemberName, + 'already_running requires strong runtime verification' + ); + void this.reevaluateMemberLaunchStatus(run, spawnedMemberName); } else { this.setMemberSpawnStatus(run, spawnedMemberName, 'waiting'); } @@ -6623,6 +6924,9 @@ export class TeamProvisioningService { }; if (status === 'spawning') { + next.skippedForLaunch = false; + next.skipReason = undefined; + next.skippedAt = undefined; next.agentToolAccepted = false; next.runtimeAlive = false; next.bootstrapConfirmed = false; @@ -6630,10 +6934,17 @@ export class TeamProvisioningService { next.error = undefined; next.hardFailureReason = undefined; next.livenessSource = undefined; + next.livenessKind = undefined; + next.runtimeDiagnostic = undefined; + next.runtimeDiagnosticSeverity = undefined; + next.livenessLastCheckedAt = undefined; next.firstSpawnAcceptedAt = undefined; next.lastHeartbeatAt = undefined; next.launchState = 'starting'; } else if (status === 'waiting') { + next.skippedForLaunch = false; + next.skipReason = undefined; + next.skippedAt = undefined; next.agentToolAccepted = true; next.runtimeAlive = false; next.bootstrapConfirmed = false; @@ -6641,10 +6952,17 @@ export class TeamProvisioningService { next.error = undefined; next.hardFailureReason = undefined; next.livenessSource = undefined; + next.livenessKind = undefined; + next.runtimeDiagnostic = undefined; + next.runtimeDiagnosticSeverity = undefined; + next.livenessLastCheckedAt = undefined; next.firstSpawnAcceptedAt = prev.firstSpawnAcceptedAt ?? updatedAt; next.lastHeartbeatAt = undefined; next.launchState = 'runtime_pending_bootstrap'; } else if (status === 'online') { + next.skippedForLaunch = false; + next.skipReason = undefined; + next.skippedAt = undefined; next.agentToolAccepted = true; next.runtimeAlive = true; next.livenessSource = livenessSource; @@ -6664,15 +6982,44 @@ export class TeamProvisioningService { next.hardFailureReason = undefined; next.launchState = deriveMemberLaunchState(next); } else if (status === 'error') { + next.skippedForLaunch = false; + next.skipReason = undefined; + next.skippedAt = undefined; next.error = error; next.hardFailure = true; next.hardFailureReason = error; next.launchState = 'failed_to_start'; + } else if (status === 'skipped') { + next.skippedForLaunch = true; + next.skipReason = + error?.trim() || prev.hardFailureReason || prev.error || 'Skipped for this launch'; + next.skippedAt = updatedAt; + next.agentToolAccepted = false; + next.runtimeAlive = false; + next.bootstrapConfirmed = false; + next.hardFailure = false; + next.error = undefined; + next.hardFailureReason = undefined; + next.livenessSource = undefined; + next.livenessKind = undefined; + next.runtimeDiagnostic = undefined; + next.runtimeDiagnosticSeverity = undefined; + next.livenessLastCheckedAt = undefined; + next.firstSpawnAcceptedAt = undefined; + next.lastHeartbeatAt = undefined; + next.launchState = 'skipped_for_launch'; } else if (status === 'offline') { Object.assign(next, createInitialMemberSpawnStatusEntry(), { updatedAt }); next.error = undefined; next.hardFailureReason = undefined; + next.skippedForLaunch = false; + next.skipReason = undefined; + next.skippedAt = undefined; next.livenessSource = undefined; + next.livenessKind = undefined; + next.runtimeDiagnostic = undefined; + next.runtimeDiagnosticSeverity = undefined; + next.livenessLastCheckedAt = undefined; next.firstSpawnAcceptedAt = undefined; next.lastHeartbeatAt = undefined; } @@ -6683,11 +7030,17 @@ export class TeamProvisioningService { prev.launchState === next.launchState && prev.error === next.error && prev.hardFailureReason === next.hardFailureReason && + (prev.skippedForLaunch === true) === (next.skippedForLaunch === true) && + prev.skipReason === next.skipReason && + prev.skippedAt === next.skippedAt && prev.livenessSource === next.livenessSource && prev.agentToolAccepted === next.agentToolAccepted && prev.runtimeAlive === next.runtimeAlive && prev.bootstrapConfirmed === next.bootstrapConfirmed && prev.hardFailure === next.hardFailure && + prev.livenessKind === next.livenessKind && + prev.runtimeDiagnostic === next.runtimeDiagnostic && + prev.runtimeDiagnosticSeverity === next.runtimeDiagnosticSeverity && prev.firstSpawnAcceptedAt === next.firstSpawnAcceptedAt && prev.lastHeartbeatAt === next.lastHeartbeatAt ) { @@ -6698,11 +7051,21 @@ export class TeamProvisioningService { if ( (status === 'online' && (next.bootstrapConfirmed || livenessSource === 'process')) || status === 'offline' || - status === 'error' + status === 'error' || + status === 'skipped' ) { run.pendingMemberRestarts?.delete(memberName); } this.syncMemberLaunchGraceCheck(run, memberName, next); + const launchDiagnostics = boundLaunchDiagnostics(buildLaunchDiagnosticsFromRun(run)); + if (launchDiagnostics) { + run.progress = { + ...run.progress, + updatedAt: nowIso(), + launchDiagnostics, + }; + run.onProgress(run.progress); + } if (status === 'spawning') { this.appendMemberBootstrapDiagnostic(run, memberName, 'Agent tool invoked'); @@ -6730,6 +7093,14 @@ export class TeamProvisioningService { memberName, error?.trim().length ? error.trim() : 'bootstrap failed' ); + } else if (status === 'skipped') { + this.appendMemberBootstrapDiagnostic( + run, + memberName, + error?.trim().length + ? `skipped for this launch: ${error.trim()}` + : 'skipped for this launch' + ); } if (!this.isCurrentTrackedRun(run)) return; this.emitMemberSpawnChange(run, memberName); @@ -6993,11 +7364,9 @@ export class TeamProvisioningService { const persistedRuntimeMember = getPersistedRuntimeMember(memberName); const liveRuntimeMember = getLiveRuntimeMember(memberName); const launchMember = launchSnapshot?.members[memberName]; - const backendType = normalizeTeamAgentRuntimeBackendType( - persistedRuntimeMember?.backendType, - false - ); - const rssPid = liveRuntimeMember?.pid ?? liveRuntimeMember?.metricsPid; + const backendType = + liveRuntimeMember?.backendType ?? + normalizeTeamAgentRuntimeBackendType(persistedRuntimeMember?.backendType, false); const runtimeModel = liveRuntimeMember?.model ?? launchMember?.model?.trim() ?? @@ -7010,22 +7379,22 @@ export class TeamProvisioningService { inferTeamProviderIdFromModel(launchMember?.model) ?? inferTeamProviderIdFromModel(member.model); const isOpenCodeMember = memberProviderId === 'opencode'; + const metricsPid = liveRuntimeMember?.metricsPid; const isSharedOpenCodeHost = isOpenCodeMember && - !liveRuntimeMember?.pid && - typeof liveRuntimeMember?.metricsPid === 'number' && - liveRuntimeMember.metricsPid > 0; - const displayPid = liveRuntimeMember?.pid ?? (isSharedOpenCodeHost ? rssPid : undefined); + typeof metricsPid === 'number' && + metricsPid > 0 && + liveRuntimeMember?.pidSource !== 'agent_process_table'; + const rssPid = isSharedOpenCodeHost ? metricsPid : (liveRuntimeMember?.pid ?? metricsPid); + const displayPid = isSharedOpenCodeHost ? rssPid : liveRuntimeMember?.pid; const restartable = isOpenCodeMember - ? Boolean(liveRuntimeMember?.pid) + ? !isSharedOpenCodeHost && Boolean(liveRuntimeMember?.pid) : isSharedOpenCodeHost ? false : backendType !== 'in-process'; - const launchSnapshotAlive = - this.isTeamAlive(teamName) && - (launchMember?.runtimeAlive === true || - launchMember?.bootstrapConfirmed === true || - launchMember?.launchState === 'confirmed_alive'); + const historicalBootstrapConfirmed = + launchMember?.bootstrapConfirmed === true || + launchMember?.launchState === 'confirmed_alive'; let rssBytes = rssPid ? rssBytesByPid.get(rssPid) : undefined; if (rssBytes == null && isSharedOpenCodeHost && typeof rssPid === 'number' && rssPid > 0) { try { @@ -7041,7 +7410,7 @@ export class TeamProvisioningService { snapshotMembers[memberName] = { memberName, - alive: liveRuntimeMember?.alive === true || launchSnapshotAlive, + alive: liveRuntimeMember?.alive === true, restartable, ...(backendType ? { backendType } : {}), ...(memberProviderId ? { providerId: memberProviderId } : {}), @@ -7053,6 +7422,33 @@ export class TeamProvisioningService { ...(displayPid ? { pid: displayPid } : {}), ...(runtimeModel ? { runtimeModel } : {}), ...(typeof rssBytes === 'number' && rssBytes >= 0 ? { rssBytes } : {}), + ...(liveRuntimeMember?.livenessKind + ? { livenessKind: liveRuntimeMember.livenessKind } + : {}), + ...(liveRuntimeMember?.pidSource ? { pidSource: liveRuntimeMember.pidSource } : {}), + ...(liveRuntimeMember?.processCommand + ? { processCommand: liveRuntimeMember.processCommand } + : {}), + ...(liveRuntimeMember?.tmuxPaneId ? { paneId: liveRuntimeMember.tmuxPaneId } : {}), + ...(liveRuntimeMember?.panePid ? { panePid: liveRuntimeMember.panePid } : {}), + ...(liveRuntimeMember?.paneCurrentCommand + ? { paneCurrentCommand: liveRuntimeMember.paneCurrentCommand } + : {}), + ...(liveRuntimeMember?.metricsPid ? { runtimePid: liveRuntimeMember.metricsPid } : {}), + ...(liveRuntimeMember?.runtimeSessionId + ? { runtimeSessionId: liveRuntimeMember.runtimeSessionId } + : {}), + ...(liveRuntimeMember?.runtimeLastSeenAt + ? { runtimeLastSeenAt: liveRuntimeMember.runtimeLastSeenAt } + : {}), + ...(historicalBootstrapConfirmed ? { historicalBootstrapConfirmed: true } : {}), + ...(liveRuntimeMember?.runtimeDiagnostic + ? { runtimeDiagnostic: liveRuntimeMember.runtimeDiagnostic } + : {}), + ...(liveRuntimeMember?.runtimeDiagnosticSeverity + ? { runtimeDiagnosticSeverity: liveRuntimeMember.runtimeDiagnosticSeverity } + : {}), + ...(liveRuntimeMember?.diagnostics ? { diagnostics: liveRuntimeMember.diagnostics } : {}), updatedAt, }; } @@ -7335,6 +7731,144 @@ export class TeamProvisioningService { } } + async skipMemberForLaunch(teamName: string, memberName: string): Promise { + const normalizedMemberName = memberName.trim(); + if (!normalizedMemberName) { + throw new Error('Member name is required'); + } + + const config = await this.configReader.getConfig(teamName); + if (!config) { + throw new Error(`Team "${teamName}" configuration is no longer available`); + } + + let metaMembers: Awaited> = []; + try { + metaMembers = await this.membersMetaStore.getMembers(teamName); + } catch { + metaMembers = []; + } + + const configuredMember = this.resolveEffectiveConfiguredMember( + config.members ?? [], + metaMembers, + normalizedMemberName + ); + if (!configuredMember) { + throw new Error(`Member "${normalizedMemberName}" is not configured in team "${teamName}"`); + } + if (configuredMember.removedAt) { + throw new Error(`Member "${normalizedMemberName}" has been removed`); + } + if (isLeadMember({ name: normalizedMemberName, agentType: configuredMember.agentType })) { + throw new Error('Lead cannot be skipped for a launch'); + } + + const runId = this.getTrackedRunId(teamName); + const run = runId ? this.runs.get(runId) : undefined; + const persistedSnapshot = await this.launchStateStore.read(teamName).catch(() => null); + const runEntry = run?.memberSpawnStatuses.get(normalizedMemberName); + const persistedMember = persistedSnapshot?.members[normalizedMemberName]; + const alreadySkipped = + runEntry?.launchState === 'skipped_for_launch' || + runEntry?.skippedForLaunch === true || + persistedMember?.launchState === 'skipped_for_launch' || + persistedMember?.skippedForLaunch === true; + + if (alreadySkipped) { + return; + } + + const failedThisLaunch = + runEntry?.launchState === 'failed_to_start' || + runEntry?.status === 'error' || + persistedMember?.launchState === 'failed_to_start' || + persistedMember?.hardFailure === true; + if (!failedThisLaunch) { + throw new Error(`Member "${normalizedMemberName}" has not failed this launch`); + } + + if (run?.pendingMemberRestarts.has(normalizedMemberName)) { + throw new Error(`Restart for teammate "${normalizedMemberName}" is already in progress`); + } + + const previousFailureReason = + runEntry?.hardFailureReason ?? + runEntry?.error ?? + persistedMember?.hardFailureReason ?? + persistedMember?.runtimeDiagnostic; + const reason = previousFailureReason?.trim() + ? `Skipped by user after launch failure: ${previousFailureReason.trim()}` + : 'Skipped by user for this launch'; + + if (run && !run.processKilled && !run.cancelRequested) { + this.agentRuntimeSnapshotCache.delete(teamName); + this.liveTeamAgentRuntimeMetadataCache.delete(teamName); + this.resetRuntimeToolActivity(run, normalizedMemberName); + this.clearMemberSpawnToolTracking(run, normalizedMemberName); + this.setMemberSpawnStatus(run, normalizedMemberName, 'skipped', reason); + if (run.isLaunch) { + await this.persistLaunchStateSnapshot( + run, + run.provisioningComplete ? 'finished' : 'active' + ); + } + + try { + await this.sendMessageToRun( + run, + `Teammate "${normalizedMemberName}" was skipped for this launch after a startup failure. Continue without waiting for this teammate unless the user retries it.` + ); + } catch (error) { + logger.debug( + `[${teamName}] Failed to notify lead about skipped teammate "${normalizedMemberName}": ${ + error instanceof Error ? error.message : String(error) + }` + ); + } + return; + } + + if (!persistedSnapshot || !persistedMember) { + throw new Error(`No launch state is available for member "${normalizedMemberName}"`); + } + + const updatedAt = nowIso(); + const nextMembers = { + ...persistedSnapshot.members, + [normalizedMemberName]: { + ...persistedMember, + launchState: 'skipped_for_launch' as const, + skippedForLaunch: true, + skipReason: reason, + skippedAt: updatedAt, + agentToolAccepted: false, + runtimeAlive: false, + bootstrapConfirmed: false, + hardFailure: false, + hardFailureReason: undefined, + pendingPermissionRequestIds: undefined, + livenessKind: undefined, + runtimeDiagnostic: undefined, + runtimeDiagnosticSeverity: undefined, + lastEvaluatedAt: updatedAt, + diagnostics: [`skipped for this launch: ${reason}`], + }, + }; + const nextSnapshot = createPersistedLaunchSnapshot({ + teamName: persistedSnapshot.teamName, + expectedMembers: persistedSnapshot.expectedMembers, + bootstrapExpectedMembers: persistedSnapshot.bootstrapExpectedMembers, + leadSessionId: persistedSnapshot.leadSessionId, + launchPhase: persistedSnapshot.launchPhase, + members: nextMembers, + updatedAt, + }); + await this.launchStateStore.write(teamName, nextSnapshot); + this.agentRuntimeSnapshotCache.delete(teamName); + this.liveTeamAgentRuntimeMetadataCache.delete(teamName); + } + private getMutableAliveRunOrThrow(teamName: string): ProvisioningRun { const runId = this.getAliveRunId(teamName); if (!runId) { @@ -7519,23 +8053,100 @@ export class TeamProvisioningService { if (!refreshed) return; if ( refreshed.launchState === 'failed_to_start' || - refreshed.launchState === 'confirmed_alive' || - refreshed.runtimeAlive + refreshed.launchState === 'confirmed_alive' ) { return; } + const refreshedFirstSpawnAcceptedAt = refreshed.firstSpawnAcceptedAt; + if (!refreshedFirstSpawnAcceptedAt) { + return; + } const restartPending = run.pendingMemberRestarts.has(memberName); + const runtimeByMember = await this.getLiveTeamAgentRuntimeMetadata(run.teamName); + const metadata = + runtimeByMember.get(memberName) ?? + [...runtimeByMember.entries()].find(([candidateName]) => + matchesObservedMemberNameForExpected(candidateName, memberName) + )?.[1]; + const acceptedAtMs = Date.parse(refreshedFirstSpawnAcceptedAt); + const elapsedMs = Number.isFinite(acceptedAtMs) ? Date.now() - acceptedAtMs : Infinity; + const runtimeDiagnostic = metadata?.runtimeDiagnostic; + if (metadata?.livenessKind === 'runtime_process') { + if (elapsedMs >= MEMBER_BOOTSTRAP_STALL_MS) { + run.memberSpawnStatuses.set(memberName, { + ...refreshed, + livenessKind: metadata.livenessKind, + runtimeDiagnostic: 'Runtime process is alive, but no bootstrap check-in after 5 min.', + runtimeDiagnosticSeverity: 'warning', + livenessLastCheckedAt: nowIso(), + }); + } + this.setMemberSpawnStatus(run, memberName, 'online', undefined, 'process'); + return; + } + if (metadata?.livenessKind === 'permission_blocked') { + const next = { + ...refreshed, + livenessKind: metadata.livenessKind, + runtimeDiagnostic: runtimeDiagnostic ?? 'waiting for permission approval', + runtimeDiagnosticSeverity: metadata.runtimeDiagnosticSeverity ?? 'warning', + livenessLastCheckedAt: nowIso(), + launchState: 'runtime_pending_permission' as const, + }; + run.memberSpawnStatuses.set(memberName, next); + this.emitMemberSpawnChange(run, memberName); + return; + } + if ( + metadata?.livenessKind === 'runtime_process_candidate' && + elapsedMs < MEMBER_BOOTSTRAP_STALL_MS + ) { + const next = { + ...refreshed, + livenessKind: metadata.livenessKind, + runtimeDiagnostic: runtimeDiagnostic ?? 'runtime process candidate detected', + runtimeDiagnosticSeverity: metadata.runtimeDiagnosticSeverity ?? 'warning', + livenessLastCheckedAt: nowIso(), + }; + run.memberSpawnStatuses.set(memberName, next); + this.emitMemberSpawnChange(run, memberName); + const stallDelayMs = Math.max( + 1_000, + Date.parse(refreshedFirstSpawnAcceptedAt) + MEMBER_BOOTSTRAP_STALL_MS - Date.now() + ); + const stallKey = `${this.getMemberLaunchGraceKey(run, memberName)}:bootstrap-stall`; + if (!this.pendingTimeouts.has(stallKey)) { + const timer = setTimeout(() => { + this.pendingTimeouts.delete(stallKey); + void this.reevaluateMemberLaunchStatus(run, memberName); + }, stallDelayMs); + timer.unref?.(); + this.pendingTimeouts.set(stallKey, timer); + } + return; + } + const strictReason = restartPending + ? buildRestartGraceTimeoutReason(memberName) + : (runtimeDiagnostic ?? + (metadata?.livenessKind === 'shell_only' + ? 'Tmux pane is alive, but no teammate runtime process was found.' + : 'Teammate did not join within the launch grace window.')); if (restartPending) { run.pendingMemberRestarts.delete(memberName); } - this.setMemberSpawnStatus( - run, - memberName, - 'error', - restartPending - ? buildRestartGraceTimeoutReason(memberName) - : 'Teammate did not join within the launch grace window.' - ); + run.memberSpawnStatuses.set(memberName, { + ...refreshed, + runtimeAlive: false, + livenessSource: undefined, + bootstrapConfirmed: false, + ...(metadata?.livenessKind ? { livenessKind: metadata.livenessKind } : {}), + ...(runtimeDiagnostic ? { runtimeDiagnostic } : {}), + ...(metadata?.runtimeDiagnosticSeverity + ? { runtimeDiagnosticSeverity: metadata.runtimeDiagnosticSeverity } + : {}), + livenessLastCheckedAt: nowIso(), + }); + this.setMemberSpawnStatus(run, memberName, 'error', strictReason); } private shouldSkipMemberSpawnAudit(run: ProvisioningRun): boolean { @@ -7544,7 +8155,11 @@ export class TeamProvisioningService { } return run.expectedMembers.every((memberName) => { const entry = run.memberSpawnStatuses.get(memberName); - return entry?.launchState === 'failed_to_start' || entry?.launchState === 'confirmed_alive'; + return ( + entry?.launchState === 'failed_to_start' || + entry?.launchState === 'confirmed_alive' || + entry?.launchState === 'skipped_for_launch' + ); }); } @@ -10011,6 +10626,7 @@ export class TeamProvisioningService { runId, providerId: 'opencode', cwd: input.request.cwd, + members: result.members, }); this.aliveRunByTeam.set(input.request.teamName, runId); } @@ -10136,6 +10752,13 @@ export class TeamProvisioningService { pendingPermissionRequestIds: evidence?.pendingPermissionRequestIds?.length ? [...new Set(evidence.pendingPermissionRequestIds)] : undefined, + ...(evidence?.runtimePid ? { runtimePid: evidence.runtimePid } : {}), + ...(evidence?.sessionId ? { runtimeSessionId: evidence.sessionId } : {}), + ...(evidence?.livenessKind ? { livenessKind: evidence.livenessKind } : {}), + ...(evidence?.pidSource ? { pidSource: evidence.pidSource } : {}), + ...(evidence?.runtimeDiagnostic ? { runtimeDiagnostic: evidence.runtimeDiagnostic } : {}), + ...(evidence?.runtimeDiagnostic ? { runtimeDiagnosticSeverity: 'info' as const } : {}), + ...(evidence?.runtimeAlive ? { runtimeLastSeenAt: now } : {}), firstSpawnAcceptedAt: evidence?.agentToolAccepted ? now : undefined, lastHeartbeatAt: evidence?.bootstrapConfirmed ? now : undefined, lastRuntimeAliveAt: evidence?.runtimeAlive ? now : undefined, @@ -12299,6 +12922,11 @@ export class TeamProvisioningService { // Read config.json to get the actual registered members const registeredNames = await this.getRegisteredTeamMemberNames(run.teamName); if (!registeredNames) { + try { + await fs.promises.access(path.join(getTeamsBasePath(), run.teamName)); + } catch { + return; + } const now = Date.now(); if ( shouldWarnOnUnreadableMemberAuditConfig({ @@ -12321,7 +12949,9 @@ export class TeamProvisioningService { const current = run.memberSpawnStatuses.get(expected); if ( current?.launchState === 'failed_to_start' || - current?.launchState === 'confirmed_alive' + current?.launchState === 'confirmed_alive' || + current?.launchState === 'skipped_for_launch' || + current?.skippedForLaunch === true ) { continue; } @@ -12409,6 +13039,8 @@ export class TeamProvisioningService { const current = run.memberSpawnStatuses.get(expected); if ( current?.launchState === 'failed_to_start' || + current?.launchState === 'skipped_for_launch' || + current?.skippedForLaunch === true || current?.bootstrapConfirmed || current?.runtimeAlive ) { @@ -12447,13 +13079,41 @@ export class TeamProvisioningService { if (!current) { continue; } + if (current.launchState === 'skipped_for_launch' || current.skippedForLaunch === true) { + nextStatuses[resolvedStatusKey] = { + ...current, + status: 'skipped', + launchState: 'skipped_for_launch', + skippedForLaunch: true, + runtimeAlive: false, + bootstrapConfirmed: false, + hardFailure: false, + hardFailureReason: undefined, + error: undefined, + livenessSource: undefined, + livenessLastCheckedAt: nowIso(), + }; + continue; + } + const runtimeDiagnostic = buildRuntimeDiagnosticForSpawn(metadata); const nextEntry: MemberSpawnStatusEntry = { ...current, ...(metadata.model ? { runtimeModel: metadata.model } : {}), + ...(metadata.livenessKind ? { livenessKind: metadata.livenessKind } : {}), + ...(runtimeDiagnostic ? { runtimeDiagnostic } : {}), + ...(metadata.runtimeDiagnosticSeverity + ? { runtimeDiagnosticSeverity: metadata.runtimeDiagnosticSeverity } + : {}), + livenessLastCheckedAt: nowIso(), }; const failureReason = current.hardFailureReason ?? current.error; + const hasStrongEvidence = isStrongRuntimeEvidence(metadata); + const hasWeakEvidence = + metadata.livenessKind != null && + !isStrongRuntimeEvidence(metadata) && + current.bootstrapConfirmed !== true; if ( - metadata.alive && + hasStrongEvidence && current.hardFailure !== true && current.launchState !== 'failed_to_start' ) { @@ -12467,7 +13127,7 @@ export class TeamProvisioningService { nextEntry.launchState = deriveMemberLaunchState(nextEntry); } if ( - metadata.alive && + hasStrongEvidence && current.launchState === 'failed_to_start' && isAutoClearableLaunchFailureReason(failureReason) ) { @@ -12480,6 +13140,26 @@ export class TeamProvisioningService { nextEntry.livenessSource = current.bootstrapConfirmed ? current.livenessSource : 'process'; nextEntry.launchState = deriveMemberLaunchState(nextEntry); } + if (hasWeakEvidence) { + nextEntry.runtimeAlive = false; + if (nextEntry.livenessSource === 'process') { + nextEntry.livenessSource = undefined; + } + if ( + current.launchState === 'runtime_pending_bootstrap' || + current.launchState === 'runtime_pending_permission' + ) { + nextEntry.agentToolAccepted = true; + } + if ( + current.status === 'online' && + current.hardFailure !== true && + current.launchState !== 'failed_to_start' + ) { + nextEntry.status = nextEntry.agentToolAccepted ? 'waiting' : 'spawning'; + } + nextEntry.launchState = deriveMemberLaunchState(nextEntry); + } nextStatuses[resolvedStatusKey] = nextEntry; } return nextStatuses; @@ -12784,10 +13464,17 @@ export class TeamProvisioningService { this.findMetaMemberModel(metaMembers, memberName); upsertMetadata(memberName, { backendType: normalizeTeamAgentRuntimeBackendType(member.backendType, false), + providerId: normalizeOptionalTeamProviderId(member.providerId), agentId: typeof member.agentId === 'string' ? member.agentId.trim() || undefined : undefined, tmuxPaneId: typeof member.tmuxPaneId === 'string' ? member.tmuxPaneId.trim() || undefined : undefined, + ...(normalizeRuntimePositiveInteger(member.runtimePid) + ? { metricsPid: normalizeRuntimePositiveInteger(member.runtimePid) } + : {}), + ...(typeof member.runtimeSessionId === 'string' && member.runtimeSessionId.trim() + ? { runtimeSessionId: member.runtimeSessionId.trim() } + : {}), ...(runtimeModel ? { model: runtimeModel } : {}), }); } @@ -12822,6 +13509,9 @@ export class TeamProvisioningService { ...(runtimeModel ? { model: runtimeModel } : {}), ...(configuredAgentId ? { agentId: configuredAgentId } : {}), ...(configuredTmuxPaneId ? { tmuxPaneId: configuredTmuxPaneId } : {}), + ...(normalizeOptionalTeamProviderId(member.providerId) + ? { providerId: normalizeOptionalTeamProviderId(member.providerId) } + : {}), ...(normalizeTeamAgentRuntimeBackendType(configuredBackendType, false) ? { backendType: normalizeTeamAgentRuntimeBackendType(configuredBackendType, false), @@ -12845,6 +13535,9 @@ export class TeamProvisioningService { this.findEffectiveRunMemberModel(run, memberName); upsertMetadata(memberName, { ...(runtimeModel ? { model: runtimeModel } : {}), + ...(normalizeOptionalTeamProviderId(member.providerId) + ? { providerId: normalizeOptionalTeamProviderId(member.providerId) } + : {}), ...(typeof member.agentId === 'string' && member.agentId.trim() ? { agentId: member.agentId.trim() } : {}), @@ -12870,100 +13563,156 @@ export class TeamProvisioningService { const runtimeModel = lane.member.model?.trim() || undefined; upsertMetadata(memberName, { backendType: 'process', - alive: evidence?.runtimeAlive === true || evidence?.agentToolAccepted === true, + providerId: 'opencode', + alive: false, + livenessKind: evidence?.livenessKind, + pidSource: evidence?.pidSource, + runtimeDiagnostic: evidence?.runtimeDiagnostic, ...(runtimeModel ? { model: runtimeModel } : {}), ...(typeof evidence?.runtimePid === 'number' && evidence.runtimePid > 0 ? { metricsPid: evidence.runtimePid } : {}), + ...(evidence?.sessionId ? { runtimeSessionId: evidence.sessionId } : {}), }); } - const shouldReadPersistedOpenCodeLaunchSnapshot = - (run?.mixedSecondaryLanes?.length ?? 0) > 0 || - configuredMembers.some( - (member) => normalizeOptionalTeamProviderId(member.providerId) === 'opencode' - ) || - metaMembers.some( - (member) => normalizeOptionalTeamProviderId(member.providerId) === 'opencode' - ); - const persistedLaunchSnapshot = shouldReadPersistedOpenCodeLaunchSnapshot - ? await this.launchStateStore.read(teamName).catch(() => null) - : null; + const currentRuntimeAdapterRun = this.runtimeAdapterRunByTeam.get(teamName); + const persistedLaunchSnapshot = await this.launchStateStore.read(teamName).catch(() => null); for (const persistedMember of Object.values(persistedLaunchSnapshot?.members ?? {})) { const memberName = persistedMember.name?.trim() ?? ''; - if ( - !memberName || - this.isMemberRemovedInMeta(metaMembers, memberName) || - persistedMember.providerId !== 'opencode' || - persistedMember.laneKind !== 'secondary' || - persistedMember.laneOwnerProviderId !== 'opencode' - ) { + if (!memberName || this.isMemberRemovedInMeta(metaMembers, memberName)) { continue; } + const currentRuntimeAdapterEvidence = currentRuntimeAdapterRun?.members?.[memberName]; upsertMetadata(memberName, { - backendType: 'process', - alive: persistedMember.runtimeAlive === true || persistedMember.bootstrapConfirmed === true, + backendType: + persistedMember.providerId === 'opencode' + ? 'process' + : metadataByMember.get(memberName)?.backendType, + providerId: persistedMember.providerId, + alive: false, + livenessKind: currentRuntimeAdapterEvidence?.livenessKind ?? persistedMember.livenessKind, + pidSource: currentRuntimeAdapterEvidence?.pidSource ?? persistedMember.pidSource, + runtimeDiagnostic: + currentRuntimeAdapterEvidence?.runtimeDiagnostic ?? persistedMember.runtimeDiagnostic, + runtimeDiagnosticSeverity: persistedMember.runtimeDiagnosticSeverity, + runtimeLastSeenAt: + persistedMember.runtimeLastSeenAt ?? + persistedMember.lastHeartbeatAt ?? + persistedMember.lastRuntimeAliveAt, ...(persistedMember.model?.trim() ? { model: persistedMember.model.trim() } : {}), - ...(typeof persistedMember.runtimePid === 'number' && persistedMember.runtimePid > 0 - ? { metricsPid: persistedMember.runtimePid } - : {}), + ...(typeof currentRuntimeAdapterEvidence?.runtimePid === 'number' && + currentRuntimeAdapterEvidence.runtimePid > 0 + ? { metricsPid: currentRuntimeAdapterEvidence.runtimePid } + : typeof persistedMember.runtimePid === 'number' && persistedMember.runtimePid > 0 + ? { metricsPid: persistedMember.runtimePid } + : {}), + ...(currentRuntimeAdapterEvidence?.sessionId + ? { runtimeSessionId: currentRuntimeAdapterEvidence.sessionId } + : persistedMember.runtimeSessionId + ? { runtimeSessionId: persistedMember.runtimeSessionId } + : {}), }); } const paneIds = [...metadataByMember.values()] .map((metadata) => metadata.tmuxPaneId?.trim() ?? '') .filter((paneId) => paneId.length > 0); - let panePidById = new Map(); + let paneInfoById = new Map(); if (paneIds.length > 0) { try { - panePidById = await listTmuxPanePidsForCurrentPlatform(paneIds); + paneInfoById = await listTmuxPaneRuntimeInfoForCurrentPlatform(paneIds); } catch (error) { logger.debug( - `[${teamName}] Failed to read tmux pane pids for runtime snapshot: ${ + `[${teamName}] Failed to read tmux pane info for runtime snapshot: ${ error instanceof Error ? error.message : String(error) }` ); } } - const unresolvedAgentIds = [...metadataByMember.values()] - .map((metadata) => metadata.agentId?.trim() ?? '') - .filter((agentId) => agentId.length > 0); - const processPidByAgentId = - unresolvedAgentIds.length > 0 - ? this.findLiveProcessPidByAgentId(teamName, unresolvedAgentIds) - : new Map(); + let processRows: Awaited> = []; + let processTableAvailable = true; + try { + processRows = await listRuntimeProcessesForCurrentTmuxPlatform(); + } catch (error) { + processTableAvailable = false; + logger.debug( + `[${teamName}] Failed to read process table for runtime snapshot: ${ + error instanceof Error ? error.message : String(error) + }` + ); + } for (const [memberName, metadata] of metadataByMember.entries()) { const paneId = metadata.tmuxPaneId?.trim() ?? ''; - const backendType = metadata.backendType; - const panePid = paneId ? panePidById.get(paneId) : undefined; - const processPid = metadata.agentId ? processPidByAgentId.get(metadata.agentId) : undefined; - const resolvedPid = - typeof panePid === 'number' && panePid > 0 - ? panePid - : typeof processPid === 'number' && processPid > 0 - ? processPid - : undefined; - const status = this.findTrackedMemberSpawnStatus(run, memberName); - const mayInferAliveFromStatusOnly = status?.launchState !== 'failed_to_start'; - const sharedRuntimeAlive = - backendType === 'process' && - typeof metadata.metricsPid === 'number' && - metadata.metricsPid > 0; - const alive = - typeof resolvedPid === 'number' && resolvedPid > 0 - ? true - : backendType === 'tmux' - ? false - : sharedRuntimeAlive - ? true - : mayInferAliveFromStatusOnly && - Boolean(status?.runtimeAlive || status?.bootstrapConfirmed); + const launchMember = persistedLaunchSnapshot?.members[memberName]; + const adapterEvidence = currentRuntimeAdapterRun?.members?.[memberName]; + const adapterStatus: MemberSpawnStatusEntry | undefined = adapterEvidence + ? { + status: adapterEvidence.hardFailure + ? 'error' + : adapterEvidence.bootstrapConfirmed + ? 'online' + : adapterEvidence.agentToolAccepted + ? 'waiting' + : 'spawning', + launchState: adapterEvidence.launchState, + ...(adapterEvidence.hardFailureReason + ? { hardFailureReason: adapterEvidence.hardFailureReason } + : {}), + ...(adapterEvidence.pendingPermissionRequestIds?.length + ? { pendingPermissionRequestIds: adapterEvidence.pendingPermissionRequestIds } + : {}), + agentToolAccepted: adapterEvidence.agentToolAccepted, + runtimeAlive: adapterEvidence.runtimeAlive, + bootstrapConfirmed: adapterEvidence.bootstrapConfirmed, + hardFailure: adapterEvidence.hardFailure, + ...(metadata.model ? { runtimeModel: metadata.model } : {}), + ...(adapterEvidence.livenessKind ? { livenessKind: adapterEvidence.livenessKind } : {}), + ...(adapterEvidence.runtimeDiagnostic + ? { runtimeDiagnostic: adapterEvidence.runtimeDiagnostic } + : {}), + updatedAt: persistedLaunchSnapshot?.updatedAt ?? nowIso(), + } + : undefined; + const status = this.findTrackedMemberSpawnStatus(run, memberName) ?? adapterStatus; + const resolved = resolveTeamMemberRuntimeLiveness({ + teamName, + memberName, + agentId: metadata.agentId, + backendType: metadata.backendType, + providerId: metadata.providerId ?? launchMember?.providerId, + tmuxPaneId: metadata.tmuxPaneId, + persistedRuntimePid: launchMember?.runtimePid ?? metadata.metricsPid, + persistedRuntimeSessionId: launchMember?.runtimeSessionId ?? metadata.runtimeSessionId, + trackedSpawnStatus: status, + runtimePid: metadata.metricsPid, + runtimeSessionId: metadata.runtimeSessionId, + pane: paneId ? paneInfoById.get(paneId) : undefined, + processRows, + processTableAvailable, + nowIso: nowIso(), + }); metadataByMember.set(memberName, { ...metadata, - alive, - ...(typeof resolvedPid === 'number' && resolvedPid > 0 ? { pid: resolvedPid } : {}), + alive: resolved.alive, + ...(typeof resolved.pid === 'number' && resolved.pid > 0 ? { pid: resolved.pid } : {}), + ...(typeof (resolved.metricsPid ?? metadata.metricsPid) === 'number' && + Number.isFinite(resolved.metricsPid ?? metadata.metricsPid) && + (resolved.metricsPid ?? metadata.metricsPid)! > 0 + ? { metricsPid: resolved.metricsPid ?? metadata.metricsPid } + : {}), + livenessKind: resolved.livenessKind, + ...(resolved.pidSource ? { pidSource: resolved.pidSource } : {}), + ...(resolved.processCommand ? { processCommand: resolved.processCommand } : {}), + ...(resolved.panePid ? { panePid: resolved.panePid } : {}), + ...(resolved.paneCurrentCommand ? { paneCurrentCommand: resolved.paneCurrentCommand } : {}), + ...(resolved.runtimeSessionId ? { runtimeSessionId: resolved.runtimeSessionId } : {}), + ...(resolved.runtimeLastSeenAt ? { runtimeLastSeenAt: resolved.runtimeLastSeenAt } : {}), + runtimeDiagnostic: resolved.runtimeDiagnostic, + runtimeDiagnosticSeverity: resolved.runtimeDiagnosticSeverity, + diagnostics: resolved.diagnostics, }); } @@ -12974,83 +13723,6 @@ export class TeamProvisioningService { return metadataByMember; } - private readUnixProcessTableRows(): { - pid: number; - command: string; - }[] { - if (process.platform === 'win32') { - return []; - } - - let output = ''; - try { - output = execFileSync('ps', ['-ax', '-o', 'pid=,command='], { - encoding: 'utf8', - stdio: ['ignore', 'pipe', 'ignore'], - }); - } catch { - return []; - } - - const rows: { pid: number; command: string }[] = []; - for (const line of output.split('\n')) { - const trimmed = line.trim(); - if (!trimmed) continue; - const match = /^(\d+)\s+(.*)$/.exec(trimmed); - if (!match) continue; - const pid = Number.parseInt(match[1], 10); - const command = match[2]?.trim() ?? ''; - if (!Number.isFinite(pid) || pid <= 0 || command.length === 0) { - continue; - } - rows.push({ - pid, - command, - }); - } - return rows; - } - - private findLiveProcessPidByAgentId( - teamName: string, - agentIds: readonly string[] - ): Map { - const normalizedAgentIds = [ - ...new Set(agentIds.map((agentId) => agentId.trim()).filter(Boolean)), - ]; - if (normalizedAgentIds.length === 0) { - return new Map(); - } - - const rows = this.readUnixProcessTableRows(); - if (rows.length === 0) { - return new Map(); - } - - const pidByAgentId = new Map(); - for (const row of rows) { - if ( - !commandContainsCliArgValue(row.command, '--team-name', teamName) || - !row.command.includes('--agent-id') - ) { - continue; - } - - for (const agentId of normalizedAgentIds) { - if (!commandContainsCliArgValue(row.command, '--agent-id', agentId)) { - continue; - } - const currentPid = pidByAgentId.get(agentId); - if (!currentPid || row.pid > currentPid) { - pidByAgentId.set(agentId, row.pid); - } - break; - } - } - - return pidByAgentId; - } - private async readProcessRssBytesByPid(pids: readonly number[]): Promise> { const uniquePids = [...new Set(pids.filter((pid) => Number.isFinite(pid) && pid > 0))]; if (uniquePids.length === 0) { @@ -13115,20 +13787,36 @@ export class TeamProvisioningService { confirmedCount: number; pendingCount: number; failedCount: number; + skippedCount?: number; runtimeAlivePendingCount: number; + shellOnlyPendingCount?: number; + runtimeProcessPendingCount?: number; + runtimeCandidatePendingCount?: number; + noRuntimePendingCount?: number; + permissionPendingCount?: number; } { const expectedMembers = run.expectedMembers ?? []; const memberSpawnStatuses = run.memberSpawnStatuses ?? new Map(); let confirmedCount = 0; let pendingCount = 0; let failedCount = 0; + let skippedCount = 0; let runtimeAlivePendingCount = 0; + let shellOnlyPendingCount = 0; + let runtimeProcessPendingCount = 0; + let runtimeCandidatePendingCount = 0; + let noRuntimePendingCount = 0; + let permissionPendingCount = 0; for (const expected of expectedMembers) { const entry = memberSpawnStatuses.get(expected) ?? createInitialMemberSpawnStatusEntry(); if (entry.launchState === 'confirmed_alive') { confirmedCount += 1; continue; } + if (entry.launchState === 'skipped_for_launch' || entry.skippedForLaunch === true) { + skippedCount += 1; + continue; + } if (entry.launchState === 'failed_to_start') { failedCount += 1; continue; @@ -13137,8 +13825,35 @@ export class TeamProvisioningService { if (entry.runtimeAlive) { runtimeAlivePendingCount += 1; } + if (entry.launchState === 'runtime_pending_permission') { + permissionPendingCount += 1; + } + if (entry.livenessKind === 'shell_only') { + shellOnlyPendingCount += 1; + } else if (entry.livenessKind === 'runtime_process') { + runtimeProcessPendingCount += 1; + } else if (entry.livenessKind === 'runtime_process_candidate') { + runtimeCandidatePendingCount += 1; + } else if ( + entry.livenessKind === 'not_found' || + entry.livenessKind === 'stale_metadata' || + entry.livenessKind === 'registered_only' + ) { + noRuntimePendingCount += 1; + } } - return { confirmedCount, pendingCount, failedCount, runtimeAlivePendingCount }; + return { + confirmedCount, + pendingCount, + failedCount, + skippedCount, + runtimeAlivePendingCount, + shellOnlyPendingCount, + runtimeProcessPendingCount, + runtimeCandidatePendingCount, + noRuntimePendingCount, + permissionPendingCount, + }; } private buildPendingBootstrapStatusMessage( @@ -13148,6 +13863,11 @@ export class TeamProvisioningService { confirmedCount: number; pendingCount: number; runtimeAlivePendingCount: number; + shellOnlyPendingCount?: number; + runtimeProcessPendingCount?: number; + runtimeCandidatePendingCount?: number; + noRuntimePendingCount?: number; + permissionPendingCount?: number; }, snapshot?: PersistedTeamLaunchSnapshot | null ): string { @@ -13169,22 +13889,34 @@ export class TeamProvisioningService { }`; } - const stillStartingCount = Math.max( - 0, - launchSummary.pendingCount - launchSummary.runtimeAlivePendingCount - ); + const runtimeProcessPendingCount = launchSummary.runtimeProcessPendingCount ?? 0; + const stillStartingCount = Math.max(0, launchSummary.pendingCount - runtimeProcessPendingCount); + const diagnosticParts = [ + launchSummary.shellOnlyPendingCount + ? `${launchSummary.shellOnlyPendingCount} shell-only` + : '', + launchSummary.runtimeProcessPendingCount + ? `${launchSummary.runtimeProcessPendingCount} waiting for bootstrap` + : '', + launchSummary.runtimeCandidatePendingCount + ? `${launchSummary.runtimeCandidatePendingCount} process candidates` + : '', + launchSummary.noRuntimePendingCount + ? `${launchSummary.noRuntimePendingCount} no runtime found` + : '', + ].filter(Boolean); + const diagnosticSuffix = diagnosticParts.length > 0 ? ` - ${diagnosticParts.join(', ')}` : ''; if (launchSummary.confirmedCount === 0) { const allRuntimeAlive = - launchSummary.runtimeAlivePendingCount > 0 && - launchSummary.runtimeAlivePendingCount === expectedTeammateCount; + runtimeProcessPendingCount > 0 && runtimeProcessPendingCount === expectedTeammateCount; return allRuntimeAlive ? `${prefix} — teammates online` - : launchSummary.runtimeAlivePendingCount > 0 - ? `${prefix} — ${launchSummary.runtimeAlivePendingCount}/${expectedTeammateCount} teammate${launchSummary.runtimeAlivePendingCount === 1 ? '' : 's'} online${stillStartingCount > 0 ? `, ${stillStartingCount} still starting` : ''}` - : `${prefix} — teammates are still starting`; + : runtimeProcessPendingCount > 0 + ? `${prefix} — ${runtimeProcessPendingCount}/${expectedTeammateCount} teammate${runtimeProcessPendingCount === 1 ? '' : 's'} online${stillStartingCount > 0 ? `, ${stillStartingCount} still starting` : ''}` + : `${prefix} — teammates are still starting${diagnosticSuffix}`; } - return `${prefix} — ${launchSummary.confirmedCount}/${expectedTeammateCount} teammates made contact${launchSummary.runtimeAlivePendingCount > 0 ? `, ${launchSummary.runtimeAlivePendingCount} teammate${launchSummary.runtimeAlivePendingCount === 1 ? '' : 's'} online` : ''}${stillStartingCount > 0 ? `${launchSummary.runtimeAlivePendingCount > 0 ? ', ' : ', '}${stillStartingCount} still joining` : ''}`; + 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( @@ -13195,6 +13927,7 @@ export class TeamProvisioningService { pendingCount: number; failedCount: number; runtimeAlivePendingCount: number; + runtimeProcessPendingCount?: number; }, snapshot?: PersistedTeamLaunchSnapshot | null ): string { @@ -13204,13 +13937,18 @@ export class TeamProvisioningService { } const persistedMemberNames = this.getPersistedLaunchMemberNames(snapshot); - const allPendingMembers = persistedMemberNames.filter((memberName) => { - const member = snapshot.members[memberName]; - if (!member) { - return false; - } - return member.launchState !== 'confirmed_alive' && member.launchState !== 'failed_to_start'; - }); + const allPendingMembers = persistedMemberNames + .filter((memberName) => { + const member = snapshot.members[memberName]; + if (!member) { + return false; + } + return member.launchState !== 'confirmed_alive' && member.launchState !== 'failed_to_start'; + }) + .filter((memberName) => { + const member = snapshot.members[memberName]; + return member?.launchState !== 'skipped_for_launch'; + }); if ( allPendingMembers.length > 0 && allPendingMembers.every((memberName) => { @@ -13239,7 +13977,11 @@ export class TeamProvisioningService { if (!member) { return true; } - return member.launchState !== 'confirmed_alive' && member.launchState !== 'failed_to_start'; + return ( + member.launchState !== 'confirmed_alive' && + member.launchState !== 'failed_to_start' && + member.launchState !== 'skipped_for_launch' + ); }); if (secondaryPendingMembers.length === 0) { return this.buildPendingBootstrapStatusMessage(prefix, run, launchSummary); @@ -13608,10 +14350,14 @@ export class TeamProvisioningService { if (filteredSnapshot.teamLaunchState === 'clean_success' && launchPhase !== 'active') { await this.clearPersistedLaunchState(run.teamName); + this.agentRuntimeSnapshotCache.delete(run.teamName); + this.liveTeamAgentRuntimeMetadataCache.delete(run.teamName); return null; } await this.launchStateStore.write(run.teamName, filteredSnapshot); + this.agentRuntimeSnapshotCache.delete(run.teamName); + this.liveTeamAgentRuntimeMetadataCache.delete(run.teamName); return filteredSnapshot; } @@ -14296,7 +15042,7 @@ export class TeamProvisioningService { }; } - const liveAgentNames = await this.getLiveTeamAgentNames(teamName); + const liveRuntimeByMember = await this.getLiveTeamAgentRuntimeMetadata(teamName); const nextMembers = { ...filteredPersisted.members }; const persistedMemberNames = this.getPersistedLaunchMemberNames(filteredPersisted); const now = nowIso(); @@ -14316,11 +15062,6 @@ export class TeamProvisioningService { current.firstSpawnAcceptedAt = current.firstSpawnAcceptedAt ?? bootstrapMember.firstSpawnAcceptedAt; } - if (bootstrapMember?.runtimeAlive && !current.runtimeAlive) { - current.runtimeAlive = true; - current.lastRuntimeAliveAt = - current.lastRuntimeAliveAt ?? bootstrapMember.lastRuntimeAliveAt; - } if (bootstrapMember?.bootstrapConfirmed && !current.bootstrapConfirmed) { current.bootstrapConfirmed = true; current.lastHeartbeatAt = current.lastHeartbeatAt ?? bootstrapMember.lastHeartbeatAt; @@ -14328,10 +15069,13 @@ export class TeamProvisioningService { const matchedConfigNames = [...configMembers].filter((name) => matchesObservedMemberNameForExpected(name, expected) ); - const observedRuntimeAlive = [...liveAgentNames].some((name) => + const runtimeMetadataCandidates = [...liveRuntimeByMember.entries()].filter(([name]) => matchesObservedMemberNameForExpected(name, expected) ); - const runtimeAlive = current.runtimeAlive === true || observedRuntimeAlive; + const runtimeMetadata = + runtimeMetadataCandidates.find(([, metadata]) => metadata.alive) ?? + runtimeMetadataCandidates[0]; + const observedRuntimeAlive = runtimeMetadata?.[1].alive === true; const heartbeatMessage = this.selectLatestLeadInboxLaunchReconcileMessage( leadInboxMessages, persistedMemberNames, @@ -14343,11 +15087,15 @@ export class TeamProvisioningService { : null; const acceptedAtMs = current.firstSpawnAcceptedAt != null ? Date.parse(current.firstSpawnAcceptedAt) : NaN; - current.runtimeAlive = runtimeAlive; + current.runtimeAlive = observedRuntimeAlive; current.lastRuntimeAliveAt = observedRuntimeAlive ? now : current.lastRuntimeAliveAt; + current.livenessKind = runtimeMetadata?.[1].livenessKind; + current.pidSource = runtimeMetadata?.[1].pidSource; + current.runtimeDiagnostic = runtimeMetadata?.[1].runtimeDiagnostic; + current.runtimeDiagnosticSeverity = runtimeMetadata?.[1].runtimeDiagnosticSeverity; current.sources = { ...(current.sources ?? {}), - processAlive: runtimeAlive || undefined, + processAlive: observedRuntimeAlive || undefined, configRegistered: matchedConfigNames.length > 0 || undefined, configDrift: heartbeatMessage != null && matchedConfigNames.length === 0 @@ -15116,13 +15864,14 @@ export class TeamProvisioningService { run.processKilled = true; run.cancelRequested = true; killTeamProcess(run.child); - if (this.hasSecondaryRuntimeRuns(teamName)) { - await this.stopMixedSecondaryRuntimeLanes(teamName); - } + const stopSecondaryRuntimeLanes = this.hasSecondaryRuntimeRuns(teamName) + ? this.stopMixedSecondaryRuntimeLanes(teamName) + : null; const progress = updateProgress(run, 'disconnected', 'Team stopped by user'); run.onProgress(progress); this.cleanupRun(run); logger.info(`[${teamName}] Process stopped (SIGKILL)`); + await stopSecondaryRuntimeLanes; } private getShutdownTrackedTeamNames(): string[] { @@ -15514,8 +16263,8 @@ export class TeamProvisioningService { killTrackedCliProcesses('SIGKILL'); this.killTransientProbeProcessesForShutdown(); - await this.cancelPendingRuntimeAdapterLaunchesForShutdown(); const initialTracked = await this.stopTrackedTeamsForShutdown('Shutdown'); + await this.cancelPendingRuntimeAdapterLaunchesForShutdown(); // A create/launch may have been inside a per-team lock before it exposed a // run in provisioningRunByTeam. Wait briefly, then rescan to catch anything @@ -15650,7 +16399,15 @@ export class TeamProvisioningService { ); return true; } - this.setMemberSpawnStatus(run, memberName, 'online', undefined, 'process'); + this.agentRuntimeSnapshotCache.delete(run.teamName); + this.liveTeamAgentRuntimeMetadataCache.delete(run.teamName); + this.setMemberSpawnStatus(run, memberName, 'waiting'); + this.appendMemberBootstrapDiagnostic( + run, + memberName, + 'already_running requires strong runtime verification' + ); + void this.reevaluateMemberLaunchStatus(run, memberName); return true; } diff --git a/src/main/services/team/TeamRuntimeLivenessResolver.ts b/src/main/services/team/TeamRuntimeLivenessResolver.ts new file mode 100644 index 00000000..804f9e75 --- /dev/null +++ b/src/main/services/team/TeamRuntimeLivenessResolver.ts @@ -0,0 +1,383 @@ +import type { RuntimeProcessTableRow, TmuxPaneRuntimeInfo } from '@features/tmux-installer/main'; +import type { + MemberSpawnStatusEntry, + TeamAgentRuntimeBackendType, + TeamAgentRuntimeDiagnosticSeverity, + TeamAgentRuntimeLivenessKind, + TeamAgentRuntimePidSource, + TeamProviderId, +} from '@shared/types'; + +export interface ResolveTeamMemberRuntimeLivenessInput { + teamName: string; + memberName: string; + agentId?: string; + backendType?: TeamAgentRuntimeBackendType; + providerId?: TeamProviderId; + tmuxPaneId?: string; + persistedRuntimePid?: number; + persistedRuntimeSessionId?: string; + trackedSpawnStatus?: MemberSpawnStatusEntry; + runtimePid?: number; + runtimeSessionId?: string; + pane?: TmuxPaneRuntimeInfo; + processRows: readonly RuntimeProcessTableRow[]; + processTableAvailable: boolean; + nowIso: string; +} + +export interface ResolvedTeamMemberRuntimeLiveness { + alive: boolean; + livenessKind: TeamAgentRuntimeLivenessKind; + pidSource?: TeamAgentRuntimePidSource; + pid?: number; + metricsPid?: number; + panePid?: number; + paneCurrentCommand?: string; + processCommand?: string; + runtimeSessionId?: string; + runtimeLastSeenAt?: string; + runtimeDiagnostic: string; + runtimeDiagnosticSeverity: TeamAgentRuntimeDiagnosticSeverity; + diagnostics: string[]; +} + +const SHELL_COMMAND_NAMES = new Set(['sh', 'bash', 'zsh', 'fish', 'dash', 'login', 'tmux']); +const SECRET_FLAG_PATTERN = + /(--(?:api-key|token|password|secret|authorization|auth-token)(?:=|\s+))("[^"]*"|'[^']*'|\S+)/gi; + +function basenameCommand(command: string | undefined): string { + const firstToken = command?.trim().split(/\s+/, 1)[0] ?? ''; + const base = firstToken.split(/[\\/]/).pop() ?? firstToken; + return base.replace(/^-/, '').toLowerCase(); +} + +export function isShellLikeCommand(command: string | undefined): boolean { + return SHELL_COMMAND_NAMES.has(basenameCommand(command)); +} + +export function sanitizeProcessCommandForDiagnostics( + command: string | undefined +): string | undefined { + const trimmed = command?.trim(); + if (!trimmed) return undefined; + return trimmed.replace(SECRET_FLAG_PATTERN, '$1[redacted]').slice(0, 500); +} + +function escapeRegexLiteral(value: string): string { + return value.replace(/[.*+?^${}()|[\]\\]/g, '\\$&'); +} + +export function extractCliArgValues(command: string, argName: string): string[] { + const escapedArg = escapeRegexLiteral(argName); + const pattern = new RegExp( + `(?:^|\\s)${escapedArg}(?:=|\\s+)("([^"]*)"|'([^']*)'|([^\\s]+))`, + 'g' + ); + + const values: string[] = []; + for (const match of command.matchAll(pattern)) { + const value = (match[2] ?? match[3] ?? match[4] ?? '').trim(); + if (value) values.push(value); + } + return values; +} + +export function commandArgEquals( + command: string, + argName: string, + expected: string | undefined +): boolean { + const normalizedExpected = expected?.trim(); + if (!normalizedExpected) return false; + return extractCliArgValues(command, argName).some((value) => value === normalizedExpected); +} + +function collectDescendants( + rows: readonly RuntimeProcessTableRow[], + rootPid: number +): RuntimeProcessTableRow[] { + const childrenByParent = new Map(); + for (const row of rows) { + const current = childrenByParent.get(row.ppid) ?? []; + current.push(row); + childrenByParent.set(row.ppid, current); + } + + const descendants: RuntimeProcessTableRow[] = []; + const queue = [...(childrenByParent.get(rootPid) ?? [])]; + const seen = new Set(); + while (queue.length > 0) { + const row = queue.shift(); + if (!row || seen.has(row.pid)) continue; + seen.add(row.pid); + descendants.push(row); + queue.push(...(childrenByParent.get(row.pid) ?? [])); + } + return descendants; +} + +function isVerifiedRuntimeProcess(params: { + row: RuntimeProcessTableRow; + teamName: string; + agentId?: string; +}): boolean { + return ( + commandArgEquals(params.row.command, '--team-name', params.teamName) && + commandArgEquals(params.row.command, '--agent-id', params.agentId) + ); +} + +function isOpenCodeRuntimeProcess(command: string | undefined): boolean { + return (command ?? '').toLowerCase().includes('opencode'); +} + +function hasPersistedEvidence(input: ResolveTeamMemberRuntimeLivenessInput): boolean { + return Boolean( + input.agentId?.trim() || + input.tmuxPaneId?.trim() || + input.persistedRuntimePid || + input.runtimePid || + input.persistedRuntimeSessionId?.trim() || + input.runtimeSessionId?.trim() || + input.backendType + ); +} + +function result(params: { + alive: boolean; + livenessKind: TeamAgentRuntimeLivenessKind; + runtimeDiagnostic: string; + runtimeDiagnosticSeverity?: TeamAgentRuntimeDiagnosticSeverity; + diagnostics?: string[]; + pidSource?: TeamAgentRuntimePidSource; + pid?: number; + metricsPid?: number; + panePid?: number; + paneCurrentCommand?: string; + processCommand?: string; + runtimeSessionId?: string; + runtimeLastSeenAt?: string; +}): ResolvedTeamMemberRuntimeLiveness { + return { + alive: params.alive, + livenessKind: params.livenessKind, + runtimeDiagnostic: params.runtimeDiagnostic, + runtimeDiagnosticSeverity: params.runtimeDiagnosticSeverity ?? 'info', + diagnostics: params.diagnostics ?? [params.runtimeDiagnostic], + ...(params.pidSource ? { pidSource: params.pidSource } : {}), + ...(typeof params.pid === 'number' && params.pid > 0 ? { pid: params.pid } : {}), + ...(typeof params.metricsPid === 'number' && params.metricsPid > 0 + ? { metricsPid: params.metricsPid } + : {}), + ...(typeof params.panePid === 'number' && params.panePid > 0 + ? { panePid: params.panePid } + : {}), + ...(params.paneCurrentCommand ? { paneCurrentCommand: params.paneCurrentCommand } : {}), + ...(params.processCommand ? { processCommand: params.processCommand } : {}), + ...(params.runtimeSessionId ? { runtimeSessionId: params.runtimeSessionId } : {}), + ...(params.runtimeLastSeenAt ? { runtimeLastSeenAt: params.runtimeLastSeenAt } : {}), + }; +} + +export function resolveTeamMemberRuntimeLiveness( + input: ResolveTeamMemberRuntimeLivenessInput +): ResolvedTeamMemberRuntimeLiveness { + const tracked = input.trackedSpawnStatus; + const runtimeSessionId = input.runtimeSessionId ?? input.persistedRuntimeSessionId; + const diagnostics: string[] = []; + if (!input.processTableAvailable) { + diagnostics.push('process table unavailable'); + } + + if ( + tracked?.launchState === 'runtime_pending_permission' || + (tracked?.pendingPermissionRequestIds?.length ?? 0) > 0 + ) { + return result({ + alive: false, + livenessKind: 'permission_blocked', + runtimeSessionId, + runtimeDiagnostic: 'waiting for permission approval', + runtimeDiagnosticSeverity: 'warning', + diagnostics: [...diagnostics, 'permission approval pending'], + }); + } + + const verifiedProcess = input.processRows + .filter((row) => + isVerifiedRuntimeProcess({ row, teamName: input.teamName, agentId: input.agentId }) + ) + .sort((left, right) => right.pid - left.pid)[0]; + if (verifiedProcess) { + return result({ + alive: true, + livenessKind: 'runtime_process', + pidSource: 'agent_process_table', + pid: verifiedProcess.pid, + runtimeSessionId, + processCommand: sanitizeProcessCommandForDiagnostics(verifiedProcess.command), + runtimeDiagnostic: 'verified runtime process detected', + diagnostics: [...diagnostics, 'matched process table by team-name and agent-id'], + }); + } + + const runtimePid = input.runtimePid ?? input.persistedRuntimePid; + const runtimePidRow = + typeof runtimePid === 'number' && runtimePid > 0 + ? input.processRows.find((row) => row.pid === runtimePid) + : undefined; + if (runtimePidRow && input.providerId === 'opencode') { + const processCommand = sanitizeProcessCommandForDiagnostics(runtimePidRow.command); + if (isOpenCodeRuntimeProcess(runtimePidRow.command)) { + return result({ + alive: true, + livenessKind: 'runtime_process', + pidSource: 'opencode_bridge', + pid: runtimePidRow.pid, + runtimeSessionId, + processCommand, + runtimeDiagnostic: 'OpenCode runtime process detected', + diagnostics: [...diagnostics, 'matched OpenCode runtime pid and process identity'], + }); + } + return result({ + alive: false, + livenessKind: 'runtime_process_candidate', + pidSource: 'opencode_bridge', + pid: runtimePidRow.pid, + runtimeSessionId, + processCommand, + runtimeDiagnostic: 'OpenCode runtime pid is alive, but process identity is unverified', + runtimeDiagnosticSeverity: 'warning', + diagnostics: [ + ...diagnostics, + 'matched OpenCode runtime pid without OpenCode process identity', + ], + }); + } + + if (tracked?.bootstrapConfirmed === true || tracked?.launchState === 'confirmed_alive') { + return result({ + alive: true, + livenessKind: 'confirmed_bootstrap', + pidSource: 'runtime_bootstrap', + runtimeSessionId, + runtimeLastSeenAt: tracked.lastHeartbeatAt ?? tracked.updatedAt, + runtimeDiagnostic: 'bootstrap confirmed', + diagnostics: [...diagnostics, 'bootstrap confirmed'], + }); + } + + const pane = input.pane; + if (pane) { + const descendants = collectDescendants(input.processRows, pane.panePid); + const verifiedDescendant = descendants + .filter((row) => + isVerifiedRuntimeProcess({ row, teamName: input.teamName, agentId: input.agentId }) + ) + .sort((left, right) => right.pid - left.pid)[0]; + if (verifiedDescendant) { + return result({ + alive: true, + livenessKind: 'runtime_process', + pidSource: 'tmux_child', + pid: verifiedDescendant.pid, + panePid: pane.panePid, + paneCurrentCommand: pane.currentCommand, + runtimeSessionId, + processCommand: sanitizeProcessCommandForDiagnostics(verifiedDescendant.command), + runtimeDiagnostic: 'verified tmux runtime child detected', + diagnostics: [...diagnostics, 'matched tmux descendant by team-name and agent-id'], + }); + } + + const candidate = descendants.find((row) => !isShellLikeCommand(row.command)); + if (candidate) { + return result({ + alive: false, + livenessKind: 'runtime_process_candidate', + pidSource: 'tmux_child', + pid: candidate.pid, + panePid: pane.panePid, + paneCurrentCommand: pane.currentCommand, + runtimeSessionId, + processCommand: sanitizeProcessCommandForDiagnostics(candidate.command), + runtimeDiagnostic: 'runtime process candidate detected', + runtimeDiagnosticSeverity: 'warning', + diagnostics: [...diagnostics, 'tmux descendant found without runtime identity match'], + }); + } + + const shellOnly = isShellLikeCommand(pane.currentCommand); + return result({ + alive: false, + livenessKind: shellOnly ? 'shell_only' : 'runtime_process_candidate', + pidSource: 'tmux_pane', + pid: pane.panePid, + panePid: pane.panePid, + paneCurrentCommand: pane.currentCommand, + runtimeSessionId, + runtimeDiagnostic: shellOnly + ? `tmux pane foreground command is ${pane.currentCommand ?? 'a shell'}` + : 'tmux pane is alive, but runtime identity is not verified', + runtimeDiagnosticSeverity: shellOnly ? 'warning' : 'info', + diagnostics: [ + ...diagnostics, + shellOnly + ? `tmux pane is alive, but foreground command is ${pane.currentCommand ?? 'a shell'}` + : 'tmux pane exists, but no verified runtime process was found', + ], + }); + } + + if (runtimePid && !runtimePidRow) { + if (!input.processTableAvailable) { + return result({ + alive: false, + livenessKind: 'registered_only', + pidSource: 'persisted_metadata', + pid: runtimePid, + runtimeSessionId, + runtimeDiagnostic: 'runtime pid could not be verified because process table is unavailable', + runtimeDiagnosticSeverity: 'warning', + diagnostics: [...diagnostics, 'runtime pid could not be verified'], + }); + } + return result({ + alive: false, + livenessKind: 'stale_metadata', + pidSource: 'persisted_metadata', + pid: runtimePid, + runtimeSessionId, + runtimeDiagnostic: 'persisted runtime pid is not alive', + runtimeDiagnosticSeverity: 'warning', + diagnostics: [...diagnostics, 'persisted runtime pid was not found in process table'], + }); + } + + if (hasPersistedEvidence(input)) { + return result({ + alive: false, + livenessKind: 'registered_only', + runtimeSessionId, + runtimeDiagnostic: 'registered runtime metadata without live process', + runtimeDiagnosticSeverity: 'warning', + diagnostics: [...diagnostics, 'member has persisted runtime metadata only'], + }); + } + + return result({ + alive: false, + livenessKind: 'not_found', + runtimeDiagnostic: 'runtime process not found', + runtimeDiagnosticSeverity: 'warning', + diagnostics: [...diagnostics, 'runtime process not found'], + }); +} + +export function isStrongRuntimeEvidence( + value: { livenessKind?: TeamAgentRuntimeLivenessKind } | undefined +): boolean { + return value?.livenessKind === 'confirmed_bootstrap' || value?.livenessKind === 'runtime_process'; +} diff --git a/src/main/services/team/TeamTaskReader.ts b/src/main/services/team/TeamTaskReader.ts index d5091d05..1462ac91 100644 --- a/src/main/services/team/TeamTaskReader.ts +++ b/src/main/services/team/TeamTaskReader.ts @@ -170,6 +170,11 @@ export class TeamTaskReader { completedAt: i.completedAt, })) : undefined; + const status = (['pending', 'in_progress', 'completed', 'deleted'] as const).includes( + parsed.status as TeamTask['status'] + ) + ? (parsed.status as TeamTask['status']) + : 'pending'; const task: TeamTask = { id: typeof parsed.id === 'string' || typeof parsed.id === 'number' ? String(parsed.id) : '', @@ -192,11 +197,7 @@ export class TeamTaskReader { promptTaskRefs: normalizeTaskRefs(parsed.promptTaskRefs), owner: typeof parsed.owner === 'string' ? parsed.owner : undefined, createdBy: typeof parsed.createdBy === 'string' ? parsed.createdBy : undefined, - status: (['pending', 'in_progress', 'completed', 'deleted'] as const).includes( - parsed.status as TeamTask['status'] - ) - ? (parsed.status as TeamTask['status']) - : 'pending', + status, workIntervals, historyEvents, blocks: Array.isArray(parsed.blocks) @@ -299,6 +300,7 @@ export class TeamTaskReader { reviewState: getReviewStateFromTask({ historyEvents, reviewState: parsed.reviewState as TeamTask['reviewState'], + status, }), sourceMessageId: typeof parsed.sourceMessageId === 'string' && parsed.sourceMessageId.trim() @@ -413,6 +415,7 @@ export class TeamTaskReader { createdAt: typeof parsed.createdAt === 'string' ? parsed.createdAt : undefined, reviewState: getReviewStateFromTask({ reviewState: parsed.reviewState as TeamTask['reviewState'], + status: 'deleted', }), }; diff --git a/src/main/services/team/cliFlavor.ts b/src/main/services/team/cliFlavor.ts index e973dcba..51316f72 100644 --- a/src/main/services/team/cliFlavor.ts +++ b/src/main/services/team/cliFlavor.ts @@ -1,5 +1,3 @@ -import { configManager } from '../infrastructure/ConfigManager'; - import type { CliFlavor, CliFlavorUiOptions } from '@shared/types'; export const DEFAULT_CLI_FLAVOR: CliFlavor = 'agent_teams_orchestrator'; @@ -18,8 +16,7 @@ export function getConfiguredCliFlavor(): CliFlavor { return envOverride; } - const multimodelEnabled = configManager.getConfig().general.multimodelEnabled; - return multimodelEnabled ? 'agent_teams_orchestrator' : 'claude'; + return DEFAULT_CLI_FLAVOR; } export function getCliFlavorUiOptions(flavor: CliFlavor): CliFlavorUiOptions { diff --git a/src/main/services/team/index.ts b/src/main/services/team/index.ts index 8c6d8b04..63c45d22 100644 --- a/src/main/services/team/index.ts +++ b/src/main/services/team/index.ts @@ -22,8 +22,6 @@ export type { export { OpenCodeReadinessBridge } from './opencode/bridge/OpenCodeReadinessBridge'; export { ReviewApplierService } from './ReviewApplierService'; export type { - OpenCodeTeamLaunchMode, - OpenCodeTeamRuntimeAdapterOptions, OpenCodeTeamRuntimeBridgePort, TeamLaunchRuntimeAdapter, TeamRuntimeLaunchInput, diff --git a/src/main/services/team/opencode/bridge/OpenCodeBridgeCommandContract.ts b/src/main/services/team/opencode/bridge/OpenCodeBridgeCommandContract.ts index 39a0b95d..1ff3c554 100644 --- a/src/main/services/team/opencode/bridge/OpenCodeBridgeCommandContract.ts +++ b/src/main/services/team/opencode/bridge/OpenCodeBridgeCommandContract.ts @@ -15,8 +15,6 @@ export type OpenCodeBridgeCommandName = | 'opencode.getRuntimeTranscript' | 'opencode.recoverDeliveryJournal'; -export type OpenCodeTeamLaunchMode = 'disabled' | 'dogfood' | 'production'; - export type OpenCodeTeamLaunchBridgeState = | 'blocked' | 'launching' @@ -48,7 +46,6 @@ export interface OpenCodeTeamLaunchMemberCommandSpec { } export interface OpenCodeLaunchTeamCommandBody { - mode: OpenCodeTeamLaunchMode; runId: string; laneId: string; teamId: string; diff --git a/src/main/services/team/opencode/bridge/OpenCodeReadinessBridge.ts b/src/main/services/team/opencode/bridge/OpenCodeReadinessBridge.ts index 31c4fff5..7405f082 100644 --- a/src/main/services/team/opencode/bridge/OpenCodeReadinessBridge.ts +++ b/src/main/services/team/opencode/bridge/OpenCodeReadinessBridge.ts @@ -1,10 +1,3 @@ -import { - assertOpenCodeProductionE2EArtifactGate, - buildOpenCodeProjectPathFingerprint, - type OpenCodeProductionE2EEvidence, -} from '../e2e/OpenCodeProductionE2EEvidence'; -import { REQUIRED_AGENT_TEAMS_APP_TOOL_IDS } from '../mcp/OpenCodeMcpToolAvailability'; - import type { OpenCodeTeamRuntimeBridgePort } from '../../runtime/OpenCodeTeamRuntimeAdapter'; import type { OpenCodeTeamLaunchReadiness, @@ -23,7 +16,6 @@ import type { OpenCodeSendMessageCommandData, OpenCodeStopTeamCommandBody, OpenCodeStopTeamCommandData, - OpenCodeTeamLaunchMode, } from './OpenCodeBridgeCommandContract'; import type { OpenCodeStateChangingBridgeCommandService } from './OpenCodeStateChangingBridgeCommandService'; @@ -48,29 +40,12 @@ export interface OpenCodeReadinessBridgeOptions { sendTimeoutMs?: number; stopTimeoutMs?: number; stateChangingCommands?: Pick; - productionE2eEvidence?: OpenCodeProductionE2EEvidenceReadPort; -} - -export interface OpenCodeProductionE2EEvidenceReadPort { - read(input?: { - selectedModel?: string | null; - projectPathFingerprint?: string | null; - opencodeVersion?: string | null; - binaryFingerprint?: string | null; - capabilitySnapshotId?: string | null; - }): Promise<{ - ok: boolean; - evidence: OpenCodeProductionE2EEvidence | null; - artifactPath: string; - diagnostics: string[]; - }>; } export interface OpenCodeReadinessBridgeCommandBody { projectPath: string; selectedModel: string | null; requireExecutionProbe: boolean; - launchMode?: OpenCodeTeamLaunchMode; } const DEFAULT_READINESS_TIMEOUT_MS = 120_000; @@ -103,11 +78,7 @@ export class OpenCodeReadinessBridge implements OpenCodeTeamRuntimeBridgePort { if (result.ok) { this.lastRuntimeSnapshotsByProjectPath.set(input.projectPath, result.runtime); - return this.applyProductionE2EGate({ - input, - readiness: result.data, - runtime: result.runtime, - }); + return result.data; } this.lastRuntimeSnapshotsByProjectPath.delete(input.projectPath); @@ -122,84 +93,6 @@ export class OpenCodeReadinessBridge implements OpenCodeTeamRuntimeBridgePort { }); } - private async applyProductionE2EGate(input: { - input: OpenCodeReadinessBridgeCommandBody; - readiness: OpenCodeTeamLaunchReadiness; - runtime: OpenCodeBridgeRuntimeSnapshot; - }): Promise { - const launchMode = input.input.launchMode; - if (launchMode !== 'production' && launchMode !== 'dogfood') { - return input.readiness; - } - if (!input.readiness.launchAllowed) { - return input.readiness; - } - - const expectedModel = input.readiness.modelId ?? input.input.selectedModel; - const projectPathFingerprint = buildOpenCodeProjectPathFingerprint(input.input.projectPath); - const evidenceRead = this.options.productionE2eEvidence - ? await this.options.productionE2eEvidence.read({ - selectedModel: expectedModel, - projectPathFingerprint, - opencodeVersion: input.runtime.version, - binaryFingerprint: input.runtime.binaryFingerprint, - capabilitySnapshotId: input.runtime.capabilitySnapshotId, - }) - : { - ok: false, - evidence: null, - artifactPath: '', - diagnostics: ['OpenCode production E2E evidence store is not configured'], - }; - const gate = evidenceRead.ok - ? assertOpenCodeProductionE2EArtifactGate({ - evidence: evidenceRead.evidence, - artifactPath: evidenceRead.artifactPath, - expected: { - opencodeVersion: input.runtime.version, - binaryFingerprint: input.runtime.binaryFingerprint, - capabilitySnapshotId: input.runtime.capabilitySnapshotId, - selectedModel: expectedModel, - projectPathFingerprint, - requiredMcpTools: REQUIRED_AGENT_TEAMS_APP_TOOL_IDS, - }, - }) - : { - ok: false, - diagnostics: evidenceRead.diagnostics, - }; - - if (gate.ok) { - return { - ...input.readiness, - diagnostics: dedupe([...input.readiness.diagnostics, ...evidenceRead.diagnostics]), - supportLevel: 'production_supported', - }; - } - - const diagnostics = dedupe([ - ...input.readiness.diagnostics, - ...evidenceRead.diagnostics, - ...gate.diagnostics, - ]); - if (launchMode === 'dogfood') { - return { - ...input.readiness, - supportLevel: 'supported_e2e_pending', - diagnostics, - }; - } - - return { - ...input.readiness, - state: 'e2e_missing', - launchAllowed: false, - supportLevel: 'supported_e2e_pending', - missing: dedupe([...input.readiness.missing, ...gate.diagnostics]), - diagnostics, - }; - } - getLastOpenCodeRuntimeSnapshot(projectPath: string): OpenCodeBridgeRuntimeSnapshot | null { return this.lastRuntimeSnapshotsByProjectPath.get(projectPath) ?? null; } diff --git a/src/main/services/team/opencode/config/OpenCodeLaunchModeEnv.ts b/src/main/services/team/opencode/config/OpenCodeLaunchModeEnv.ts deleted file mode 100644 index ccabe61c..00000000 --- a/src/main/services/team/opencode/config/OpenCodeLaunchModeEnv.ts +++ /dev/null @@ -1,17 +0,0 @@ -import type { OpenCodeTeamLaunchMode } from '../bridge/OpenCodeBridgeCommandContract'; - -export const CLAUDE_TEAM_OPENCODE_LAUNCH_MODE_ENV = 'CLAUDE_TEAM_OPENCODE_LAUNCH_MODE'; -export const CLAUDE_TEAM_OPENCODE_DOGFOOD_ENV = 'CLAUDE_TEAM_OPENCODE_DOGFOOD'; - -export function resolveOpenCodeTeamLaunchModeFromEnv( - env: NodeJS.ProcessEnv = process.env -): OpenCodeTeamLaunchMode { - const raw = env[CLAUDE_TEAM_OPENCODE_LAUNCH_MODE_ENV]?.trim().toLowerCase(); - if (raw === 'dogfood' || raw === 'production' || raw === 'disabled') { - return raw; - } - if (env[CLAUDE_TEAM_OPENCODE_DOGFOOD_ENV] === '1') { - return 'dogfood'; - } - return 'production'; -} diff --git a/src/main/services/team/opencode/e2e/OpenCodeProductionE2EEvidence.ts b/src/main/services/team/opencode/e2e/OpenCodeProductionE2EEvidence.ts deleted file mode 100644 index 7c88adbf..00000000 --- a/src/main/services/team/opencode/e2e/OpenCodeProductionE2EEvidence.ts +++ /dev/null @@ -1,612 +0,0 @@ -import { createHash } from 'node:crypto'; -import * as path from 'node:path'; - -export const OPENCODE_PRODUCTION_E2E_EVIDENCE_SCHEMA_VERSION = 1; -export const OPENCODE_PRODUCTION_E2E_EVIDENCE_COLLECTION_SCHEMA_VERSION = 1; - -export const OPENCODE_PRODUCTION_E2E_EVIDENCE_MAX_AGE_MS = 7 * 24 * 60 * 60 * 1000; - -export const OPENCODE_PRODUCTION_E2E_READY_CHECKPOINTS = [ - 'required_tools_proven', - 'delivery_ready', - 'member_ready', - 'run_ready', -] as const; - -export const OPENCODE_PRODUCTION_E2E_REQUIRED_SIGNALS = [ - 'app_mcp_tools_visible', - 'state_changing_launch_completed', - 'session_records_persisted', - 'bootstrap_confirmed_alive', - 'canonical_log_projection_observed', - 'reconcile_completed', - 'stop_completed', - 'stale_run_rejected', -] as const; - -export type OpenCodeProductionE2ERequiredSignal = - (typeof OPENCODE_PRODUCTION_E2E_REQUIRED_SIGNALS)[number]; - -export interface OpenCodeProductionE2ECheckpointEvidence { - name: string; - observedAt: string; -} - -export interface OpenCodeProductionE2ESessionEvidence { - memberName: string; - sessionId: string; - launchState: 'confirmed_alive'; -} - -export interface OpenCodeProductionE2EEvidence { - schemaVersion: typeof OPENCODE_PRODUCTION_E2E_EVIDENCE_SCHEMA_VERSION; - evidenceId: string; - createdAt: string; - expiresAt: string; - version: string; - passed: boolean; - artifactPath: string | null; - binaryFingerprint: string; - capabilitySnapshotId: string; - selectedModel: string; - projectPathFingerprint: string | null; - requiredSignals: Record; - mcpTools: { - requiredTools: readonly string[]; - observedTools: readonly string[]; - }; - launch: { - runId: string; - teamId: string; - teamLaunchState: 'ready'; - memberCount: number; - sessions: OpenCodeProductionE2ESessionEvidence[]; - durableCheckpoints: OpenCodeProductionE2ECheckpointEvidence[]; - }; - reconcile: { - runId: string; - teamLaunchState: 'ready'; - memberCount: number; - }; - stop: { - runId: string; - stopped: true; - stoppedSessionIds: string[]; - }; - logProjection: { - observed: true; - projectedMessageCount: number; - }; - diagnostics?: string[]; -} - -export interface OpenCodeProductionE2EEvidenceCollection { - collectionSchemaVersion: typeof OPENCODE_PRODUCTION_E2E_EVIDENCE_COLLECTION_SCHEMA_VERSION; - entriesByModel: Record; -} - -export type OpenCodeProductionE2EEvidenceStoreData = - | OpenCodeProductionE2EEvidence - | OpenCodeProductionE2EEvidenceCollection - | null; - -export interface OpenCodeProductionE2EGateExpectation { - opencodeVersion: string | null; - binaryFingerprint: string | null; - capabilitySnapshotId: string | null; - /** - * The currently selected raw model id. Kept for observability and evidence - * lookup preference, but not as a hard production-proof gate. - */ - selectedModel: string | null; - projectPathFingerprint?: string | null; - requiredMcpTools?: readonly string[]; -} - -export interface OpenCodeProductionE2EGateResult { - ok: boolean; - diagnostics: string[]; -} - -export function buildOpenCodeProjectPathFingerprint( - projectPath: string | null | undefined -): string | null { - const trimmed = projectPath?.trim() ?? ''; - if (!trimmed) { - return null; - } - - const normalized = path.resolve(trimmed).replace(/\\/g, '/'); - return `project:${createHash('sha256').update(normalized).digest('hex')}`; -} - -export function validateOpenCodeProductionE2EEvidence( - value: unknown -): OpenCodeProductionE2EEvidence { - const record = asRecord(value); - if (!record) { - throw new Error('OpenCode production E2E evidence must be an object'); - } - - if (record.schemaVersion !== OPENCODE_PRODUCTION_E2E_EVIDENCE_SCHEMA_VERSION) { - throw new Error('OpenCode production E2E evidence has unsupported schemaVersion'); - } - - const evidence: OpenCodeProductionE2EEvidence = { - schemaVersion: OPENCODE_PRODUCTION_E2E_EVIDENCE_SCHEMA_VERSION, - evidenceId: requireString(record.evidenceId, 'evidenceId'), - createdAt: requireIsoDate(record.createdAt, 'createdAt'), - expiresAt: requireIsoDate(record.expiresAt, 'expiresAt'), - version: requireString(record.version, 'version'), - passed: requireBoolean(record.passed, 'passed'), - artifactPath: optionalString(record.artifactPath, 'artifactPath'), - binaryFingerprint: requireString(record.binaryFingerprint, 'binaryFingerprint'), - capabilitySnapshotId: requireString(record.capabilitySnapshotId, 'capabilitySnapshotId'), - selectedModel: requireString(record.selectedModel, 'selectedModel'), - projectPathFingerprint: optionalString(record.projectPathFingerprint, 'projectPathFingerprint'), - requiredSignals: normalizeRequiredSignals(record.requiredSignals), - mcpTools: normalizeMcpTools(record.mcpTools), - launch: normalizeLaunch(record.launch), - reconcile: normalizeReconcile(record.reconcile), - stop: normalizeStop(record.stop), - logProjection: normalizeLogProjection(record.logProjection), - diagnostics: optionalStringArray(record.diagnostics, 'diagnostics'), - }; - - return evidence; -} - -export function validateNullableOpenCodeProductionE2EEvidence( - value: unknown -): OpenCodeProductionE2EEvidence | null { - if (value === null) { - return null; - } - return validateOpenCodeProductionE2EEvidence(value); -} - -export function validateOpenCodeProductionE2EEvidenceStoreData( - value: unknown -): OpenCodeProductionE2EEvidenceStoreData { - if (value === null) { - return null; - } - - const record = asRecord(value); - if ( - record?.collectionSchemaVersion === OPENCODE_PRODUCTION_E2E_EVIDENCE_COLLECTION_SCHEMA_VERSION - ) { - return validateOpenCodeProductionE2EEvidenceCollection(record); - } - - return validateOpenCodeProductionE2EEvidence(value); -} - -export function isOpenCodeProductionE2EEvidenceCollection( - value: OpenCodeProductionE2EEvidenceStoreData -): value is OpenCodeProductionE2EEvidenceCollection { - return ( - value !== null && - typeof value === 'object' && - 'collectionSchemaVersion' in value && - value.collectionSchemaVersion === OPENCODE_PRODUCTION_E2E_EVIDENCE_COLLECTION_SCHEMA_VERSION - ); -} - -export function assertOpenCodeProductionE2EEvidenceBasics(input: { - evidence: OpenCodeProductionE2EEvidence | null; - testedVersion: string; - now?: Date; - artifactPath?: string | null; -}): OpenCodeProductionE2EGateResult { - const diagnostics: string[] = []; - const now = input.now ?? new Date(); - const artifactPath = input.artifactPath ?? input.evidence?.artifactPath ?? null; - - if (!input.evidence) { - return { - ok: false, - diagnostics: [ - 'OpenCode version is capability-compatible but production E2E evidence is missing', - ], - }; - } - - diagnostics.push(...collectArtifactShapeDiagnostics(input.evidence, now, artifactPath)); - - if (input.evidence.version !== input.testedVersion) { - diagnostics.push( - `OpenCode production E2E evidence version ${input.evidence.version} does not match tested version ${input.testedVersion}` - ); - } - - return { - ok: diagnostics.length === 0, - diagnostics, - }; -} - -export function assertOpenCodeProductionE2EArtifactGate(input: { - evidence: OpenCodeProductionE2EEvidence | null; - expected: OpenCodeProductionE2EGateExpectation; - now?: Date; - artifactPath?: string | null; -}): OpenCodeProductionE2EGateResult { - const diagnostics: string[] = []; - const now = input.now ?? new Date(); - const artifactPath = input.artifactPath ?? input.evidence?.artifactPath ?? null; - - if (!input.evidence) { - return { - ok: false, - diagnostics: [ - 'OpenCode production launch requires a current production E2E evidence artifact', - ], - }; - } - - diagnostics.push(...collectArtifactShapeDiagnostics(input.evidence, now, artifactPath)); - diagnostics.push(...collectExpectedRuntimeDiagnostics(input.evidence, input.expected)); - - return { - ok: diagnostics.length === 0, - diagnostics, - }; -} - -function collectArtifactShapeDiagnostics( - evidence: OpenCodeProductionE2EEvidence, - now: Date, - artifactPath: string | null -): string[] { - const diagnostics: string[] = []; - const createdAtMs = Date.parse(evidence.createdAt); - const expiresAtMs = Date.parse(evidence.expiresAt); - - if (!evidence.passed) { - diagnostics.push('OpenCode production E2E evidence did not pass'); - } - - if (!artifactPath) { - diagnostics.push('OpenCode production E2E evidence artifact path is missing'); - } - - if (!Number.isFinite(createdAtMs)) { - diagnostics.push('OpenCode production E2E evidence createdAt is invalid'); - } else if (now.getTime() - createdAtMs > OPENCODE_PRODUCTION_E2E_EVIDENCE_MAX_AGE_MS) { - diagnostics.push('OpenCode production E2E evidence is older than the maximum allowed age'); - } - - if (!Number.isFinite(expiresAtMs)) { - diagnostics.push('OpenCode production E2E evidence expiresAt is invalid'); - } else if (expiresAtMs <= now.getTime()) { - diagnostics.push('OpenCode production E2E evidence is expired'); - } - - const missingSignals = OPENCODE_PRODUCTION_E2E_REQUIRED_SIGNALS.filter( - (signal) => evidence.requiredSignals[signal] !== true - ); - if (missingSignals.length > 0) { - diagnostics.push( - `OpenCode production E2E evidence is missing signals: ${missingSignals.join(', ')}` - ); - } - - const checkpointNames = new Set( - evidence.launch.durableCheckpoints.map((checkpoint) => checkpoint.name) - ); - const missingCheckpoints = OPENCODE_PRODUCTION_E2E_READY_CHECKPOINTS.filter( - (checkpoint) => !checkpointNames.has(checkpoint) - ); - if (missingCheckpoints.length > 0) { - diagnostics.push( - `OpenCode production E2E evidence is missing durable checkpoints: ${missingCheckpoints.join(', ')}` - ); - } - - if ( - evidence.launch.memberCount <= 0 || - evidence.launch.sessions.length !== evidence.launch.memberCount - ) { - diagnostics.push( - 'OpenCode production E2E evidence must include confirmed session evidence for every member' - ); - } - - if (evidence.reconcile.runId !== evidence.launch.runId) { - diagnostics.push( - 'OpenCode production E2E reconcile evidence runId does not match launch runId' - ); - } - - if (evidence.reconcile.memberCount !== evidence.launch.memberCount) { - diagnostics.push( - 'OpenCode production E2E reconcile member count does not match launch member count' - ); - } - - if (evidence.stop.runId !== evidence.launch.runId) { - diagnostics.push('OpenCode production E2E stop evidence runId does not match launch runId'); - } - - if (evidence.stop.stoppedSessionIds.length < evidence.launch.sessions.length) { - diagnostics.push( - 'OpenCode production E2E evidence does not prove every launched session was stopped' - ); - } - - if (evidence.logProjection.projectedMessageCount <= 0) { - diagnostics.push('OpenCode production E2E evidence must include projected log messages'); - } - - const observedTools = new Set(evidence.mcpTools.observedTools); - const missingTools = evidence.mcpTools.requiredTools.filter((tool) => !observedTools.has(tool)); - if (missingTools.length > 0) { - diagnostics.push( - `OpenCode production E2E evidence is missing observed MCP tools: ${missingTools.join(', ')}` - ); - } - - return diagnostics; -} - -function collectExpectedRuntimeDiagnostics( - evidence: OpenCodeProductionE2EEvidence, - expected: OpenCodeProductionE2EGateExpectation -): string[] { - const diagnostics: string[] = []; - - if (!expected.opencodeVersion) { - diagnostics.push('OpenCode production gate cannot verify runtime version'); - } else if (evidence.version !== expected.opencodeVersion) { - diagnostics.push( - `OpenCode production E2E evidence version ${evidence.version} does not match runtime version ${expected.opencodeVersion}` - ); - } - - if (!expected.binaryFingerprint) { - diagnostics.push('OpenCode production gate cannot verify runtime binary fingerprint'); - } else if (evidence.binaryFingerprint !== expected.binaryFingerprint) { - diagnostics.push( - 'OpenCode production E2E evidence binary fingerprint does not match runtime binary fingerprint' - ); - } - - if (!expected.capabilitySnapshotId) { - diagnostics.push('OpenCode production gate cannot verify capability snapshot id'); - } else if (evidence.capabilitySnapshotId !== expected.capabilitySnapshotId) { - diagnostics.push( - 'OpenCode production E2E evidence capability snapshot does not match current runtime' - ); - } - - if ( - expected.projectPathFingerprint && - evidence.projectPathFingerprint !== expected.projectPathFingerprint - ) { - diagnostics.push( - 'OpenCode production E2E evidence project context does not match the current working directory' - ); - } - - const requiredTools = expected.requiredMcpTools ?? []; - if (requiredTools.length > 0) { - const observedTools = new Set(evidence.mcpTools.observedTools); - const missingTools = requiredTools.filter((tool) => !observedTools.has(tool)); - if (missingTools.length > 0) { - diagnostics.push( - `OpenCode production E2E evidence does not prove required app MCP tools: ${missingTools.join(', ')}` - ); - } - } - - return diagnostics; -} - -function normalizeRequiredSignals( - value: unknown -): Record { - const record = asRecord(value); - if (!record) { - throw new Error('OpenCode production E2E evidence requiredSignals must be an object'); - } - - return Object.fromEntries( - OPENCODE_PRODUCTION_E2E_REQUIRED_SIGNALS.map((signal) => [ - signal, - requireBoolean(record[signal], `requiredSignals.${signal}`), - ]) - ) as Record; -} - -function normalizeMcpTools(value: unknown): OpenCodeProductionE2EEvidence['mcpTools'] { - const record = asRecord(value); - if (!record) { - throw new Error('OpenCode production E2E evidence mcpTools must be an object'); - } - return { - requiredTools: requireStringArray(record.requiredTools, 'mcpTools.requiredTools'), - observedTools: requireStringArray(record.observedTools, 'mcpTools.observedTools'), - }; -} - -function validateOpenCodeProductionE2EEvidenceCollection( - value: Record -): OpenCodeProductionE2EEvidenceCollection { - const entriesRecord = asRecord(value.entriesByModel); - if (!entriesRecord) { - throw new Error('OpenCode production E2E evidence collection entriesByModel must be an object'); - } - - const entries: Record = {}; - for (const [entryKey, rawEvidence] of Object.entries(entriesRecord)) { - const trimmedEntryKey = entryKey.trim(); - if (!trimmedEntryKey) { - throw new Error('OpenCode production E2E evidence collection key must be non-empty'); - } - - const evidence = validateOpenCodeProductionE2EEvidence(rawEvidence); - entries[trimmedEntryKey] = evidence; - } - - return { - collectionSchemaVersion: OPENCODE_PRODUCTION_E2E_EVIDENCE_COLLECTION_SCHEMA_VERSION, - entriesByModel: entries, - }; -} - -function normalizeLaunch(value: unknown): OpenCodeProductionE2EEvidence['launch'] { - const record = asRecord(value); - if (!record) { - throw new Error('OpenCode production E2E evidence launch must be an object'); - } - if (record.teamLaunchState !== 'ready') { - throw new Error('OpenCode production E2E evidence launch.teamLaunchState must be ready'); - } - return { - runId: requireString(record.runId, 'launch.runId'), - teamId: requireString(record.teamId, 'launch.teamId'), - teamLaunchState: 'ready', - memberCount: requirePositiveInteger(record.memberCount, 'launch.memberCount'), - sessions: requireArray(record.sessions, 'launch.sessions').map(normalizeSession), - durableCheckpoints: requireArray(record.durableCheckpoints, 'launch.durableCheckpoints').map( - normalizeCheckpoint - ), - }; -} - -function normalizeSession(value: unknown): OpenCodeProductionE2ESessionEvidence { - const record = asRecord(value); - if (!record) { - throw new Error('OpenCode production E2E evidence launch session must be an object'); - } - if (record.launchState !== 'confirmed_alive') { - throw new Error('OpenCode production E2E evidence launch session must be confirmed_alive'); - } - return { - memberName: requireString(record.memberName, 'launch.sessions.memberName'), - sessionId: requireString(record.sessionId, 'launch.sessions.sessionId'), - launchState: 'confirmed_alive', - }; -} - -function normalizeCheckpoint(value: unknown): OpenCodeProductionE2ECheckpointEvidence { - const record = asRecord(value); - if (!record) { - throw new Error('OpenCode production E2E evidence durable checkpoint must be an object'); - } - return { - name: requireString(record.name, 'launch.durableCheckpoints.name'), - observedAt: requireIsoDate(record.observedAt, 'launch.durableCheckpoints.observedAt'), - }; -} - -function normalizeReconcile(value: unknown): OpenCodeProductionE2EEvidence['reconcile'] { - const record = asRecord(value); - if (!record) { - throw new Error('OpenCode production E2E evidence reconcile must be an object'); - } - if (record.teamLaunchState !== 'ready') { - throw new Error('OpenCode production E2E evidence reconcile.teamLaunchState must be ready'); - } - return { - runId: requireString(record.runId, 'reconcile.runId'), - teamLaunchState: 'ready', - memberCount: requirePositiveInteger(record.memberCount, 'reconcile.memberCount'), - }; -} - -function normalizeStop(value: unknown): OpenCodeProductionE2EEvidence['stop'] { - const record = asRecord(value); - if (!record) { - throw new Error('OpenCode production E2E evidence stop must be an object'); - } - if (record.stopped !== true) { - throw new Error('OpenCode production E2E evidence stop.stopped must be true'); - } - return { - runId: requireString(record.runId, 'stop.runId'), - stopped: true, - stoppedSessionIds: requireStringArray(record.stoppedSessionIds, 'stop.stoppedSessionIds'), - }; -} - -function normalizeLogProjection(value: unknown): OpenCodeProductionE2EEvidence['logProjection'] { - const record = asRecord(value); - if (!record) { - throw new Error('OpenCode production E2E evidence logProjection must be an object'); - } - if (record.observed !== true) { - throw new Error('OpenCode production E2E evidence logProjection.observed must be true'); - } - return { - observed: true, - projectedMessageCount: requirePositiveInteger( - record.projectedMessageCount, - 'logProjection.projectedMessageCount' - ), - }; -} - -function asRecord(value: unknown): Record | null { - return typeof value === 'object' && value !== null && !Array.isArray(value) - ? (value as Record) - : null; -} - -function requireString(value: unknown, field: string): string { - if (typeof value !== 'string' || value.trim().length === 0) { - throw new Error(`OpenCode production E2E evidence ${field} must be a non-empty string`); - } - return value.trim(); -} - -function optionalString(value: unknown, field: string): string | null { - if (value === null || value === undefined) { - return null; - } - if (typeof value !== 'string' || value.trim().length === 0) { - throw new Error(`OpenCode production E2E evidence ${field} must be a non-empty string or null`); - } - return value.trim(); -} - -function requireBoolean(value: unknown, field: string): boolean { - if (typeof value !== 'boolean') { - throw new Error(`OpenCode production E2E evidence ${field} must be boolean`); - } - return value; -} - -function requirePositiveInteger(value: unknown, field: string): number { - if (!Number.isInteger(value) || (value as number) <= 0) { - throw new Error(`OpenCode production E2E evidence ${field} must be a positive integer`); - } - return value as number; -} - -function requireIsoDate(value: unknown, field: string): string { - const text = requireString(value, field); - if (!Number.isFinite(Date.parse(text))) { - throw new Error(`OpenCode production E2E evidence ${field} must be an ISO timestamp`); - } - return text; -} - -function requireArray(value: unknown, field: string): unknown[] { - if (!Array.isArray(value)) { - throw new Error(`OpenCode production E2E evidence ${field} must be an array`); - } - return value; -} - -function requireStringArray(value: unknown, field: string): string[] { - return requireArray(value, field).map((item, index) => requireString(item, `${field}[${index}]`)); -} - -function optionalStringArray(value: unknown, field: string): string[] | undefined { - if (value === undefined) { - return undefined; - } - return requireStringArray(value, field); -} diff --git a/src/main/services/team/opencode/e2e/OpenCodeProductionE2EEvidencePath.ts b/src/main/services/team/opencode/e2e/OpenCodeProductionE2EEvidencePath.ts deleted file mode 100644 index ee3f7b50..00000000 --- a/src/main/services/team/opencode/e2e/OpenCodeProductionE2EEvidencePath.ts +++ /dev/null @@ -1,20 +0,0 @@ -import { join, resolve } from 'path'; - -export const OPENCODE_PRODUCTION_E2E_EVIDENCE_PATH_ENV = - 'CLAUDE_TEAM_OPENCODE_PRODUCTION_E2E_EVIDENCE_PATH'; - -export const OPENCODE_PRODUCTION_E2E_EVIDENCE_FILE = 'production-e2e-evidence.json'; - -export function resolveOpenCodeProductionE2EEvidencePath(input: { - bridgeControlDir: string; - env?: NodeJS.ProcessEnv; -}): string { - const env = input.env ?? process.env; - const overridePath = env[OPENCODE_PRODUCTION_E2E_EVIDENCE_PATH_ENV]?.trim(); - - if (overridePath) { - return resolve(overridePath); - } - - return join(input.bridgeControlDir, OPENCODE_PRODUCTION_E2E_EVIDENCE_FILE); -} diff --git a/src/main/services/team/opencode/e2e/OpenCodeProductionE2EEvidenceStore.ts b/src/main/services/team/opencode/e2e/OpenCodeProductionE2EEvidenceStore.ts deleted file mode 100644 index 03b934e8..00000000 --- a/src/main/services/team/opencode/e2e/OpenCodeProductionE2EEvidenceStore.ts +++ /dev/null @@ -1,253 +0,0 @@ -import * as path from 'path'; - -import { VersionedJsonStore } from '../store/VersionedJsonStore'; - -import { - isOpenCodeProductionE2EEvidenceCollection, - OPENCODE_PRODUCTION_E2E_EVIDENCE_SCHEMA_VERSION, - type OpenCodeProductionE2EEvidence, - type OpenCodeProductionE2EEvidenceCollection, - type OpenCodeProductionE2EEvidenceStoreData, - validateOpenCodeProductionE2EEvidence, - validateOpenCodeProductionE2EEvidenceStoreData, -} from './OpenCodeProductionE2EEvidence'; - -export interface OpenCodeProductionE2EEvidenceStoreReadResult { - ok: boolean; - evidence: OpenCodeProductionE2EEvidence | null; - artifactPath: string; - diagnostics: string[]; -} - -export interface OpenCodeProductionE2EEvidenceStoreOptions { - filePath: string; - clock?: () => Date; -} - -export interface OpenCodeProductionE2EEvidenceStoreReadOptions { - /** - * Preferred exact raw model id when a matching project-scoped proof exists. - * Production proof is primarily scoped to the runtime/project integration, not - * to a mandatory per-model whitelist. - */ - selectedModel?: string | null; - projectPathFingerprint?: string | null; - opencodeVersion?: string | null; - binaryFingerprint?: string | null; - capabilitySnapshotId?: string | null; -} - -export class OpenCodeProductionE2EEvidenceStore { - private readonly filePath: string; - private readonly store: VersionedJsonStore; - - constructor(options: OpenCodeProductionE2EEvidenceStoreOptions) { - this.filePath = options.filePath; - this.store = new VersionedJsonStore({ - filePath: options.filePath, - schemaVersion: OPENCODE_PRODUCTION_E2E_EVIDENCE_SCHEMA_VERSION, - defaultData: () => null, - validate: validateOpenCodeProductionE2EEvidenceStoreData, - clock: options.clock, - quarantineDir: path.dirname(options.filePath), - }); - } - - async read( - options: OpenCodeProductionE2EEvidenceStoreReadOptions = {} - ): Promise { - const result = await this.store.read(); - if (!result.ok) { - return { - ok: false, - evidence: null, - artifactPath: this.filePath, - diagnostics: [ - `OpenCode production E2E evidence store is unreadable: ${result.message}`, - ...(result.quarantinePath - ? [`Quarantined corrupt evidence at ${result.quarantinePath}`] - : []), - ], - }; - } - - const selection = selectEvidence(result.data, options); - return { - ok: true, - evidence: selection.evidence, - artifactPath: this.filePath, - diagnostics: [ - ...selection.diagnostics, - ...(result.status === 'missing' - ? ['OpenCode production E2E evidence artifact has not been written yet'] - : []), - ], - }; - } - - async write(evidence: OpenCodeProductionE2EEvidence): Promise { - const validated = validateOpenCodeProductionE2EEvidence(evidence); - await this.store.updateLocked((current) => { - const nextEvidence = { - ...validated, - artifactPath: validated.artifactPath ?? this.filePath, - }; - return upsertEvidence(current, nextEvidence); - }); - } -} - -function selectEvidence( - data: OpenCodeProductionE2EEvidenceStoreData, - options: OpenCodeProductionE2EEvidenceStoreReadOptions -): { - evidence: OpenCodeProductionE2EEvidence | null; - diagnostics: string[]; -} { - if (!data) { - return { evidence: null, diagnostics: [] }; - } - - if (!isOpenCodeProductionE2EEvidenceCollection(data)) { - return { evidence: data, diagnostics: [] }; - } - - const modelId = options.selectedModel?.trim() ?? ''; - const projectPathFingerprint = options.projectPathFingerprint?.trim() ?? ''; - const entries = Object.values(data.entriesByModel); - const pickBestForRuntime = ( - candidates: OpenCodeProductionE2EEvidence[] - ): OpenCodeProductionE2EEvidence | null => { - const runtimeMatched = candidates.filter((entry) => runtimeIdentityMatches(entry, options)); - return pickNewestEvidence(runtimeMatched.length > 0 ? runtimeMatched : candidates); - }; - - if (projectPathFingerprint) { - const pathEntries = entries.filter( - (entry) => entry.projectPathFingerprint === projectPathFingerprint - ); - if (pathEntries.length === 0) { - return { - evidence: null, - diagnostics: [ - 'OpenCode production E2E evidence artifact has no entry for the current working directory', - ], - }; - } - - if (modelId) { - const exactModelMatch = pickBestForRuntime( - pathEntries.filter((entry) => entry.selectedModel === modelId) - ); - if (exactModelMatch) { - return { - evidence: exactModelMatch, - diagnostics: [], - }; - } - } - - return { - evidence: pickBestForRuntime(pathEntries), - diagnostics: [], - }; - } - - if (modelId) { - const exactModelEntries = entries.filter((entry) => entry.selectedModel === modelId); - if (exactModelEntries.length === 0) { - return { - evidence: null, - diagnostics: [ - `OpenCode production E2E evidence artifact has no entry for selected model ${modelId}`, - ], - }; - } - - return { - evidence: pickNewestEvidence(exactModelEntries), - diagnostics: [], - }; - } - - if (entries.length === 1) { - return { evidence: entries[0] ?? null, diagnostics: [] }; - } - - return { - evidence: null, - diagnostics: - entries.length === 0 - ? ['OpenCode production E2E evidence artifact has no model entries'] - : [ - `OpenCode production E2E evidence artifact contains ${entries.length} model entries; selected model is required`, - ], - }; -} - -function upsertEvidence( - current: OpenCodeProductionE2EEvidenceStoreData, - evidence: OpenCodeProductionE2EEvidence -): OpenCodeProductionE2EEvidenceCollection { - const entriesByModel: Record = {}; - if (isOpenCodeProductionE2EEvidenceCollection(current)) { - Object.assign(entriesByModel, current.entriesByModel); - } else if (current) { - entriesByModel[current.selectedModel] = current; - } - - entriesByModel[buildEvidenceKey(evidence)] = evidence; - return { - collectionSchemaVersion: 1, - entriesByModel, - }; -} - -function buildEvidenceKey(evidence: OpenCodeProductionE2EEvidence): string { - return [evidence.selectedModel, evidence.projectPathFingerprint ?? 'global'].join('::'); -} - -function runtimeIdentityMatches( - evidence: OpenCodeProductionE2EEvidence, - options: OpenCodeProductionE2EEvidenceStoreReadOptions -): boolean { - const expectedVersion = options.opencodeVersion?.trim() ?? ''; - if (expectedVersion && evidence.version !== expectedVersion) { - return false; - } - - const expectedBinaryFingerprint = options.binaryFingerprint?.trim() ?? ''; - if (expectedBinaryFingerprint && evidence.binaryFingerprint !== expectedBinaryFingerprint) { - return false; - } - - const expectedCapabilitySnapshotId = options.capabilitySnapshotId?.trim() ?? ''; - if ( - expectedCapabilitySnapshotId && - evidence.capabilitySnapshotId !== expectedCapabilitySnapshotId - ) { - return false; - } - - return true; -} - -function pickNewestEvidence( - entries: OpenCodeProductionE2EEvidence[] -): OpenCodeProductionE2EEvidence | null { - if (entries.length === 0) { - return null; - } - - return entries.slice(1).reduce((latest, entry) => { - const latestAt = Date.parse(latest.createdAt); - const entryAt = Date.parse(entry.createdAt); - if (!Number.isFinite(entryAt)) { - return latest; - } - if (!Number.isFinite(latestAt) || entryAt >= latestAt) { - return entry; - } - return latest; - }, entries[0]); -} diff --git a/src/main/services/team/opencode/readiness/OpenCodeTeamLaunchReadiness.ts b/src/main/services/team/opencode/readiness/OpenCodeTeamLaunchReadiness.ts index a7f8ebb9..0cf779d9 100644 --- a/src/main/services/team/opencode/readiness/OpenCodeTeamLaunchReadiness.ts +++ b/src/main/services/team/opencode/readiness/OpenCodeTeamLaunchReadiness.ts @@ -2,12 +2,10 @@ import { evaluateOpenCodeSupport, OPENCODE_TEAM_LAUNCH_VERSION_POLICY, type OpenCodeInstallMethod, - type OpenCodeProductionE2EEvidence, type OpenCodeSupportedVersionPolicy, type OpenCodeSupportLevel, } from '../version/OpenCodeVersionPolicy'; -import type { OpenCodeTeamLaunchMode } from '../bridge/OpenCodeBridgeCommandContract'; import type { OpenCodeApiCapabilities } from '../capabilities/OpenCodeApiCapabilities'; import type { OpenCodeMcpToolProof } from '../mcp/OpenCodeMcpToolAvailability'; import type { RuntimeStoreReadinessCheck } from '../store/RuntimeStoreManifest'; @@ -18,7 +16,6 @@ export type OpenCodeTeamLaunchReadinessState = | 'not_authenticated' | 'unsupported_version' | 'capabilities_missing' - | 'e2e_missing' | 'runtime_store_blocked' | 'mcp_unavailable' | 'model_unavailable' @@ -98,21 +95,8 @@ export interface OpenCodeModelExecutionProbePort { }): Promise; } -export interface OpenCodeProductionE2EEvidencePort { - read(input: { - projectPath: string; - inventory: OpenCodeRuntimeInventory; - capabilities: OpenCodeApiCapabilities; - }): Promise; -} - export interface OpenCodeTeamLaunchReadinessServiceOptions { versionPolicy?: OpenCodeSupportedVersionPolicy; - launchMode?: OpenCodeTeamLaunchMode; - /** - * @deprecated Use launchMode. Kept for callers that still pass a boolean feature gate. - */ - adapterEnabled?: boolean; } export class OpenCodeTeamLaunchReadinessService { @@ -122,7 +106,6 @@ export class OpenCodeTeamLaunchReadinessService { private readonly mcpTools: OpenCodeMcpToolProofPort, private readonly runtimeStores: OpenCodeRuntimeStoreReadinessPort, private readonly modelExecution: OpenCodeModelExecutionProbePort, - private readonly e2eEvidence: OpenCodeProductionE2EEvidencePort, private readonly options: OpenCodeTeamLaunchReadinessServiceOptions = {} ) {} @@ -130,21 +113,8 @@ export class OpenCodeTeamLaunchReadinessService { projectPath: string; selectedModel: string | null; requireExecutionProbe: boolean; - launchMode?: OpenCodeTeamLaunchMode; }): Promise { - const launchMode = resolveReadinessLaunchMode(input.launchMode, this.options); const policy = this.options.versionPolicy ?? OPENCODE_TEAM_LAUNCH_VERSION_POLICY; - const dogfoodWarnings: string[] = []; - - if (launchMode === 'disabled') { - return readiness({ - state: 'adapter_disabled', - inventory: null, - modelId: input.selectedModel, - missing: ['OpenCode team launch adapter is disabled by feature gate'], - diagnostics: ['OpenCode team launch adapter is disabled by feature gate'], - }); - } try { const inventory = await this.inventory.probe({ projectPath: input.projectPath }); @@ -184,34 +154,22 @@ export class OpenCodeTeamLaunchReadinessService { projectPath: input.projectPath, inventory, }); - const evidence = await this.e2eEvidence.read({ - projectPath: input.projectPath, - inventory, - capabilities, - }); const support = evaluateOpenCodeSupport({ version: inventory.version ?? '0.0.0', capabilities, - evidence, policy, }); if (!support.supported) { - if (launchMode === 'dogfood' && support.supportLevel === 'supported_e2e_pending') { - dogfoodWarnings.push( - 'OpenCode production E2E evidence is missing; dogfood launch remains allowed after runtime checks.' - ); - } else { - return readiness({ - state: mapSupportLevelToReadinessState(support.supportLevel), - inventory, - modelId, - capabilities, - supportLevel: support.supportLevel, - missing: support.diagnostics, - diagnostics: appendDiagnostics(inventory.diagnostics, support.diagnostics), - }); - } + return readiness({ + state: mapSupportLevelToReadinessState(support.supportLevel), + inventory, + modelId, + capabilities, + supportLevel: support.supportLevel, + missing: support.diagnostics, + diagnostics: appendDiagnostics(inventory.diagnostics, support.diagnostics), + }); } const runtimeStoreReadiness = await this.runtimeStores.check({ @@ -280,7 +238,7 @@ export class OpenCodeTeamLaunchReadinessService { runtimeStoreReadiness, supportLevel: support.supportLevel, launchAllowed: true, - diagnostics: appendDiagnostics(inventory.diagnostics, dogfoodWarnings), + diagnostics: inventory.diagnostics, }); } catch (error) { return readiness({ @@ -293,22 +251,6 @@ export class OpenCodeTeamLaunchReadinessService { } } -function resolveReadinessLaunchMode( - requested: OpenCodeTeamLaunchMode | undefined, - options: OpenCodeTeamLaunchReadinessServiceOptions -): OpenCodeTeamLaunchMode { - if (requested) { - return requested; - } - if (options.launchMode) { - return options.launchMode; - } - if (options.adapterEnabled === true) { - return 'production'; - } - return 'disabled'; -} - function readiness(input: { state: OpenCodeTeamLaunchReadinessState; inventory: OpenCodeRuntimeInventory | null; @@ -361,8 +303,6 @@ function mapSupportLevelToReadinessState( return 'unsupported_version'; case 'supported_capabilities_pending': return 'capabilities_missing'; - case 'supported_e2e_pending': - return 'e2e_missing'; case 'production_supported': return 'ready'; } diff --git a/src/main/services/team/opencode/version/OpenCodeVersionPolicy.ts b/src/main/services/team/opencode/version/OpenCodeVersionPolicy.ts index 3f9882c1..a033d4f3 100644 --- a/src/main/services/team/opencode/version/OpenCodeVersionPolicy.ts +++ b/src/main/services/team/opencode/version/OpenCodeVersionPolicy.ts @@ -1,11 +1,6 @@ import { createHash } from 'crypto'; import { promises as fs } from 'fs'; -import { - assertOpenCodeProductionE2EEvidenceBasics, - type OpenCodeProductionE2EEvidence, -} from '../e2e/OpenCodeProductionE2EEvidence'; - import type { OpenCodeApiCapabilities, OpenCodeApiEndpointKey, @@ -14,18 +9,14 @@ import type { export interface OpenCodeSupportedVersionPolicy { minimumVersion: string; - testedVersion: string; allowedPrerelease: boolean; requireCapabilities: boolean; - requireE2EArtifactsForTestedVersion: boolean; } export const OPENCODE_TEAM_LAUNCH_VERSION_POLICY: OpenCodeSupportedVersionPolicy = { minimumVersion: '1.14.19', - testedVersion: '1.14.19', allowedPrerelease: false, requireCapabilities: true, - requireE2EArtifactsForTestedVersion: true, }; export type OpenCodeInstallMethod = 'brew' | 'npm' | 'bun' | 'manual' | 'unknown'; @@ -41,11 +32,8 @@ export type OpenCodeSupportLevel = | 'unsupported_too_old' | 'unsupported_prerelease' | 'supported_capabilities_pending' - | 'supported_e2e_pending' | 'production_supported'; -export { type OpenCodeProductionE2EEvidence } from '../e2e/OpenCodeProductionE2EEvidence'; - export interface OpenCodeCompatibilitySnapshot { schemaVersion: 1; createdAt: string; @@ -57,7 +45,6 @@ export interface OpenCodeCompatibilitySnapshot { supported: boolean; supportLevel: OpenCodeSupportLevel; apiCapabilities: OpenCodeApiCapabilities; - testedEvidencePath: string | null; diagnostics: string[]; } @@ -121,7 +108,6 @@ export function shouldReuseCompatibilitySnapshot(input: { export function evaluateOpenCodeSupport(input: { version: string; capabilities: OpenCodeApiCapabilities; - evidence: OpenCodeProductionE2EEvidence | null; policy?: OpenCodeSupportedVersionPolicy; }): OpenCodeSupportDecision { const policy = input.policy ?? OPENCODE_TEAM_LAUNCH_VERSION_POLICY; @@ -157,21 +143,6 @@ export function evaluateOpenCodeSupport(input: { }; } - if (policy.requireE2EArtifactsForTestedVersion) { - const evidenceDecision = assertOpenCodeProductionE2EGate({ - evidence: input.evidence, - testedVersion: policy.testedVersion, - }); - if (!evidenceDecision.ok) { - return { - supported: false, - supportLevel: 'supported_e2e_pending', - semver: parsed, - diagnostics: evidenceDecision.diagnostics, - }; - } - } - return { supported: true, supportLevel: 'production_supported', @@ -180,14 +151,6 @@ export function evaluateOpenCodeSupport(input: { }; } -export function assertOpenCodeProductionE2EGate(input: { - evidence: OpenCodeProductionE2EEvidence | null; - testedVersion: string; - now?: Date; -}): { ok: boolean; diagnostics: string[] } { - return assertOpenCodeProductionE2EEvidenceBasics(input); -} - export function selectPermissionReplyRouteFromCache( cache: OpenCodeRouteCompatibilityCache ): OpenCodePermissionReplyRoute | null { diff --git a/src/main/services/team/progressPayload.ts b/src/main/services/team/progressPayload.ts index c2f4fce7..e7ecb51c 100644 --- a/src/main/services/team/progressPayload.ts +++ b/src/main/services/team/progressPayload.ts @@ -12,8 +12,14 @@ * diagnostics and completion-time reports. */ +import type { TeamLaunchDiagnosticItem } from '@shared/types'; + export const PROGRESS_LOG_TAIL_LINES = 200; export const PROGRESS_OUTPUT_TAIL_PARTS = 20; +export const PROGRESS_LAUNCH_DIAGNOSTICS_LIMIT = 20; +const PROGRESS_LAUNCH_DIAGNOSTIC_TEXT_LIMIT = 500; +const SECRET_FLAG_PATTERN = + /(--(?:api-key|token|password|secret|authorization|auth-token)(?:=|\s+))("[^"]*"|'[^']*'|\S+)/gi; /** * Return the trailing `maxLines` of a line-buffered CLI log, joined with "\n" @@ -50,3 +56,30 @@ export function buildProgressAssistantOutput( const joined = tail.join('\n\n'); return joined.trim().length === 0 ? undefined : joined; } + +function boundDiagnosticText(value: string | undefined): 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 > PROGRESS_LAUNCH_DIAGNOSTIC_TEXT_LIMIT + ? `${redacted.slice(0, PROGRESS_LAUNCH_DIAGNOSTIC_TEXT_LIMIT - 3).trimEnd()}...` + : redacted; +} + +export function boundLaunchDiagnostics( + items: readonly TeamLaunchDiagnosticItem[] | undefined, + maxItems: number = PROGRESS_LAUNCH_DIAGNOSTICS_LIMIT +): TeamLaunchDiagnosticItem[] | undefined { + if (!items || items.length === 0) { + return undefined; + } + + const bounded = items.slice(0, Math.max(1, maxItems)).map((item) => ({ + ...item, + label: boundDiagnosticText(item.label) ?? item.code, + detail: boundDiagnosticText(item.detail), + })); + return bounded.length > 0 ? bounded : undefined; +} diff --git a/src/main/services/team/runtime/OpenCodeTeamRuntimeAdapter.ts b/src/main/services/team/runtime/OpenCodeTeamRuntimeAdapter.ts index a9d13a24..2f13b866 100644 --- a/src/main/services/team/runtime/OpenCodeTeamRuntimeAdapter.ts +++ b/src/main/services/team/runtime/OpenCodeTeamRuntimeAdapter.ts @@ -11,7 +11,6 @@ import type { OpenCodeSendMessageCommandData, OpenCodeStopTeamCommandBody, OpenCodeStopTeamCommandData, - OpenCodeTeamLaunchMode, OpenCodeTeamMemberLaunchBridgeState, } from '../opencode/bridge/OpenCodeBridgeCommandContract'; import type { OpenCodeTeamLaunchReadiness } from '../opencode/readiness/OpenCodeTeamLaunchReadiness'; @@ -33,7 +32,6 @@ export interface OpenCodeTeamRuntimeBridgePort { projectPath: string; selectedModel: string | null; requireExecutionProbe: boolean; - launchMode?: OpenCodeTeamLaunchMode; }): Promise; getLastOpenCodeRuntimeSnapshot?(projectPath: string): OpenCodeBridgeRuntimeSnapshot | null; launchOpenCodeTeam?(input: OpenCodeLaunchTeamCommandBody): Promise; @@ -46,14 +44,6 @@ export interface OpenCodeTeamRuntimeBridgePort { ): Promise; } -export interface OpenCodeTeamRuntimeAdapterOptions { - launchMode?: OpenCodeTeamLaunchMode; - /** - * @deprecated Use launchMode. Kept for older tests/callers until the production gate is fully wired. - */ - launchEnabled?: boolean; -} - export interface OpenCodeTeamRuntimeMessageInput { runId?: string; teamName: string; @@ -76,8 +66,6 @@ export interface OpenCodeTeamRuntimeMessageResult { diagnostics: string[]; } -export { type OpenCodeTeamLaunchMode } from '../opencode/bridge/OpenCodeBridgeCommandContract'; - const REQUIRED_READY_CHECKPOINTS = new Set([ 'required_tools_proven', 'delivery_ready', @@ -90,32 +78,14 @@ export class OpenCodeTeamRuntimeAdapter implements TeamLaunchRuntimeAdapter { private readonly lastProjectPathByTeamName = new Map(); private readonly lastReadinessByProjectPath = new Map(); - constructor( - private readonly bridge: OpenCodeTeamRuntimeBridgePort, - private readonly options: OpenCodeTeamRuntimeAdapterOptions = {} - ) {} + constructor(private readonly bridge: OpenCodeTeamRuntimeBridgePort) {} async prepare(input: TeamRuntimeLaunchInput): Promise { - const configuredLaunchMode = resolveOpenCodeTeamLaunchMode(this.options); - if (configuredLaunchMode === 'disabled') { - return { - ok: false, - providerId: this.providerId, - reason: 'opencode_team_launch_disabled', - retryable: false, - diagnostics: [ - 'OpenCode team launch mode is disabled. Set CLAUDE_TEAM_OPENCODE_LAUNCH_MODE=dogfood for local dogfood testing or production after strict readiness evidence exists.', - ], - warnings: [], - }; - } - const runtimeOnly = input.runtimeOnly === true; const readiness = await this.bridge.checkOpenCodeTeamLaunchReadiness({ projectPath: input.cwd, selectedModel: input.model ?? null, requireExecutionProbe: !runtimeOnly, - launchMode: runtimeOnly ? undefined : configuredLaunchMode, }); this.lastReadinessByProjectPath.set(input.cwd, readiness); @@ -130,36 +100,12 @@ export class OpenCodeTeamRuntimeAdapter implements TeamLaunchRuntimeAdapter { }; } - const warnings = - configuredLaunchMode === 'dogfood' && !runtimeOnly - ? [ - 'OpenCode dogfood launch mode is active. This is local test mode and may run without production E2E evidence.', - ] - : []; - - if ( - !runtimeOnly && - configuredLaunchMode === 'production' && - readiness.supportLevel !== 'production_supported' - ) { - return { - ok: false, - providerId: this.providerId, - reason: 'opencode_production_e2e_evidence_missing', - retryable: false, - diagnostics: [ - 'OpenCode production launch requires strict production E2E evidence before enabling team launch.', - ], - warnings, - }; - } - return { ok: true, providerId: this.providerId, modelId: readiness.modelId, diagnostics: readiness.diagnostics, - warnings, + warnings: [], }; } @@ -177,7 +123,6 @@ export class OpenCodeTeamRuntimeAdapter implements TeamLaunchRuntimeAdapter { ); } - const configuredLaunchMode = resolveOpenCodeTeamLaunchMode(this.options); const prepared = await this.prepare(input); if (!prepared.ok) { return blockedLaunchResult(input, prepared.reason, prepared.diagnostics, prepared.warnings); @@ -199,7 +144,6 @@ export class OpenCodeTeamRuntimeAdapter implements TeamLaunchRuntimeAdapter { const runtimeSnapshot = this.bridge.getLastOpenCodeRuntimeSnapshot?.(input.cwd) ?? null; this.lastProjectPathByTeamName.set(input.teamName, input.cwd); const data = await this.bridge.launchOpenCodeTeam({ - mode: configuredLaunchMode, runId: input.runId, laneId: input.laneId?.trim() || 'primary', teamId: input.teamName, @@ -307,7 +251,7 @@ export class OpenCodeTeamRuntimeAdapter implements TeamLaunchRuntimeAdapter { providerId: this.providerId, launchState: member.launchState, agentToolAccepted: member.agentToolAccepted, - runtimeAlive: member.runtimeAlive, + runtimeAlive: member.bootstrapConfirmed === true, bootstrapConfirmed: member.bootstrapConfirmed, hardFailure: member.hardFailure, hardFailureReason: member.hardFailureReason, @@ -425,18 +369,6 @@ export class OpenCodeTeamRuntimeAdapter implements TeamLaunchRuntimeAdapter { } } -export function resolveOpenCodeTeamLaunchMode( - options: OpenCodeTeamRuntimeAdapterOptions = {} -): OpenCodeTeamLaunchMode { - if (options.launchMode) { - return options.launchMode; - } - if (options.launchEnabled === true) { - return 'production'; - } - return 'disabled'; -} - function mapOpenCodeLaunchDataToRuntimeResult( input: TeamRuntimeLaunchInput, data: OpenCodeLaunchTeamCommandData, @@ -549,9 +481,26 @@ function mapBridgeMemberToRuntimeEvidence( diagnostics: string[] ): TeamRuntimeMemberLaunchEvidence { const confirmed = launchState === 'confirmed_alive'; - const createdOrBlocked = launchState === 'created' || launchState === 'permission_blocked'; const failed = launchState === 'failed'; - const pendingRuntimeObserved = createdOrBlocked && runtimeMaterialized; + const hasRuntimePid = + typeof runtimePid === 'number' && Number.isFinite(runtimePid) && runtimePid > 0; + const pendingRuntimeObserved = launchState === 'created' && hasRuntimePid; + const livenessKind = confirmed + ? 'confirmed_bootstrap' + : pendingRuntimeObserved + ? 'runtime_process_candidate' + : launchState === 'permission_blocked' + ? 'permission_blocked' + : runtimeMaterialized || sessionId + ? 'runtime_process_candidate' + : 'registered_only'; + const runtimeDiagnostic = pendingRuntimeObserved + ? 'OpenCode runtime pid reported by bridge without local process verification' + : launchState === 'permission_blocked' + ? 'OpenCode runtime is waiting for permission approval' + : runtimeMaterialized || sessionId + ? 'OpenCode session exists without verified runtime pid' + : undefined; return { memberName, providerId: 'opencode', @@ -562,8 +511,13 @@ function mapBridgeMemberToRuntimeEvidence( : launchState === 'permission_blocked' ? 'runtime_pending_permission' : 'runtime_pending_bootstrap', - agentToolAccepted: confirmed || pendingRuntimeObserved, - runtimeAlive: confirmed || pendingRuntimeObserved, + agentToolAccepted: + confirmed || + pendingRuntimeObserved || + launchState === 'permission_blocked' || + runtimeMaterialized || + Boolean(sessionId), + runtimeAlive: confirmed, bootstrapConfirmed: confirmed, hardFailure: failed, hardFailureReason: failed ? 'OpenCode bridge reported member launch failure' : undefined, @@ -572,9 +526,10 @@ function mapBridgeMemberToRuntimeEvidence( ? [...new Set(pendingPermissionRequestIds)] : undefined, sessionId, - ...(typeof runtimePid === 'number' && Number.isFinite(runtimePid) && runtimePid > 0 - ? { runtimePid } - : {}), + ...(hasRuntimePid ? { runtimePid } : {}), + livenessKind, + ...(hasRuntimePid ? { pidSource: 'opencode_bridge' as const } : {}), + ...(runtimeDiagnostic ? { runtimeDiagnostic } : {}), diagnostics, }; } @@ -709,7 +664,6 @@ function isRetryableReadinessState(state: OpenCodeTeamLaunchReadiness['state']): return ( state === 'not_installed' || state === 'not_authenticated' || - state === 'e2e_missing' || state === 'runtime_store_blocked' || state === 'mcp_unavailable' || state === 'model_unavailable' || diff --git a/src/main/services/team/runtime/TeamRuntimeAdapter.ts b/src/main/services/team/runtime/TeamRuntimeAdapter.ts index 80847ebc..1fd06f8e 100644 --- a/src/main/services/team/runtime/TeamRuntimeAdapter.ts +++ b/src/main/services/team/runtime/TeamRuntimeAdapter.ts @@ -4,6 +4,8 @@ import type { PersistedTeamLaunchPhase, PersistedTeamLaunchSnapshot, TeamAgentRuntimeBackendType, + TeamAgentRuntimeLivenessKind, + TeamAgentRuntimePidSource, TeamLaunchAggregateState, } from '@shared/types'; @@ -73,6 +75,9 @@ export interface TeamRuntimeMemberLaunchEvidence { sessionId?: string; backendType?: TeamAgentRuntimeBackendType; runtimePid?: number; + livenessKind?: TeamAgentRuntimeLivenessKind; + pidSource?: TeamAgentRuntimePidSource; + runtimeDiagnostic?: string; diagnostics: string[]; } diff --git a/src/main/services/team/runtime/index.ts b/src/main/services/team/runtime/index.ts index 6d8909a2..bd6de5b5 100644 --- a/src/main/services/team/runtime/index.ts +++ b/src/main/services/team/runtime/index.ts @@ -1,6 +1,4 @@ export type { - OpenCodeTeamLaunchMode, - OpenCodeTeamRuntimeAdapterOptions, OpenCodeTeamRuntimeBridgePort, OpenCodeTeamRuntimeMessageInput, OpenCodeTeamRuntimeMessageResult, diff --git a/src/main/utils/pathDecoder.ts b/src/main/utils/pathDecoder.ts index 9467d667..a778d74f 100644 --- a/src/main/utils/pathDecoder.ts +++ b/src/main/utils/pathDecoder.ts @@ -31,6 +31,10 @@ export function encodePath(absolutePath: string): string { } const encoded = absolutePath.replace(/[/\\]/g, '-'); + const windowsDriveMatch = /^([a-zA-Z]):-(.*)$/.exec(encoded); + if (windowsDriveMatch) { + return `${windowsDriveMatch[1].toUpperCase()}--${windowsDriveMatch[2]}`; + } // Ensure leading dash for absolute paths return encoded.startsWith('-') ? encoded : `-${encoded}`; @@ -50,7 +54,7 @@ export function decodePath(encodedName: string): string { // Legacy Windows format observed in some Claude installs: "C--Users-name-project" // (no leading dash, drive separator encoded as "--"). - const legacyWindowsRegex = /^([a-zA-Z])--(.+)$/; + const legacyWindowsRegex = /^([a-zA-Z])--(.*)$/; const legacyWindowsMatch = legacyWindowsRegex.exec(encodedName); if (legacyWindowsMatch) { const drive = legacyWindowsMatch[1].toUpperCase(); diff --git a/src/main/workers/team-fs-worker.ts b/src/main/workers/team-fs-worker.ts index f6661a7a..96f41db0 100644 --- a/src/main/workers/team-fs-worker.ts +++ b/src/main/workers/team-fs-worker.ts @@ -97,6 +97,13 @@ interface TaskReadDiag { const MAX_LAUNCH_STATE_BYTES = 32 * 1024; const TEAM_LAUNCH_STATE_FILE = 'launch-state.json'; +const REVIEW_LIFECYCLE_EVENTS = new Set([ + 'review_requested', + 'review_changes_requested', + 'review_approved', + 'review_started', +]); +const REVIEW_RESET_STATUSES = new Set(['in_progress', 'deleted']); // --------------------------------------------------------------------------- // Parsed JSON types (loose shapes from disk) @@ -743,28 +750,66 @@ function normalizeHistoryEvents(parsed: ParsedTask): RawHistoryEvent[] | undefin .map((i) => ({ ...i })); } -/** Derive review state from historyEvents (inline reducer for worker isolation). */ -function deriveReviewStateFromEvents(events: RawHistoryEvent[] | undefined): string { - if (!Array.isArray(events) || events.length === 0) return 'none'; - for (let i = events.length - 1; i >= 0; i--) { - const e = events[i]; - const t = e.type; - if ( - t === 'review_requested' || - t === 'review_changes_requested' || - t === 'review_approved' || - t === 'review_started' - ) { - const to = typeof e.to === 'string' ? e.to : 'none'; - return to === 'review' || to === 'needsFix' || to === 'approved' ? to : 'none'; +function normalizeReviewState(value: unknown): string { + return value === 'review' || value === 'needsFix' || value === 'approved' ? value : 'none'; +} + +function normalizeFallbackReviewState(value: unknown, status: string): string { + const reviewState = normalizeReviewState(value); + if (reviewState === 'none') return 'none'; + if (status === 'in_progress' || status === 'deleted') return 'none'; + if (status === 'pending') return reviewState === 'needsFix' ? 'needsFix' : 'none'; + if (status === 'completed') { + return reviewState === 'review' || reviewState === 'approved' ? reviewState : 'none'; + } + return reviewState; +} + +function eventReviewState(event: RawHistoryEvent): string | null { + const type = typeof event.type === 'string' ? event.type : ''; + if (!REVIEW_LIFECYCLE_EVENTS.has(type)) { + return null; + } + return normalizeReviewState(event.to); +} + +function derivePendingReviewState(events: RawHistoryEvent[], startIndex: number): string { + for (let i = startIndex - 1; i >= 0; i--) { + const previous = events[i]; + const reviewState = eventReviewState(previous); + if (reviewState) { + return reviewState === 'needsFix' ? 'needsFix' : 'none'; } - if (t === 'status_changed' && e.to === 'in_progress') { + if ( + previous.type === 'task_created' || + (previous.type === 'status_changed' && + (REVIEW_RESET_STATUSES.has(String(previous.to || '')) || previous.to === 'pending')) + ) { return 'none'; } } return 'none'; } +/** Derive review state from historyEvents (inline reducer for worker isolation). */ +function deriveReviewStateFromEvents(events: RawHistoryEvent[] | undefined): string | null { + if (!Array.isArray(events) || events.length === 0) return null; + for (let i = events.length - 1; i >= 0; i--) { + const e = events[i]; + const reviewState = eventReviewState(e); + if (reviewState) { + return reviewState; + } + if (e.type === 'status_changed' && REVIEW_RESET_STATUSES.has(String(e.to || ''))) { + return 'none'; + } + if (e.type === 'status_changed' && e.to === 'pending') { + return derivePendingReviewState(events, i); + } + } + return null; +} + function normalizeComments(parsed: ParsedTask): unknown[] | undefined { if (!Array.isArray(parsed.comments)) return undefined; return (parsed.comments as unknown[]) @@ -869,7 +914,16 @@ async function readTasksDirForTeam( ? (parsed.needsClarification as string) : undefined; const historyEvents = normalizeHistoryEvents(parsed); - const reviewState = deriveReviewStateFromEvents(historyEvents); + const status = + parsed.status === 'pending' || + parsed.status === 'in_progress' || + parsed.status === 'completed' || + parsed.status === 'deleted' + ? (parsed.status as string) + : 'pending'; + const reviewState = + deriveReviewStateFromEvents(historyEvents) ?? + normalizeFallbackReviewState(parsed.reviewState, status); tasks.push({ id: typeof parsed.id === 'string' || typeof parsed.id === 'number' ? String(parsed.id) : '', @@ -896,13 +950,7 @@ async function readTasksDirForTeam( : undefined, owner: typeof parsed.owner === 'string' ? parsed.owner : undefined, createdBy: typeof parsed.createdBy === 'string' ? parsed.createdBy : undefined, - status: - parsed.status === 'pending' || - parsed.status === 'in_progress' || - parsed.status === 'completed' || - parsed.status === 'deleted' - ? (parsed.status as string) - : 'pending', + status, workIntervals: normalizeWorkIntervals(parsed), historyEvents: normalizeHistoryEvents(parsed), blocks: Array.isArray(parsed.blocks) ? (parsed.blocks as unknown[]) : undefined, diff --git a/src/preload/constants/ipcChannels.ts b/src/preload/constants/ipcChannels.ts index 95de628a..b12a70e1 100644 --- a/src/preload/constants/ipcChannels.ts +++ b/src/preload/constants/ipcChannels.ts @@ -385,6 +385,9 @@ export const TEAM_GET_AGENT_RUNTIME = 'team:getAgentRuntime'; /** Restart a specific teammate runtime */ export const TEAM_RESTART_MEMBER = 'team:restartMember'; +/** Skip a failed teammate for the current launch */ +export const TEAM_SKIP_MEMBER_FOR_LAUNCH = 'team:skipMemberForLaunch'; + /** Soft-delete a task (set status to 'deleted' with deletedAt timestamp) */ export const TEAM_SOFT_DELETE_TASK = 'team:softDeleteTask'; diff --git a/src/preload/index.ts b/src/preload/index.ts index c7bbd64f..b840f74d 100644 --- a/src/preload/index.ts +++ b/src/preload/index.ts @@ -171,6 +171,7 @@ import { TEAM_SET_TASK_LOG_STREAM_TRACKING, TEAM_SET_TOOL_ACTIVITY_TRACKING, TEAM_SHOW_MESSAGE_NOTIFICATION, + TEAM_SKIP_MEMBER_FOR_LAUNCH, TEAM_SOFT_DELETE_TASK, TEAM_START_TASK, TEAM_START_TASK_BY_USER, @@ -1093,6 +1094,9 @@ const electronAPI: ElectronAPI = { restartMember: async (teamName: string, memberName: string) => { return invokeIpcWithResult(TEAM_RESTART_MEMBER, teamName, memberName); }, + skipMemberForLaunch: async (teamName: string, memberName: string) => { + return invokeIpcWithResult(TEAM_SKIP_MEMBER_FOR_LAUNCH, teamName, memberName); + }, softDeleteTask: async (teamName: string, taskId: string) => { return invokeIpcWithResult(TEAM_SOFT_DELETE_TASK, teamName, taskId); }, diff --git a/src/renderer/api/httpClient.ts b/src/renderer/api/httpClient.ts index 746b7aad..485dc4bc 100644 --- a/src/renderer/api/httpClient.ts +++ b/src/renderer/api/httpClient.ts @@ -959,6 +959,9 @@ export class HttpAPIClient implements ElectronAPI { restartMember: async (): Promise => { throw new Error('Member restart is not available in browser mode'); }, + skipMemberForLaunch: async (): Promise => { + throw new Error('Member launch skip is not available in browser mode'); + }, softDeleteTask: async (_teamName: string, _taskId: string): Promise => { // Not available via HTTP client — no-op }, diff --git a/src/renderer/components/dashboard/CliStatusBanner.tsx b/src/renderer/components/dashboard/CliStatusBanner.tsx index 0aa434d3..01aec775 100644 --- a/src/renderer/components/dashboard/CliStatusBanner.tsx +++ b/src/renderer/components/dashboard/CliStatusBanner.tsx @@ -32,7 +32,6 @@ import { import { ProviderModelBadges } from '@renderer/components/runtime/ProviderModelBadges'; import { getProviderRuntimeBackendSummary } from '@renderer/components/runtime/ProviderRuntimeBackendSelector'; import { ProviderRuntimeSettingsDialog } from '@renderer/components/runtime/ProviderRuntimeSettingsDialog'; -import { SettingsToggle } from '@renderer/components/settings/components'; import { TerminalLogPanel } from '@renderer/components/terminal/TerminalLogPanel'; import { TerminalModal } from '@renderer/components/terminal/TerminalModal'; import { useCliInstaller } from '@renderer/hooks/useCliInstaller'; @@ -265,11 +264,8 @@ interface InstalledBannerProps { cliStatusError: string | null; providersCollapsed: boolean; isBusy: boolean; - multimodelEnabled: boolean; - multimodelBusy: boolean; onInstall: () => void; onRefresh: () => void; - onMultimodelToggle: (enabled: boolean) => void; onToggleProvidersCollapsed: () => void; onProviderLogin: (providerId: CliProviderId) => void; onProviderLogout: (providerId: CliProviderId) => void; @@ -573,11 +569,8 @@ const InstalledBanner = ({ cliStatusError, providersCollapsed, isBusy, - multimodelEnabled, - multimodelBusy, onInstall, onRefresh, - onMultimodelToggle, onToggleProvidersCollapsed, onProviderLogin, onProviderLogout, @@ -683,23 +676,6 @@ const InstalledBanner = ({ Multimodel - {multimodelEnabled && ( - - Beta - - )} - {/* Extensions button — available whenever the runtime is installed */} {canOpenExtensions && ( @@ -1033,7 +1009,6 @@ export const CliStatusBanner = (): React.JSX.Element | null => { const [manageProviderId, setManageProviderId] = useState('anthropic'); const [manageDialogOpen, setManageDialogOpen] = useState(false); const [isVerifyingAuth, setIsVerifyingAuth] = useState(false); - const [isSwitchingFlavor, setIsSwitchingFlavor] = useState(false); const [showTroubleshoot, setShowTroubleshoot] = useState(false); const [providersCollapsed, setProvidersCollapsed] = useState(() => loadDashboardCliStatusBannerCollapsed() @@ -1147,37 +1122,6 @@ export const CliStatusBanner = (): React.JSX.Element | null => { })(); }, [bootstrapCliStatus, codexAccount, fetchCliStatus, multimodelEnabled]); - const handleMultimodelToggle = useCallback( - async (enabled: boolean) => { - setIsSwitchingFlavor(true); - let nextMultimodelEnabled = multimodelEnabled; - try { - useStore.setState({ - cliStatus: enabled ? createLoadingMultimodelCliStatus() : null, - cliStatusLoading: true, - cliStatusError: null, - }); - await updateConfig('general', { multimodelEnabled: enabled }); - nextMultimodelEnabled = enabled; - await invalidateCliStatus(); - if (enabled) { - await bootstrapCliStatus({ multimodelEnabled: true }); - } else { - await fetchCliStatus(); - } - } catch { - if (nextMultimodelEnabled) { - await bootstrapCliStatus({ multimodelEnabled: true }); - } else { - await fetchCliStatus(); - } - } finally { - setIsSwitchingFlavor(false); - } - }, - [bootstrapCliStatus, fetchCliStatus, invalidateCliStatus, multimodelEnabled, updateConfig] - ); - const recheckAuthState = useCallback(() => { setIsVerifyingAuth(true); void (async () => { @@ -1422,11 +1366,8 @@ export const CliStatusBanner = (): React.JSX.Element | null => { cliStatusError={cliStatusError ?? null} providersCollapsed={providersCollapsed} isBusy={isBusy} - multimodelEnabled={multimodelEnabled} - multimodelBusy={isSwitchingFlavor} onInstall={handleInstall} onRefresh={handleRefresh} - onMultimodelToggle={(enabled) => void handleMultimodelToggle(enabled)} onToggleProvidersCollapsed={handleToggleProvidersCollapsed} onProviderLogin={handleProviderLogin} onProviderLogout={handleProviderLogout} @@ -1650,11 +1591,8 @@ export const CliStatusBanner = (): React.JSX.Element | null => { cliStatusError={cliStatusError ?? null} providersCollapsed={providersCollapsed} isBusy={isBusy} - multimodelEnabled={multimodelEnabled} - multimodelBusy={isSwitchingFlavor} onInstall={handleInstall} onRefresh={handleRefresh} - onMultimodelToggle={(enabled) => void handleMultimodelToggle(enabled)} onToggleProvidersCollapsed={handleToggleProvidersCollapsed} onProviderLogin={handleProviderLogin} onProviderLogout={handleProviderLogout} @@ -1712,11 +1650,8 @@ export const CliStatusBanner = (): React.JSX.Element | null => { cliStatusError={cliStatusError ?? null} providersCollapsed={providersCollapsed} isBusy={isBusy} - multimodelEnabled={multimodelEnabled} - multimodelBusy={isSwitchingFlavor} onInstall={handleInstall} onRefresh={handleRefresh} - onMultimodelToggle={(enabled) => void handleMultimodelToggle(enabled)} onToggleProvidersCollapsed={handleToggleProvidersCollapsed} onProviderLogin={handleProviderLogin} onProviderLogout={handleProviderLogout} @@ -1934,11 +1869,8 @@ export const CliStatusBanner = (): React.JSX.Element | null => { cliStatusError={cliStatusError ?? null} providersCollapsed={providersCollapsed} isBusy={isBusy} - multimodelEnabled={multimodelEnabled} - multimodelBusy={isSwitchingFlavor} onInstall={handleInstall} onRefresh={handleRefresh} - onMultimodelToggle={(enabled) => void handleMultimodelToggle(enabled)} onToggleProvidersCollapsed={handleToggleProvidersCollapsed} onProviderLogin={handleProviderLogin} onProviderLogout={handleProviderLogout} diff --git a/src/renderer/components/settings/sections/CliStatusSection.tsx b/src/renderer/components/settings/sections/CliStatusSection.tsx index a9e78dfb..db25a727 100644 --- a/src/renderer/components/settings/sections/CliStatusSection.tsx +++ b/src/renderer/components/settings/sections/CliStatusSection.tsx @@ -27,7 +27,6 @@ import { import { ProviderModelBadges } from '@renderer/components/runtime/ProviderModelBadges'; import { getProviderRuntimeBackendSummary } from '@renderer/components/runtime/ProviderRuntimeBackendSelector'; import { ProviderRuntimeSettingsDialog } from '@renderer/components/runtime/ProviderRuntimeSettingsDialog'; -import { SettingsToggle } from '@renderer/components/settings/components'; import { TerminalModal } from '@renderer/components/terminal/TerminalModal'; import { useCliInstaller } from '@renderer/hooks/useCliInstaller'; import { useStore } from '@renderer/store'; @@ -211,7 +210,6 @@ export const CliStatusSection = (): React.JSX.Element | null => { } | null>(null); const [manageProviderId, setManageProviderId] = useState('gemini'); const [manageDialogOpen, setManageDialogOpen] = useState(false); - const [isSwitchingFlavor, setIsSwitchingFlavor] = useState(false); const multimodelEnabled = appConfig?.general?.multimodelEnabled ?? true; const loadingCliStatus = !cliStatus && cliStatusLoading && multimodelEnabled @@ -323,37 +321,6 @@ export const CliStatusSection = (): React.JSX.Element | null => { })(); }, [bootstrapCliStatus, fetchCliStatus, invalidateCliStatus, multimodelEnabled]); - const handleMultimodelToggle = useCallback( - async (enabled: boolean) => { - setIsSwitchingFlavor(true); - let nextMultimodelEnabled = multimodelEnabled; - try { - useStore.setState({ - cliStatus: enabled ? createLoadingMultimodelCliStatus() : null, - cliStatusLoading: true, - cliStatusError: null, - }); - await updateConfig('general', { multimodelEnabled: enabled }); - nextMultimodelEnabled = enabled; - await invalidateCliStatus(); - if (enabled) { - await bootstrapCliStatus({ multimodelEnabled: true }); - } else { - await fetchCliStatus(); - } - } catch { - if (nextMultimodelEnabled) { - await bootstrapCliStatus({ multimodelEnabled: true }); - } else { - await fetchCliStatus(); - } - } finally { - setIsSwitchingFlavor(false); - } - }, - [bootstrapCliStatus, fetchCliStatus, invalidateCliStatus, multimodelEnabled, updateConfig] - ); - const handleRuntimeBackendChange = useCallback( async (providerId: CliProviderId, backendId: string) => { const currentBackends = appConfig?.runtime?.providerBackends ?? { @@ -440,23 +407,6 @@ export const CliStatusSection = (): React.JSX.Element | null => { > Multimodel - {multimodelEnabled && ( - - Beta - - )} - void handleMultimodelToggle(value)} - disabled={isBusy || cliStatusLoading || isSwitchingFlavor} - /> {/* Inline action buttons */} {effectiveCliStatus.supportsSelfUpdate && effectiveCliStatus.updateAvailable ? ( diff --git a/src/renderer/components/team/ProvisioningProgressBlock.tsx b/src/renderer/components/team/ProvisioningProgressBlock.tsx index 0359139b..fecba68a 100644 --- a/src/renderer/components/team/ProvisioningProgressBlock.tsx +++ b/src/renderer/components/team/ProvisioningProgressBlock.tsx @@ -19,6 +19,7 @@ import { DISPLAY_STEPS } from './provisioningSteps'; import { StepProgressBar } from './StepProgressBar'; import type { StepProgressBarStep } from './StepProgressBar'; +import type { TeamLaunchDiagnosticItem } from '@shared/types'; /** Pre-built step definitions for the provisioning stepper. */ const PROVISIONING_STEPS: StepProgressBarStep[] = DISPLAY_STEPS.map((s) => ({ @@ -61,6 +62,8 @@ export interface ProvisioningProgressBlockProps { cliLogsTail?: string; /** Accumulated assistant text output for live preview */ assistantOutput?: string; + /** Bounded structured launch diagnostics */ + launchDiagnostics?: TeamLaunchDiagnosticItem[]; /** Visual surface chrome for the outer block */ surface?: 'raised' | 'flat'; className?: string; @@ -153,15 +156,20 @@ export const ProvisioningProgressBlock = ({ pid, cliLogsTail, assistantOutput, + launchDiagnostics, surface = 'raised', className, }: ProvisioningProgressBlockProps): React.JSX.Element => { const elapsed = useElapsedTimer(startedAt, loading); const [logsOpen, setLogsOpen] = useState(() => defaultLogsOpen ?? false); + const [diagnosticsOpen, setDiagnosticsOpen] = useState(false); const [liveOutputOpen, setLiveOutputOpen] = useState(defaultLiveOutputOpen); const outputScrollRef = useRef(null); const isError = tone === 'error'; const displayAssistantOutput = sanitizeAssistantOutput(assistantOutput, isError); + const visibleLaunchDiagnostics = + launchDiagnostics?.filter((item) => item.severity === 'warning' || item.severity === 'error') ?? + []; // Auto-scroll assistant output useEffect(() => { @@ -293,6 +301,42 @@ export const ProvisioningProgressBlock = ({ errorIndex={errorStepIndex} /> + {visibleLaunchDiagnostics.length > 0 ? ( +
+ + {diagnosticsOpen ? ( +
+ {visibleLaunchDiagnostics.map((item) => ( +
+
+ {item.label} +
+ {item.detail ? ( +
+ {item.detail} +
+ ) : null} +
+ ))} +
+ ) : null} +
+ ) : null}
+ + + {skipLaunchError ?? + (skippingLaunch ? 'Skipping teammate...' : 'Skip for this launch')} + + + ) : null} + {canRetryLaunch ? ( + + + + + + {retryLaunchError ?? + (retryingLaunch ? 'Retrying teammate...' : 'Retry teammate')} + + + ) : null} + + ) : showSkippedLaunchBadge ? ( + + + + + + + {displayPresenceLabel} + + + + + {spawnEntry?.skipReason ?? 'Skipped for this launch'} + + + {canRetryLaunch ? ( + + + + + + {retryLaunchError ?? + (retryingLaunch ? 'Retrying teammate...' : 'Retry teammate')} + + + ) : null} + ) : showRuntimeAdvisoryBadge ? ( diff --git a/src/renderer/components/team/members/MemberDetailDialog.tsx b/src/renderer/components/team/members/MemberDetailDialog.tsx index 99f48e50..4eaea9d7 100644 --- a/src/renderer/components/team/members/MemberDetailDialog.tsx +++ b/src/renderer/components/team/members/MemberDetailDialog.tsx @@ -6,7 +6,16 @@ import { Tabs, TabsContent, TabsList, TabsTrigger } from '@renderer/components/u import { useMemberStats } from '@renderer/hooks/useMemberStats'; import { useStore } from '@renderer/store'; import { selectMemberMessagesForTeamMember } from '@renderer/store/slices/teamSlice'; -import { resolveMemberRuntimeSummary } from '@renderer/utils/memberRuntimeSummary'; +import { + buildMemberLaunchDiagnosticsPayload, + getMemberLaunchDiagnosticsErrorMessage, + hasMemberLaunchDiagnosticsDetails, + hasMemberLaunchDiagnosticsError, +} from '@renderer/utils/memberLaunchDiagnostics'; +import { + getRuntimeMemorySourceLabel, + resolveMemberRuntimeSummary, +} from '@renderer/utils/memberRuntimeSummary'; import { isLeadMember } from '@shared/utils/leadDetection'; import { BarChart3, @@ -22,6 +31,7 @@ import { buildMemberActivityEntries } from './memberActivityEntries'; import { MemberDetailHeader } from './MemberDetailHeader'; import { MemberDetailStats } from './MemberDetailStats'; import { type MemberActivityFilter, type MemberDetailTab } from './memberDetailTypes'; +import { MemberLaunchDiagnosticsButton } from './MemberLaunchDiagnosticsButton'; import { MemberLogsTab } from './MemberLogsTab'; import { MemberMessagesTab } from './MemberMessagesTab'; import { MemberStatsTab } from './MemberStatsTab'; @@ -50,6 +60,7 @@ interface MemberDetailDialogProps { leadActivity?: LeadActivityState; spawnEntry?: MemberSpawnStatusEntry; runtimeEntry?: TeamAgentRuntimeEntry; + runtimeRunId?: string | null; launchParams?: TeamLaunchParams; onClose: () => void; onSendMessage: () => void; @@ -76,6 +87,7 @@ export const MemberDetailDialog = ({ leadActivity, spawnEntry, runtimeEntry, + runtimeRunId, launchParams, onClose, onSendMessage, @@ -128,10 +140,31 @@ export const MemberDetailDialog = ({ : undefined, [launchParams, member, runtimeEntry, spawnEntry] ); + const memorySourceLabel = getRuntimeMemorySourceLabel(runtimeEntry); const restartInFlight = spawnEntry?.launchState === 'starting' || spawnEntry?.launchState === 'runtime_pending_bootstrap' || spawnEntry?.launchState === 'runtime_pending_permission'; + const launchDiagnosticsPayload = useMemo( + () => + member + ? buildMemberLaunchDiagnosticsPayload({ + teamName, + runId: runtimeRunId, + memberName: member.name, + spawnEntry, + runtimeEntry, + }) + : null, + [member, runtimeEntry, runtimeRunId, spawnEntry, teamName] + ); + const showCopyDiagnostics = + launchDiagnosticsPayload != null && + hasMemberLaunchDiagnosticsError(launchDiagnosticsPayload) && + hasMemberLaunchDiagnosticsDetails(launchDiagnosticsPayload); + const launchErrorMessage = launchDiagnosticsPayload + ? getMemberLaunchDiagnosticsErrorMessage(launchDiagnosticsPayload) + : undefined; useEffect(() => { if (!open || !member) { @@ -167,6 +200,7 @@ export const MemberDetailDialog = ({ spawnLaunchState={spawnEntry?.launchState} spawnLivenessSource={spawnEntry?.livenessSource} spawnRuntimeAlive={spawnEntry?.runtimeAlive} + runtimeEntry={runtimeEntry} isLaunchSettling={isLaunchSettling} onUpdateRole={ onUpdateRole ? (newRole) => onUpdateRole(member.name, newRole) : undefined @@ -250,9 +284,23 @@ export const MemberDetailDialog = ({ {restartError ? (
{restartError}
+ ) : launchErrorMessage ? ( +
+ + {launchErrorMessage} + + {launchDiagnosticsPayload && showCopyDiagnostics ? ( + + ) : null} +
) : runtimeEntry?.pid ? (
PID {runtimeEntry.pid} + {memorySourceLabel ? ` · ${memorySourceLabel}` : ''}
) : (
diff --git a/src/renderer/components/team/members/MemberDetailHeader.tsx b/src/renderer/components/team/members/MemberDetailHeader.tsx index 992c9934..a26a2766 100644 --- a/src/renderer/components/team/members/MemberDetailHeader.tsx +++ b/src/renderer/components/team/members/MemberDetailHeader.tsx @@ -23,11 +23,13 @@ import type { MemberSpawnLivenessSource, MemberSpawnStatus, ResolvedTeamMember, + TeamAgentRuntimeEntry, } from '@shared/types'; interface MemberDetailHeaderProps { member: ResolvedTeamMember; runtimeSummary?: string; + runtimeEntry?: TeamAgentRuntimeEntry; isTeamAlive?: boolean; isTeamProvisioning?: boolean; leadActivity?: LeadActivityState; @@ -43,6 +45,7 @@ interface MemberDetailHeaderProps { export const MemberDetailHeader = ({ member, runtimeSummary, + runtimeEntry, isTeamAlive, isTeamProvisioning, leadActivity, @@ -75,6 +78,7 @@ export const MemberDetailHeader = ({ spawnLaunchState, spawnLivenessSource, spawnRuntimeAlive, + runtimeEntry, runtimeAdvisory: member.runtimeAdvisory, isLaunchSettling, isTeamAlive, @@ -91,7 +95,12 @@ export const MemberDetailHeader = ({ const badgeLabel = runtimeAdvisoryTone === 'error' && runtimeAdvisoryLabel ? runtimeAdvisoryLabel - : launchVisualState === 'runtime_pending' || launchVisualState === 'permission_pending' + : launchVisualState === 'runtime_pending' || + launchVisualState === 'permission_pending' || + launchVisualState === 'shell_only' || + launchVisualState === 'runtime_candidate' || + launchVisualState === 'registered_only' || + launchVisualState === 'stale_runtime' ? (launchStatusLabel ?? presenceLabel) : presenceLabel; diff --git a/src/renderer/components/team/members/MemberHoverCard.tsx b/src/renderer/components/team/members/MemberHoverCard.tsx index 8c85df45..2064fa1b 100644 --- a/src/renderer/components/team/members/MemberHoverCard.tsx +++ b/src/renderer/components/team/members/MemberHoverCard.tsx @@ -22,6 +22,12 @@ import { buildMemberLaunchPresentation, displayMemberName, } from '@renderer/utils/memberHelpers'; +import { + buildMemberLaunchDiagnosticsPayload, + getMemberLaunchDiagnosticsErrorMessage, + hasMemberLaunchDiagnosticsDetails, + hasMemberLaunchDiagnosticsError, +} from '@renderer/utils/memberLaunchDiagnostics'; import { isLeadMember } from '@shared/utils/leadDetection'; import { ExternalLink } from 'lucide-react'; import { useShallow } from 'zustand/react/shallow'; @@ -29,6 +35,7 @@ import { useShallow } from 'zustand/react/shallow'; import { getLaunchJoinMilestonesFromMembers, getLaunchJoinState } from '../provisioningSteps'; import { CurrentTaskIndicator } from './CurrentTaskIndicator'; +import { MemberLaunchDiagnosticsButton } from './MemberLaunchDiagnosticsButton'; import type { LeadActivityState, TeamTaskWithKanban } from '@shared/types'; @@ -68,6 +75,8 @@ export const MemberHoverCard = ({ memberSpawnSnapshot, memberSpawnStatuses, spawnEntry, + runtimeRunId, + runtimeEntry, leadActivity, } = useStore( useShallow((s) => ({ @@ -89,6 +98,12 @@ export const MemberHoverCard = ({ spawnEntry: effectiveTeamName ? s.memberSpawnStatusesByTeam[effectiveTeamName]?.[name] : undefined, + runtimeRunId: effectiveTeamName + ? s.teamAgentRuntimeByTeam?.[effectiveTeamName]?.runId + : undefined, + runtimeEntry: effectiveTeamName + ? s.teamAgentRuntimeByTeam?.[effectiveTeamName]?.members[name] + : undefined, leadActivity: effectiveTeamName ? s.leadActivityByTeam[effectiveTeamName] : undefined, })) ); @@ -114,6 +129,7 @@ export const MemberHoverCard = ({ spawnLaunchState: spawnEntry?.launchState, spawnLivenessSource: spawnEntry?.livenessSource, spawnRuntimeAlive: spawnEntry?.runtimeAlive, + runtimeEntry, runtimeAdvisory: member.runtimeAdvisory, isLaunchSettling, isTeamAlive, @@ -130,9 +146,25 @@ export const MemberHoverCard = ({ const badgeLabel = runtimeAdvisoryTone === 'error' && runtimeAdvisoryLabel ? runtimeAdvisoryLabel - : launchVisualState === 'runtime_pending' || launchVisualState === 'permission_pending' + : launchVisualState === 'runtime_pending' || + launchVisualState === 'permission_pending' || + launchVisualState === 'shell_only' || + launchVisualState === 'runtime_candidate' || + launchVisualState === 'registered_only' || + launchVisualState === 'stale_runtime' ? (launchStatusLabel ?? presenceLabel) : presenceLabel; + const launchDiagnosticsPayload = buildMemberLaunchDiagnosticsPayload({ + teamName: effectiveTeamName, + runId: runtimeRunId ?? memberSpawnSnapshot?.runId ?? progress?.runId, + memberName: member.name, + spawnEntry, + runtimeEntry, + }); + const launchErrorMessage = getMemberLaunchDiagnosticsErrorMessage(launchDiagnosticsPayload); + const showCopyDiagnostics = + hasMemberLaunchDiagnosticsError(launchDiagnosticsPayload) && + hasMemberLaunchDiagnosticsDetails(launchDiagnosticsPayload); const currentTask: TeamTaskWithKanban | null = member.currentTaskId ? (tasks.find((t) => t.id === member.currentTaskId) ?? null) : null; @@ -226,18 +258,33 @@ export const MemberHoverCard = ({
)} - {/* Open profile button */} - + {launchErrorMessage ? ( +
+ + {launchErrorMessage} + + {showCopyDiagnostics ? ( + + ) : null} +
+ ) : null} + +
+ +
diff --git a/src/renderer/components/team/members/MemberLaunchDiagnosticsButton.tsx b/src/renderer/components/team/members/MemberLaunchDiagnosticsButton.tsx new file mode 100644 index 00000000..4da86616 --- /dev/null +++ b/src/renderer/components/team/members/MemberLaunchDiagnosticsButton.tsx @@ -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): Promise => { + event.preventDefault(); + event.stopPropagation(); + try { + await navigator.clipboard.writeText(formatMemberLaunchDiagnosticsPayload(payload)); + setCopied(true); + window.setTimeout(() => setCopied(false), 1500); + } catch { + setCopied(false); + } + }; + + const icon = copied ? : ; + const tooltip = copied ? 'Diagnostics copied' : 'Copy diagnostics'; + + return ( + + + + + {tooltip} + + ); +}; diff --git a/src/renderer/components/team/members/MemberList.tsx b/src/renderer/components/team/members/MemberList.tsx index 6f07b69d..8b3f6140 100644 --- a/src/renderer/components/team/members/MemberList.tsx +++ b/src/renderer/components/team/members/MemberList.tsx @@ -23,6 +23,7 @@ interface MemberListProps { pendingRepliesByMember?: Record; memberSpawnStatuses?: Map; memberRuntimeEntries?: Map; + runtimeRunId?: string | null; isLaunchSettling?: boolean; isTeamAlive?: boolean; isTeamProvisioning?: boolean; @@ -32,6 +33,8 @@ interface MemberListProps { onSendMessage?: (member: ResolvedTeamMember) => void; onAssignTask?: (member: ResolvedTeamMember) => void; onOpenTask?: (taskId: string) => void; + onRestartMember?: (memberName: string) => Promise | void; + onSkipMemberForLaunch?: (memberName: string) => Promise | void; } function areResolvedMembersEquivalent( @@ -150,7 +153,13 @@ function areMemberSpawnStatusesEquivalent( leftEntry.error !== rightEntry.error || leftEntry.hardFailure !== rightEntry.hardFailure || leftEntry.hardFailureReason !== rightEntry.hardFailureReason || + leftEntry.skippedForLaunch !== rightEntry.skippedForLaunch || + leftEntry.skipReason !== rightEntry.skipReason || + leftEntry.skippedAt !== rightEntry.skippedAt || leftEntry.livenessSource !== rightEntry.livenessSource || + leftEntry.livenessKind !== rightEntry.livenessKind || + leftEntry.runtimeDiagnostic !== rightEntry.runtimeDiagnostic || + leftEntry.runtimeDiagnosticSeverity !== rightEntry.runtimeDiagnosticSeverity || leftEntry.runtimeModel !== rightEntry.runtimeModel || leftEntry.runtimeAlive !== rightEntry.runtimeAlive || leftEntry.bootstrapConfirmed !== rightEntry.bootstrapConfirmed || @@ -189,14 +198,34 @@ function areMemberRuntimeEntriesEquivalent( if (left.size !== right.size) return false; for (const [key, leftEntry] of left) { const rightEntry = right.get(key); + const leftDiagnostics = leftEntry.diagnostics ?? []; + const rightDiagnostics = rightEntry?.diagnostics ?? []; if ( leftEntry.memberName !== rightEntry?.memberName || leftEntry.alive !== rightEntry?.alive || leftEntry.restartable !== rightEntry?.restartable || leftEntry.backendType !== rightEntry?.backendType || + leftEntry.providerId !== rightEntry?.providerId || + leftEntry.providerBackendId !== rightEntry?.providerBackendId || + leftEntry.laneId !== rightEntry?.laneId || + leftEntry.laneKind !== rightEntry?.laneKind || leftEntry.pid !== rightEntry?.pid || leftEntry.runtimeModel !== rightEntry?.runtimeModel || - leftEntry.rssBytes !== rightEntry?.rssBytes + leftEntry.rssBytes !== rightEntry?.rssBytes || + leftEntry.livenessKind !== rightEntry?.livenessKind || + leftEntry.pidSource !== rightEntry?.pidSource || + leftEntry.processCommand !== rightEntry?.processCommand || + leftEntry.paneId !== rightEntry?.paneId || + leftEntry.panePid !== rightEntry?.panePid || + leftEntry.paneCurrentCommand !== rightEntry?.paneCurrentCommand || + leftEntry.runtimePid !== rightEntry?.runtimePid || + leftEntry.runtimeSessionId !== rightEntry?.runtimeSessionId || + leftEntry.runtimeDiagnostic !== rightEntry?.runtimeDiagnostic || + leftEntry.runtimeDiagnosticSeverity !== rightEntry?.runtimeDiagnosticSeverity || + leftEntry.runtimeLastSeenAt !== rightEntry?.runtimeLastSeenAt || + leftEntry.historicalBootstrapConfirmed !== rightEntry?.historicalBootstrapConfirmed || + leftDiagnostics.length !== rightDiagnostics.length || + !leftDiagnostics.every((value, index) => value === rightDiagnostics[index]) ) { return false; } @@ -215,10 +244,13 @@ function areMemberListPropsEqual( arePendingRepliesEquivalent(prev.pendingRepliesByMember, next.pendingRepliesByMember) && areMemberSpawnStatusesEquivalent(prev.memberSpawnStatuses, next.memberSpawnStatuses) && areMemberRuntimeEntriesEquivalent(prev.memberRuntimeEntries, next.memberRuntimeEntries) && + prev.runtimeRunId === next.runtimeRunId && prev.isLaunchSettling === next.isLaunchSettling && prev.isTeamAlive === next.isTeamAlive && prev.isTeamProvisioning === next.isTeamProvisioning && prev.leadActivity === next.leadActivity && + prev.onRestartMember === next.onRestartMember && + prev.onSkipMemberForLaunch === next.onSkipMemberForLaunch && areLaunchParamsEquivalent(prev.launchParams, next.launchParams) ); } @@ -230,6 +262,7 @@ export const MemberList = memo(function MemberList({ pendingRepliesByMember, memberSpawnStatuses, memberRuntimeEntries, + runtimeRunId, isLaunchSettling, isTeamAlive, isTeamProvisioning, @@ -239,6 +272,8 @@ export const MemberList = memo(function MemberList({ onSendMessage, onAssignTask, onOpenTask, + onRestartMember, + onSkipMemberForLaunch, }: MemberListProps): React.JSX.Element { const containerRef = useRef(null); const [isWide, setIsWide] = useState(false); @@ -332,7 +367,10 @@ export const MemberList = memo(function MemberList({ isRemoved ? undefined : spawnEntry, isRemoved ? undefined : runtimeEntry )} + runtimeEntry={isRemoved ? undefined : runtimeEntry} + runtimeRunId={isRemoved ? undefined : runtimeRunId} spawnStatus={isRemoved ? undefined : spawnEntry?.status} + spawnEntry={isRemoved ? undefined : spawnEntry} spawnError={isRemoved ? undefined : (spawnEntry?.error ?? spawnEntry?.hardFailureReason)} spawnLivenessSource={isRemoved ? undefined : spawnEntry?.livenessSource} spawnLaunchState={isRemoved ? undefined : spawnEntry?.launchState} @@ -343,6 +381,8 @@ export const MemberList = memo(function MemberList({ onClick={() => onMemberClick?.(member)} onSendMessage={() => onSendMessage?.(member)} onAssignTask={() => onAssignTask?.(member)} + onRestartMember={isRemoved ? undefined : onRestartMember} + onSkipMemberForLaunch={isRemoved ? undefined : onSkipMemberForLaunch} /> ); }; diff --git a/src/renderer/components/team/members/TeamRosterEditorSection.tsx b/src/renderer/components/team/members/TeamRosterEditorSection.tsx index f8d40c25..3da87cef 100644 --- a/src/renderer/components/team/members/TeamRosterEditorSection.tsx +++ b/src/renderer/components/team/members/TeamRosterEditorSection.tsx @@ -5,7 +5,7 @@ import { MembersEditorSection } from './MembersEditorSection'; import type { MemberDraft } from './membersEditorTypes'; import type { MentionSuggestion } from '@renderer/types/mention'; -import type { EffortLevel, TeamFastMode, TeamProviderId } from '@shared/types'; +import type { EffortLevel, TeamProviderId } from '@shared/types'; interface TeamRosterEditorSectionProps { members: MemberDraft[]; @@ -31,13 +31,10 @@ interface TeamRosterEditorSectionProps { providerId: TeamProviderId; model: string; effort?: EffortLevel; - fastMode?: TeamFastMode; - providerFastModeDefault?: boolean; limitContext: boolean; onProviderChange: (providerId: TeamProviderId) => void; onModelChange: (model: string) => void; onEffortChange: (effort: string) => void; - onFastModeChange?: (fastMode: TeamFastMode) => void; onLimitContextChange: (value: boolean) => void; syncModelsWithTeammates: boolean; onSyncModelsWithTeammatesChange: (value: boolean) => void; @@ -45,7 +42,6 @@ interface TeamRosterEditorSectionProps { headerBottom?: React.ReactNode; softDeleteMembers?: boolean; leadWarningText?: string | null; - leadFastModeNotice?: string | null; memberWarningById?: Record; disableGeminiOption?: boolean; leadModelIssueText?: string | null; @@ -79,13 +75,10 @@ export const TeamRosterEditorSection = ({ providerId, model, effort, - fastMode, - providerFastModeDefault, limitContext, onProviderChange, onModelChange, onEffortChange, - onFastModeChange, onLimitContextChange, syncModelsWithTeammates, onSyncModelsWithTeammatesChange, @@ -93,7 +86,6 @@ export const TeamRosterEditorSection = ({ headerBottom, softDeleteMembers = false, leadWarningText, - leadFastModeNotice, memberWarningById, disableGeminiOption = false, leadModelIssueText, @@ -138,18 +130,14 @@ export const TeamRosterEditorSection = ({ providerId={providerId} model={model} effort={effort} - fastMode={fastMode} - providerFastModeDefault={providerFastModeDefault} limitContext={limitContext} onProviderChange={onProviderChange} onModelChange={onModelChange} onEffortChange={onEffortChange} - onFastModeChange={onFastModeChange} onLimitContextChange={onLimitContextChange} syncModelsWithTeammates={syncModelsWithTeammates} onSyncModelsWithTeammatesChange={onSyncModelsWithTeammatesChange} warningText={leadWarningText} - fastModeNotice={leadFastModeNotice} disableGeminiOption={disableGeminiOption} modelIssueText={leadModelIssueText} /> diff --git a/src/renderer/components/team/provisioningSteps.ts b/src/renderer/components/team/provisioningSteps.ts index aeafdc17..5d1e1d8f 100644 --- a/src/renderer/components/team/provisioningSteps.ts +++ b/src/renderer/components/team/provisioningSteps.ts @@ -27,6 +27,7 @@ export interface LaunchJoinMilestones { processOnlyAliveCount: number; pendingSpawnCount: number; failedSpawnCount: number; + skippedSpawnCount: number; } type DisplayStepMilestones = LaunchJoinMilestones & { @@ -63,6 +64,10 @@ function isFailedSpawnEntry(entry: MemberSpawnStatusEntry | undefined): boolean return entry?.launchState === 'failed_to_start' || entry?.status === 'error'; } +function isStrongRuntimeProcessSpawnEntry(entry: MemberSpawnStatusEntry): boolean { + return entry.runtimeAlive === true && entry.livenessKind === 'runtime_process'; +} + function shouldPreferSnapshotEntryOverLive( liveEntry: MemberSpawnStatusEntry | undefined, snapshotEntry: MemberSpawnStatusEntry | undefined, @@ -102,6 +107,7 @@ function summarizeLiveLaunchJoinMilestones(params: { let processOnlyAliveCount = 0; let pendingSpawnCount = 0; let failedSpawnCount = 0; + let skippedSpawnCount = 0; let observedTeammateCount = 0; for (const memberName of teammateNames) { @@ -123,15 +129,20 @@ function summarizeLiveLaunchJoinMilestones(params: { failedSpawnCount += 1; continue; } + if (entry.launchState === 'skipped_for_launch' || entry.skippedForLaunch === true) { + skippedSpawnCount += 1; + continue; + } if (entry.launchState === 'confirmed_alive') { heartbeatConfirmedCount += 1; continue; } - if ( - entry.launchState === 'runtime_pending_bootstrap' || - entry.launchState === 'runtime_pending_permission' - ) { - if (entry.runtimeAlive === true) { + if (entry.launchState === 'runtime_pending_permission') { + pendingSpawnCount += 1; + continue; + } + if (entry.launchState === 'runtime_pending_bootstrap') { + if (isStrongRuntimeProcessSpawnEntry(entry)) { processOnlyAliveCount += 1; } else { pendingSpawnCount += 1; @@ -148,6 +159,7 @@ function summarizeLiveLaunchJoinMilestones(params: { processOnlyAliveCount, pendingSpawnCount, failedSpawnCount, + skippedSpawnCount, observedTeammateCount, }; } @@ -196,28 +208,30 @@ export function getLaunchJoinMilestonesFromMembers({ }); if (snapshotSummary) { + const snapshotProcessOnlyAliveCount = snapshotSummary.runtimeProcessPendingCount ?? 0; const snapshotMilestones = { expectedTeammateCount, heartbeatConfirmedCount: snapshotSummary.confirmedCount, - processOnlyAliveCount: snapshotSummary.runtimeAlivePendingCount, - pendingSpawnCount: Math.max( - 0, - snapshotSummary.pendingCount - snapshotSummary.runtimeAlivePendingCount - ), + processOnlyAliveCount: snapshotProcessOnlyAliveCount, + pendingSpawnCount: Math.max(0, snapshotSummary.pendingCount - snapshotProcessOnlyAliveCount), failedSpawnCount: snapshotSummary.failedCount, + skippedSpawnCount: snapshotSummary.skippedCount ?? 0, }; const snapshotAccountedFor = snapshotMilestones.heartbeatConfirmedCount + snapshotMilestones.processOnlyAliveCount + - snapshotMilestones.failedSpawnCount; + snapshotMilestones.failedSpawnCount + + snapshotMilestones.skippedSpawnCount; const liveAccountedFor = liveSummary.heartbeatConfirmedCount + liveSummary.processOnlyAliveCount + - liveSummary.failedSpawnCount; + liveSummary.failedSpawnCount + + liveSummary.skippedSpawnCount; const liveSummaryIsMoreAdvanced = liveSummary.failedSpawnCount > snapshotMilestones.failedSpawnCount || + liveSummary.skippedSpawnCount > snapshotMilestones.skippedSpawnCount || liveSummary.heartbeatConfirmedCount > snapshotMilestones.heartbeatConfirmedCount || liveSummary.processOnlyAliveCount > snapshotMilestones.processOnlyAliveCount || (snapshotMilestones.failedSpawnCount === 0 && @@ -245,6 +259,7 @@ export function getLaunchJoinState({ processOnlyAliveCount, pendingSpawnCount, failedSpawnCount, + skippedSpawnCount, }: LaunchJoinMilestones): { allTeammatesConfirmedAlive: boolean; hasMembersStillJoining: boolean; @@ -253,14 +268,16 @@ export function getLaunchJoinState({ const allTeammatesConfirmedAlive = expectedTeammateCount > 0 && failedSpawnCount === 0 && + skippedSpawnCount === 0 && heartbeatConfirmedCount >= expectedTeammateCount; const remainingJoinCount = - expectedTeammateCount > 0 && failedSpawnCount === 0 + expectedTeammateCount > 0 && failedSpawnCount === 0 && skippedSpawnCount === 0 ? Math.max(0, expectedTeammateCount - heartbeatConfirmedCount) : 0; const hasMembersStillJoining = expectedTeammateCount > 0 && failedSpawnCount === 0 && + skippedSpawnCount === 0 && remainingJoinCount > 0 && (processOnlyAliveCount > 0 || pendingSpawnCount > 0); @@ -292,6 +309,7 @@ export function getDisplayStepIndex({ processOnlyAliveCount, pendingSpawnCount, failedSpawnCount, + skippedSpawnCount, }: DisplayStepMilestones): number { switch (progress.state) { case 'ready': @@ -319,8 +337,12 @@ export function getDisplayStepIndex({ if (failedSpawnCount > 0) { return 2; } + if (skippedSpawnCount > 0) { + return 2; + } - const accountedForTeammates = heartbeatConfirmedCount + processOnlyAliveCount + failedSpawnCount; + const accountedForTeammates = + heartbeatConfirmedCount + processOnlyAliveCount + failedSpawnCount + skippedSpawnCount; if (pendingSpawnCount > 0 || accountedForTeammates < expectedTeammateCount) { return 2; diff --git a/src/renderer/store/index.ts b/src/renderer/store/index.ts index ebba97a7..0b9424ff 100644 --- a/src/renderer/store/index.ts +++ b/src/renderer/store/index.ts @@ -242,6 +242,7 @@ export function initializeNotificationListeners(): () => void { let teamMessageRefreshTimers = new Map>(); let teamPresenceRefreshTimers = new Map>(); let memberSpawnRefreshTimers = new Map>(); + let teamAgentRuntimeRefreshTimers = new Map>(); let toolActivityTimers = new Map>(); let inProgressChangePresencePollInFlight = false; let teamMessageFallbackPollInFlight = false; @@ -286,6 +287,19 @@ export function initializeNotificationListeners(): () => void { }, TEAM_MEMBER_SPAWN_REFRESH_THROTTLE_MS); memberSpawnRefreshTimers.set(teamName, timer); }; + const scheduleTeamAgentRuntimeRefresh = (teamName: string | null | undefined): void => { + if (!teamName || !isTeamVisibleInAnyPane(teamName)) { + return; + } + if (teamAgentRuntimeRefreshTimers.has(teamName)) { + return; + } + const timer = setTimeout(() => { + teamAgentRuntimeRefreshTimers.delete(teamName); + void useStore.getState().fetchTeamAgentRuntime(teamName); + }, TEAM_MEMBER_SPAWN_REFRESH_THROTTLE_MS); + teamAgentRuntimeRefreshTimers.set(teamName, timer); + }; const scheduleTrackedTeamMessageRefresh = (teamName: string | null | undefined): void => { if (!teamName || !shouldRefreshTeamMessages(teamName)) { return; @@ -1194,6 +1208,7 @@ export function initializeNotificationListeners(): () => void { } seedCurrentRunIdIfMissing(); scheduleMemberSpawnStatusesRefresh(event.teamName); + scheduleTeamAgentRuntimeRefresh(event.teamName); return; } @@ -1276,6 +1291,8 @@ export function initializeNotificationListeners(): () => void { teamPresenceRefreshTimers = new Map(); for (const t of memberSpawnRefreshTimers.values()) clearTimeout(t); memberSpawnRefreshTimers = new Map(); + for (const t of teamAgentRuntimeRefreshTimers.values()) clearTimeout(t); + teamAgentRuntimeRefreshTimers = new Map(); for (const t of toolActivityTimers.values()) clearTimeout(t); toolActivityTimers = new Map(); teamLastRelevantActivityAt.clear(); diff --git a/src/renderer/store/slices/changeReviewSlice.ts b/src/renderer/store/slices/changeReviewSlice.ts index 0f96fd7a..937044e6 100644 --- a/src/renderer/store/slices/changeReviewSlice.ts +++ b/src/renderer/store/slices/changeReviewSlice.ts @@ -374,6 +374,19 @@ export const createChangeReviewSlice: StateCreator { + const addMatchingReviewPathAliases = ( + aliases: Set, + filePath: string, + canonicalFilePath: string, + record: Record + ): void => { + for (const key of Object.keys(record)) { + if (reviewPathsEqual(key, filePath) || reviewPathsEqual(key, canonicalFilePath)) { + aliases.add(key); + } + } + }; + const buildResolvedFileInvalidation = ( s: ChangeReviewSlice, filePath: string @@ -388,17 +401,10 @@ export const createChangeReviewSlice: StateCreator): void => { - for (const key of Object.keys(record)) { - if (reviewPathsEqual(key, filePath) || reviewPathsEqual(key, canonicalFilePath)) { - aliases.add(key); - } - } - }; - addMatchingAliases(s.fileChunkCounts); - addMatchingAliases(s.fileContents); - addMatchingAliases(s.fileContentsLoading); - addMatchingAliases(s.fileContentVersionByPath); + addMatchingReviewPathAliases(aliases, filePath, canonicalFilePath, s.fileChunkCounts); + addMatchingReviewPathAliases(aliases, filePath, canonicalFilePath, s.fileContents); + addMatchingReviewPathAliases(aliases, filePath, canonicalFilePath, s.fileContentsLoading); + addMatchingReviewPathAliases(aliases, filePath, canonicalFilePath, s.fileContentVersionByPath); const nextFileChunkCounts = { ...s.fileChunkCounts }; for (const alias of aliases) delete nextFileChunkCounts[alias]; @@ -1461,18 +1467,21 @@ export const createChangeReviewSlice: StateCreator { const aliases = new Set([filePath, canonicalFilePath]); - const addMatchingAliases = (record: Record): void => { - for (const key of Object.keys(record)) { - if (reviewPathsEqual(key, filePath) || reviewPathsEqual(key, canonicalFilePath)) { - aliases.add(key); - } - } - }; - addMatchingAliases(s.editedContents); - addMatchingAliases(s.fileChunkCounts); - addMatchingAliases(s.hunkContextHashesByFile); - addMatchingAliases(s.reviewExternalChangesByFile); - addMatchingAliases(s.fileContents); + addMatchingReviewPathAliases(aliases, filePath, canonicalFilePath, s.editedContents); + addMatchingReviewPathAliases(aliases, filePath, canonicalFilePath, s.fileChunkCounts); + addMatchingReviewPathAliases( + aliases, + filePath, + canonicalFilePath, + s.hunkContextHashesByFile + ); + addMatchingReviewPathAliases( + aliases, + filePath, + canonicalFilePath, + s.reviewExternalChangesByFile + ); + addMatchingReviewPathAliases(aliases, filePath, canonicalFilePath, s.fileContents); const nextEdited = { ...s.editedContents }; for (const alias of aliases) delete nextEdited[alias]; diff --git a/src/renderer/store/slices/teamSlice.ts b/src/renderer/store/slices/teamSlice.ts index 67192443..7d294aa6 100644 --- a/src/renderer/store/slices/teamSlice.ts +++ b/src/renderer/store/slices/teamSlice.ts @@ -71,14 +71,7 @@ const logger = createLogger('teamSlice'); const TEAM_GET_DATA_TIMEOUT_MS = 30_000; const TEAM_FETCH_TIMEOUT_MS = 30_000; const MEMBER_SPAWN_STATUSES_IPC_RETRY_BACKOFF_MS = 5_000; -const TEAM_DATA_IPC_WARN_MS = 350; -const TEAM_DATA_SET_WARN_MS = 12; -const TEAM_DATA_POST_WARN_MS = 24; -const TEAM_DATA_LARGE_MESSAGES = 150; -const TEAM_DATA_LARGE_TASKS = 80; const TEAM_REFRESH_BURST_WINDOW_MS = 4_000; -const TEAM_REFRESH_BURST_WARN_COUNT = 5; -const TEAM_REFRESH_WARN_THROTTLE_MS = 2_000; const MEMBER_SPAWN_UI_EQUAL_WARN_THROTTLE_MS = 2_000; const inFlightTeamDataRequests = new Map>(); const inFlightRefreshTeamDataCalls = new Map>(); @@ -567,45 +560,6 @@ function fetchTeamDataFresh(teamName: string): Promise { ); } -function summarizeTeamDataCounts(data: TeamViewSnapshot | null | undefined): { - tasks: number; - members: number; - activeMembers: number; - processes: number; -} { - if (!data) { - return { tasks: 0, members: 0, activeMembers: 0, processes: 0 }; - } - - return { - tasks: data.tasks.length, - members: data.members.length, - activeMembers: data.members.filter((member) => !member.removedAt).length, - processes: data.processes.length, - }; -} - -function estimateTeamPayloadWeight(data: TeamViewSnapshot): { - taskComments: number; - taskHistoryEvents: number; - taskDescriptionChars: number; -} { - let taskComments = 0; - let taskHistoryEvents = 0; - let taskDescriptionChars = 0; - for (const task of data.tasks) { - taskComments += task.comments?.length ?? 0; - taskHistoryEvents += task.historyEvents?.length ?? 0; - taskDescriptionChars += task.description?.length ?? 0; - } - - return { - taskComments, - taskHistoryEvents, - taskDescriptionChars, - }; -} - function noteTeamRefreshBurst(teamName: string): number { const now = Date.now(); const diagnostic = teamRefreshBurstDiagnostics.get(teamName) ?? { @@ -621,77 +575,10 @@ function noteTeamRefreshBurst(teamName: string): number { diagnostic.count += 1; - if ( - diagnostic.count >= TEAM_REFRESH_BURST_WARN_COUNT && - now - diagnostic.lastWarnAt >= TEAM_REFRESH_WARN_THROTTLE_MS - ) { - diagnostic.lastWarnAt = now; - logger.warn( - `[perf] refreshTeamData burst team=${teamName} count=${diagnostic.count} windowMs=${ - now - diagnostic.windowStartedAt - }` - ); - } - teamRefreshBurstDiagnostics.set(teamName, diagnostic); return diagnostic.count; } -function maybeLogTeamDataPerf(params: { - phase: 'selectTeam' | 'refreshTeamData'; - teamName: string; - ipcMs: number; - setMs: number; - postMs: number; - totalMs: number; - previousData: TeamViewSnapshot | null | undefined; - nextData: TeamViewSnapshot; - deduped: boolean; - reusedInFlightRequest: boolean; - burstCount?: number; -}): void { - const { - phase, - teamName, - ipcMs, - setMs, - postMs, - totalMs, - previousData, - nextData, - deduped, - reusedInFlightRequest, - burstCount, - } = params; - - const nextCounts = summarizeTeamDataCounts(nextData); - const previousCounts = summarizeTeamDataCounts(previousData); - const largePayload = nextCounts.tasks >= TEAM_DATA_LARGE_TASKS; - const slow = - ipcMs >= TEAM_DATA_IPC_WARN_MS || - setMs >= TEAM_DATA_SET_WARN_MS || - postMs >= TEAM_DATA_POST_WARN_MS; - - if (!slow && !largePayload && !reusedInFlightRequest) { - return; - } - - const payloadWeight = estimateTeamPayloadWeight(nextData); - logger.warn( - `[perf] ${phase} team=${teamName} ipc=${ipcMs.toFixed(1)}ms set=${setMs.toFixed( - 1 - )}ms post=${postMs.toFixed(1)}ms total=${totalMs.toFixed(1)}ms deduped=${deduped} reusedInFlight=${ - reusedInFlightRequest ? 'yes' : 'no' - } burst=${burstCount ?? 1} counts=tasks:${previousCounts.tasks}->${nextCounts.tasks},members:${ - previousCounts.members - }->${nextCounts.members},activeMembers:${ - previousCounts.activeMembers - }->${nextCounts.activeMembers},processes:${previousCounts.processes}->${nextCounts.processes} payload=textChars:${ - payloadWeight.taskDescriptionChars - },taskComments=${payloadWeight.taskComments},historyEvents=${payloadWeight.taskHistoryEvents}` - ); -} - function areLaunchSummaryCountsEqual( left: PersistedTeamLaunchSummary | undefined, right: PersistedTeamLaunchSummary | undefined @@ -702,7 +589,13 @@ function areLaunchSummaryCountsEqual( left.confirmedCount === right.confirmedCount && left.pendingCount === right.pendingCount && left.failedCount === right.failedCount && - left.runtimeAlivePendingCount === right.runtimeAlivePendingCount + left.skippedCount === right.skippedCount && + left.runtimeAlivePendingCount === right.runtimeAlivePendingCount && + left.shellOnlyPendingCount === right.shellOnlyPendingCount && + left.runtimeProcessPendingCount === right.runtimeProcessPendingCount && + left.runtimeCandidatePendingCount === right.runtimeCandidatePendingCount && + left.noRuntimePendingCount === right.noRuntimePendingCount && + left.permissionPendingCount === right.permissionPendingCount ); } @@ -736,9 +629,15 @@ function areMemberSpawnStatusEntriesEqual( left.launchState === right.launchState && left.error === right.error && left.hardFailureReason === right.hardFailureReason && + left.skippedForLaunch === right.skippedForLaunch && + left.skipReason === right.skipReason && + left.skippedAt === right.skippedAt && left.livenessSource === right.livenessSource && left.runtimeAlive === right.runtimeAlive && left.runtimeModel === right.runtimeModel && + left.livenessKind === right.livenessKind && + left.runtimeDiagnostic === right.runtimeDiagnostic && + left.runtimeDiagnosticSeverity === right.runtimeDiagnosticSeverity && left.bootstrapConfirmed === right.bootstrapConfirmed && left.hardFailure === right.hardFailure && leftPendingPermissionIds.length === rightPendingPermissionIds.length && @@ -802,14 +701,34 @@ function areTeamAgentRuntimeEntriesEqual( ): boolean { if (left === right) return true; if (!left || !right) return left === right; + const leftDiagnostics = left.diagnostics ?? []; + const rightDiagnostics = right.diagnostics ?? []; return ( left.memberName === right.memberName && left.alive === right.alive && left.restartable === right.restartable && left.backendType === right.backendType && + left.providerId === right.providerId && + left.providerBackendId === right.providerBackendId && + left.laneId === right.laneId && + left.laneKind === right.laneKind && left.pid === right.pid && left.runtimeModel === right.runtimeModel && - left.rssBytes === right.rssBytes + left.rssBytes === right.rssBytes && + left.livenessKind === right.livenessKind && + left.pidSource === right.pidSource && + left.processCommand === right.processCommand && + left.paneId === right.paneId && + left.panePid === right.panePid && + left.paneCurrentCommand === right.paneCurrentCommand && + left.runtimePid === right.runtimePid && + left.runtimeSessionId === right.runtimeSessionId && + left.runtimeDiagnostic === right.runtimeDiagnostic && + left.runtimeDiagnosticSeverity === right.runtimeDiagnosticSeverity && + left.runtimeLastSeenAt === right.runtimeLastSeenAt && + left.historicalBootstrapConfirmed === right.historicalBootstrapConfirmed && + leftDiagnostics.length === rightDiagnostics.length && + leftDiagnostics.every((value, index) => value === rightDiagnostics[index]) ); } @@ -2134,6 +2053,7 @@ export interface TeamSlice { ) => Promise; addMember: (teamName: string, request: AddMemberRequest) => Promise; restartMember: (teamName: string, memberName: string) => Promise; + skipMemberForLaunch: (teamName: string, memberName: string) => Promise; removeMember: (teamName: string, memberName: string) => Promise; updateMemberRole: ( teamName: string, @@ -3211,7 +3131,6 @@ export const createTeamSlice: StateCreator = (set, }, selectTeam: async (teamName: string, opts) => { - const startedAt = performance.now(); const teamStateEpoch = captureTeamLocalStateEpoch(teamName); const allowReloadWhileProvisioning = opts?.allowReloadWhileProvisioning === true; // Guard: prevent duplicate in-flight fetches for the same team. @@ -3244,7 +3163,6 @@ export const createTeamSlice: StateCreator = (set, if (!isTeamLocalStateEpochCurrent(teamName, teamStateEpoch)) { return; } - const ipcMs = performance.now() - startedAt; // Stale check: user may have switched to another team during the async call if (get().selectedTeamName !== teamName || get().selectedTeamLoadNonce !== requestNonce) { return; @@ -3276,7 +3194,6 @@ export const createTeamSlice: StateCreator = (set, } : data; const nextTeamData = structurallyShareTeamSnapshot(previousData, projectedTeamData); - const setStartedAt = performance.now(); set((state) => { const nextCache = state.teamDataCacheByName[teamName] === nextTeamData @@ -3295,8 +3212,6 @@ export const createTeamSlice: StateCreator = (set, }; }); lastResolvedTeamDataRefreshAtByTeam.set(teamName, Date.now()); - const setMs = performance.now() - setStartedAt; - const postStartedAt = performance.now(); const invalidationState = previousData ? collectTaskChangeInvalidationState(teamName, previousData.tasks, data.tasks) : { cacheKeys: [], taskIds: [] }; @@ -3306,19 +3221,6 @@ export const createTeamSlice: StateCreator = (set, if (invalidationState.taskIds.length > 0) { await api.review.invalidateTaskChangeSummaries(teamName, invalidationState.taskIds); } - const postMs = performance.now() - postStartedAt; - maybeLogTeamDataPerf({ - phase: 'selectTeam', - teamName, - ipcMs, - setMs, - postMs, - totalMs: performance.now() - startedAt, - previousData, - nextData: nextTeamData, - deduped: true, - reusedInFlightRequest: false, - }); // Sync tab label with the team's display name from config const displayName = data.config.name || teamName; const allTabs = get().getAllPaneTabs(); @@ -3422,19 +3324,15 @@ export const createTeamSlice: StateCreator = (set, }, refreshTeamData: async (teamName: string, opts?: RefreshTeamDataOptions) => { - const startedAt = performance.now(); const teamStateEpoch = captureTeamLocalStateEpoch(teamName); const refreshToken = beginInFlightTeamDataRefresh(teamName); // Silent refresh — update data without showing loading skeleton. // Only selectTeam() sets loading: true (for initial load). const reusedInFlightRequest = opts?.withDedup === true && inFlightTeamDataRequests.has(teamName); - const burstCount = noteTeamRefreshBurst(teamName); + noteTeamRefreshBurst(teamName); if (reusedInFlightRequest) { pendingFreshTeamDataRefreshes.add(teamName); - logger.warn( - `[perf] refreshTeamData queued-fresh team=${teamName} burst=${burstCount} reason=inFlightDedup` - ); } try { const previousData = selectTeamDataForName(get(), teamName); @@ -3444,7 +3342,6 @@ export const createTeamSlice: StateCreator = (set, if (!isTeamLocalStateEpochCurrent(teamName, teamStateEpoch)) { return; } - const ipcMs = performance.now() - startedAt; const projectedTeamData = previousData ? { ...data, @@ -3452,7 +3349,6 @@ export const createTeamSlice: StateCreator = (set, } : data; const nextTeamData = structurallyShareTeamSnapshot(previousData, projectedTeamData); - const setStartedAt = performance.now(); set((state) => { const nextCache = state.teamDataCacheByName[teamName] === nextTeamData @@ -3484,8 +3380,6 @@ export const createTeamSlice: StateCreator = (set, }; }); lastResolvedTeamDataRefreshAtByTeam.set(teamName, Date.now()); - const setMs = performance.now() - setStartedAt; - const postStartedAt = performance.now(); const invalidationState = previousData ? collectTaskChangeInvalidationState(teamName, previousData.tasks, data.tasks) : { cacheKeys: [], taskIds: [] }; @@ -3495,20 +3389,6 @@ export const createTeamSlice: StateCreator = (set, if (invalidationState.taskIds.length > 0) { await api.review.invalidateTaskChangeSummaries(teamName, invalidationState.taskIds); } - const postMs = performance.now() - postStartedAt; - maybeLogTeamDataPerf({ - phase: 'refreshTeamData', - teamName, - ipcMs, - setMs, - postMs, - totalMs: performance.now() - startedAt, - previousData, - nextData: nextTeamData, - deduped: opts?.withDedup === true, - reusedInFlightRequest, - burstCount, - }); } catch (error) { if (!isTeamLocalStateEpochCurrent(teamName, teamStateEpoch)) { return; @@ -4227,6 +4107,20 @@ export const createTeamSlice: StateCreator = (set, } }, + skipMemberForLaunch: async (teamName: string, memberName: string) => { + try { + await unwrapIpc('team:skipMemberForLaunch', () => + api.teams.skipMemberForLaunch(teamName, memberName) + ); + } finally { + await Promise.allSettled([ + get().fetchMemberSpawnStatuses(teamName), + get().fetchTeamAgentRuntime(teamName), + get().fetchTeams(), + ]); + } + }, + removeMember: async (teamName: string, memberName: string) => { await unwrapIpc('team:removeMember', () => api.teams.removeMember(teamName, memberName)); await get().refreshTeamData(teamName); diff --git a/src/renderer/utils/__tests__/teamModelAvailability.codexCatalog.test.ts b/src/renderer/utils/__tests__/teamModelAvailability.codexCatalog.test.ts index ac4f6def..555233f9 100644 --- a/src/renderer/utils/__tests__/teamModelAvailability.codexCatalog.test.ts +++ b/src/renderer/utils/__tests__/teamModelAvailability.codexCatalog.test.ts @@ -1,7 +1,6 @@ import { describe, expect, it } from 'vitest'; import { - CODEX_DYNAMIC_MODEL_REQUIRES_RUNTIME_SUPPORT_REASON, getAvailableTeamProviderModelOptions, getAvailableTeamProviderModels, getTeamModelSelectionError, @@ -151,7 +150,7 @@ describe('team model availability Codex catalog integration', () => { ]); }); - it('shows app-server future models but blocks launch until runtime declares dynamic support', () => { + it('allows app-server catalog models even when the runtime does not declare dynamic model launch', () => { const providerStatus = createCodexProviderStatus([ { id: 'gpt-5.5', @@ -168,16 +167,14 @@ describe('team model availability Codex catalog integration', () => { }, ]); - expect(getAvailableTeamProviderModels('codex', providerStatus)).toEqual([]); + expect(getAvailableTeamProviderModels('codex', providerStatus)).toEqual(['gpt-5.5']); expect(getAvailableTeamProviderModelOptions('codex', providerStatus)[1]).toMatchObject({ value: 'gpt-5.5', label: '5.5', badgeLabel: 'New', - availabilityStatus: null, + availabilityStatus: 'available', }); - expect(getTeamModelSelectionError('codex', 'gpt-5.5', providerStatus)).toContain( - CODEX_DYNAMIC_MODEL_REQUIRES_RUNTIME_SUPPORT_REASON - ); + expect(getTeamModelSelectionError('codex', 'gpt-5.5', providerStatus)).toBeNull(); }); it('keeps existing disabled model policy on top of the dynamic catalog', () => { diff --git a/src/renderer/utils/memberHelpers.ts b/src/renderer/utils/memberHelpers.ts index a827da07..2af7729f 100644 --- a/src/renderer/utils/memberHelpers.ts +++ b/src/renderer/utils/memberHelpers.ts @@ -15,6 +15,7 @@ import type { MemberSpawnStatus, MemberStatus, ResolvedTeamMember, + TeamAgentRuntimeEntry, TeamProviderId, TeamReviewState, TeamTaskStatus, @@ -118,6 +119,7 @@ export const SPAWN_DOT_COLORS: Record = { spawning: 'bg-amber-400', online: 'bg-emerald-400 animate-[dot-online-jelly_0.45s_ease-out]', error: 'bg-red-400', + skipped: 'bg-zinc-500', }; export const SPAWN_PRESENCE_LABELS: Record = { @@ -126,6 +128,7 @@ export const SPAWN_PRESENCE_LABELS: Record = { spawning: 'starting', online: 'ready', error: 'spawn failed', + skipped: 'skipped', }; function isLaunchStillStarting( @@ -137,6 +140,9 @@ function isLaunchStillStarting( if (spawnLaunchState === 'failed_to_start') { return false; } + if (spawnLaunchState === 'skipped_for_launch') { + return false; + } if (spawnLaunchState === 'runtime_pending_permission') { return false; } @@ -170,6 +176,9 @@ export function getSpawnAwareDotClass( if (spawnLaunchState === 'failed_to_start' || spawnStatus === 'error') { return SPAWN_DOT_COLORS.error; } + if (spawnLaunchState === 'skipped_for_launch' || spawnStatus === 'skipped') { + return SPAWN_DOT_COLORS.skipped; + } if (spawnLaunchState === 'runtime_pending_permission') { return 'bg-amber-400 animate-pulse'; } @@ -217,6 +226,9 @@ export function getSpawnAwarePresenceLabel( if (spawnLaunchState === 'failed_to_start' || spawnStatus === 'error') { return SPAWN_PRESENCE_LABELS.error; } + if (spawnLaunchState === 'skipped_for_launch' || spawnStatus === 'skipped') { + return SPAWN_PRESENCE_LABELS.skipped; + } if (spawnLaunchState === 'runtime_pending_permission') { return 'connecting'; } @@ -258,6 +270,9 @@ export function getSpawnCardClass( ) { return 'member-waiting-shimmer'; } + if (spawnLaunchState === 'skipped_for_launch' || spawnStatus === 'skipped') { + return 'opacity-70'; + } if (spawnLaunchState === 'runtime_pending_permission') { return 'member-waiting-shimmer'; } @@ -517,6 +532,7 @@ export function getLaunchAwarePresenceLabel( basePresenceLabel === 'starting' || basePresenceLabel === 'connecting' || basePresenceLabel === 'spawn failed' || + basePresenceLabel === 'skipped' || basePresenceLabel === 'offline' || basePresenceLabel === 'terminated' ) { @@ -531,8 +547,13 @@ export type MemberLaunchVisualState = | 'spawning' | 'permission_pending' | 'runtime_pending' + | 'shell_only' + | 'runtime_candidate' + | 'registered_only' + | 'stale_runtime' | 'settling' | 'error' + | 'skipped' | null; export interface MemberLaunchPresentation { @@ -556,11 +577,21 @@ export function getMemberLaunchStatusLabel(visualState: MemberLaunchVisualState) case 'permission_pending': return 'awaiting permission'; case 'runtime_pending': - return 'connecting'; + return 'waiting for bootstrap'; + case 'shell_only': + return 'shell only'; + case 'runtime_candidate': + return 'process candidate'; + case 'registered_only': + return 'registered'; + case 'stale_runtime': + return 'stale runtime'; case 'settling': return 'joining team'; case 'error': return 'failed'; + case 'skipped': + return 'skipped'; default: return null; } @@ -573,6 +604,7 @@ export function buildMemberLaunchPresentation({ spawnLivenessSource, spawnRuntimeAlive, runtimeAdvisory, + runtimeEntry, isLaunchSettling = false, isTeamAlive, isTeamProvisioning, @@ -584,6 +616,7 @@ export function buildMemberLaunchPresentation({ spawnLivenessSource: MemberSpawnLivenessSource | undefined; spawnRuntimeAlive: boolean | undefined; runtimeAdvisory: MemberRuntimeAdvisory | undefined; + runtimeEntry?: TeamAgentRuntimeEntry; isLaunchSettling?: boolean; isTeamAlive?: boolean; isTeamProvisioning?: boolean; @@ -628,14 +661,21 @@ export function buildMemberLaunchPresentation({ if (isTeamAlive !== false || isTeamProvisioning) { if (spawnLaunchState === 'failed_to_start' || spawnStatus === 'error') { launchVisualState = 'error'; + } else if (spawnLaunchState === 'skipped_for_launch' || spawnStatus === 'skipped') { + launchVisualState = 'skipped'; } else if (spawnLaunchState === 'runtime_pending_permission') { launchVisualState = 'permission_pending'; + } else if (runtimeEntry?.livenessKind === 'shell_only') { + launchVisualState = 'shell_only'; + } else if (runtimeEntry?.livenessKind === 'runtime_process_candidate') { + launchVisualState = 'runtime_candidate'; + } else if (runtimeEntry?.livenessKind === 'registered_only') { + launchVisualState = 'registered_only'; } else if ( - spawnLaunchState === 'runtime_pending_bootstrap' && - spawnStatus === 'online' && - spawnRuntimeAlive === true + runtimeEntry?.livenessKind === 'stale_metadata' || + runtimeEntry?.livenessKind === 'not_found' ) { - launchVisualState = 'runtime_pending'; + launchVisualState = 'stale_runtime'; } else if ( isLaunchStillStarting( spawnStatus, @@ -645,6 +685,12 @@ export function buildMemberLaunchPresentation({ ) ) { launchVisualState = spawnStatus === 'spawning' ? 'spawning' : 'waiting'; + } else if ( + spawnLaunchState === 'runtime_pending_bootstrap' && + (runtimeEntry?.livenessKind === 'runtime_process' || + (spawnStatus === 'online' && spawnRuntimeAlive === true)) + ) { + launchVisualState = 'runtime_pending'; } else if ( isLaunchSettling && spawnStatus === 'online' && @@ -655,6 +701,19 @@ export function buildMemberLaunchPresentation({ } const launchStatusLabel = getMemberLaunchStatusLabel(launchVisualState); + const shouldShowLaunchStatusAsPresence = + launchVisualState === 'permission_pending' || + launchVisualState === 'runtime_pending' || + launchVisualState === 'shell_only' || + launchVisualState === 'runtime_candidate' || + launchVisualState === 'registered_only' || + launchVisualState === 'stale_runtime'; + const displayPresenceLabel = + runtimeAdvisoryTone === 'error' && runtimeAdvisoryLabel + ? runtimeAdvisoryLabel + : shouldShowLaunchStatusAsPresence + ? (launchStatusLabel ?? presenceLabel) + : presenceLabel; const spawnBadgeLabel = spawnStatus && spawnStatus !== 'online' ? spawnStatus === 'waiting' || spawnStatus === 'spawning' @@ -663,7 +722,7 @@ export function buildMemberLaunchPresentation({ : null; return { - presenceLabel, + presenceLabel: displayPresenceLabel, dotClass: runtimeAdvisoryTone === 'error' ? STATUS_DOT_COLORS.terminated : dotClass, cardClass, runtimeAdvisoryLabel, diff --git a/src/renderer/utils/memberLaunchDiagnostics.ts b/src/renderer/utils/memberLaunchDiagnostics.ts new file mode 100644 index 00000000..8eb52c00 --- /dev/null +++ b/src/renderer/utils/memberLaunchDiagnostics.ts @@ -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 +): string[] | undefined { + const seen = new Set(); + 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); +} diff --git a/src/renderer/utils/memberRuntimeSummary.ts b/src/renderer/utils/memberRuntimeSummary.ts index 42528ad3..66c0b6cd 100644 --- a/src/renderer/utils/memberRuntimeSummary.ts +++ b/src/renderer/utils/memberRuntimeSummary.ts @@ -40,6 +40,37 @@ function isMemberLaunchPending(spawnEntry: MemberSpawnStatusEntry | undefined): ); } +export function getRuntimeMemorySourceLabel( + runtimeEntry: TeamAgentRuntimeEntry | undefined +): string | undefined { + if (!runtimeEntry?.pidSource) { + return undefined; + } + if (runtimeEntry.pidSource === 'tmux_pane') { + return 'RSS source: tmux pane shell'; + } + if ( + runtimeEntry.providerId === 'opencode' && + runtimeEntry.restartable === false && + runtimeEntry.pidSource === 'opencode_bridge' + ) { + return 'RSS source: shared OpenCode host'; + } + if (runtimeEntry.pidSource === 'tmux_child' || runtimeEntry.pidSource === 'agent_process_table') { + return 'RSS source: runtime process'; + } + if (runtimeEntry.pidSource === 'lead_process') { + return 'RSS source: lead process'; + } + if (runtimeEntry.pidSource === 'runtime_bootstrap') { + return 'RSS source: runtime bootstrap process'; + } + if (runtimeEntry.pidSource === 'persisted_metadata') { + return 'RSS source: persisted runtime metadata'; + } + return `PID source: ${runtimeEntry.pidSource}`; +} + export function resolveMemberRuntimeSummary( member: ResolvedTeamMember, launchParams: TeamLaunchParams | undefined, diff --git a/src/renderer/utils/teamLaunchSummaryCopy.ts b/src/renderer/utils/teamLaunchSummaryCopy.ts index 895e1ecc..5e704772 100644 --- a/src/renderer/utils/teamLaunchSummaryCopy.ts +++ b/src/renderer/utils/teamLaunchSummaryCopy.ts @@ -2,10 +2,10 @@ export function buildPendingRuntimeSummaryCopy(input: { confirmedCount?: number | null; expectedMemberCount?: number | null; memberCount?: number | null; - runtimeAlivePendingCount?: number | null; + runtimeProcessPendingCount?: number | null; includePeriod?: boolean; }): string { - const pendingCount = input.runtimeAlivePendingCount ?? 0; + const pendingCount = input.runtimeProcessPendingCount ?? 0; if (pendingCount <= 0) { return input.includePeriod ? 'Last launch is still reconciling.' diff --git a/src/renderer/utils/teamModelAvailability.ts b/src/renderer/utils/teamModelAvailability.ts index ddd09c16..fd944ac1 100644 --- a/src/renderer/utils/teamModelAvailability.ts +++ b/src/renderer/utils/teamModelAvailability.ts @@ -23,7 +23,6 @@ import type { } from '@shared/types'; export { - CODEX_DYNAMIC_MODEL_REQUIRES_RUNTIME_SUPPORT_REASON, GPT_5_1_CODEX_MAX_CHATGPT_UI_DISABLED_REASON, GPT_5_1_CODEX_MINI_UI_DISABLED_MODEL, GPT_5_1_CODEX_MINI_UI_DISABLED_REASON, diff --git a/src/renderer/utils/teamModelCatalog.ts b/src/renderer/utils/teamModelCatalog.ts index e951b4f3..da480b2c 100644 --- a/src/renderer/utils/teamModelCatalog.ts +++ b/src/renderer/utils/teamModelCatalog.ts @@ -21,7 +21,7 @@ export { type SupportedProviderId = CliProviderId | TeamProviderId; type RuntimeAwareProviderStatus = Pick< CliProviderStatus, - 'providerId' | 'authMethod' | 'backend' | 'modelCatalog' | 'runtimeCapabilities' + 'providerId' | 'authMethod' | 'backend' | 'modelCatalog' >; export interface TeamProviderModelOption { @@ -40,8 +40,6 @@ export const GPT_5_2_CODEX_UI_DISABLED_REASON = 'Temporarily disabled for team agents - this model is not currently available on the Codex native runtime.'; export const GPT_5_3_CODEX_SPARK_UI_DISABLED_REASON = 'Temporarily disabled for team agents - this model has been less reliable with bootstrap, task, and reply tool contracts.'; -export const CODEX_DYNAMIC_MODEL_REQUIRES_RUNTIME_SUPPORT_REASON = - 'Available in Codex, waiting for Agent Teams runtime support.'; const TEAM_PROVIDER_LABELS: Record = { anthropic: 'Anthropic', @@ -166,13 +164,6 @@ function getKnownTeamProviderModelOption( return TEAM_PROVIDER_MODEL_OPTIONS[providerId].find((option) => option.value === trimmed); } -function isKnownTeamProviderModel( - providerId: SupportedProviderId | undefined, - model: string | undefined -): boolean { - return Boolean(getKnownTeamProviderModelOption(providerId, model)); -} - export function getTeamProviderModelOptions( providerId: SupportedProviderId ): readonly TeamProviderModelOption[] { @@ -471,18 +462,6 @@ export function getRuntimeAwareTeamModelUiDisabledReason( return null; } - if ( - providerId === 'codex' && - providerStatus?.modelCatalog?.providerId === 'codex' && - providerStatus.modelCatalog.models.some( - (item) => item.launchModel === trimmed || item.id === trimmed - ) && - !isKnownTeamProviderModel(providerId, trimmed) && - providerStatus.runtimeCapabilities?.modelCatalog?.dynamic !== true - ) { - return CODEX_DYNAMIC_MODEL_REQUIRES_RUNTIME_SUPPORT_REASON; - } - return isRuntimeHiddenTeamModel(providerId, trimmed, providerStatus) ? GPT_5_1_CODEX_MAX_CHATGPT_UI_DISABLED_REASON : null; diff --git a/src/renderer/utils/teamProvisioningPresentation.ts b/src/renderer/utils/teamProvisioningPresentation.ts index 49528d1d..ee4c6fda 100644 --- a/src/renderer/utils/teamProvisioningPresentation.ts +++ b/src/renderer/utils/teamProvisioningPresentation.ts @@ -32,6 +32,22 @@ interface FailedSpawnDetail { reason: string | null; } +interface SkippedSpawnDetail { + name: string; + reason: string | null; +} + +type PendingDiagnosticBucket = + | 'shellOnly' + | 'runtimeProcess' + | 'runtimeCandidate' + | 'permission' + | 'noRuntime'; + +type PendingDiagnosticNameGroups = Record; + +const MAX_PENDING_DIAGNOSTIC_NAMES = 4; + function parseStatusUpdatedAtMs(value: string | undefined): number | null { if (!value) { return null; @@ -44,6 +60,10 @@ function isFailedSpawnEntry(entry: MemberSpawnStatusEntry | undefined): boolean return entry?.launchState === 'failed_to_start' || entry?.status === 'error'; } +function isSkippedSpawnEntry(entry: MemberSpawnStatusEntry | undefined): boolean { + return entry?.launchState === 'skipped_for_launch' || entry?.skippedForLaunch === true; +} + function shouldPreferSnapshotEntryOverLive(params: { liveEntry: MemberSpawnStatusEntry | undefined; snapshotEntry: MemberSpawnStatusEntry | undefined; @@ -126,6 +146,137 @@ function buildAwaitingPermissionPhrase(count: number): string { : `${count} teammates awaiting permission approval`; } +function getMemberNamesFromSpawnSources(params: { + memberSpawnStatuses: MemberSpawnStatusCollection; + memberSpawnSnapshotStatuses?: MemberSpawnStatusesSnapshot['statuses']; +}): string[] { + const names = new Set(); + 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) || + isSkippedSpawnEntry(entry) + ) { + continue; + } + if ( + entry.launchState === 'runtime_pending_permission' || + (entry.pendingPermissionRequestIds?.length ?? 0) > 0 + ) { + groups.permission.push(name); + continue; + } + if (entry.livenessKind === 'shell_only') { + groups.shellOnly.push(name); + } else if (entry.livenessKind === 'runtime_process') { + groups.runtimeProcess.push(name); + } else if (entry.livenessKind === 'runtime_process_candidate') { + groups.runtimeCandidate.push(name); + } else if ( + entry.livenessKind === 'not_found' || + entry.livenessKind === 'stale_metadata' || + entry.livenessKind === 'registered_only' + ) { + groups.noRuntime.push(name); + } + } + + return groups; +} + +function formatNamedPendingDiagnostic(label: string, names: readonly string[]): string | null { + if (names.length === 0) { + return null; + } + const listedNames = names.slice(0, MAX_PENDING_DIAGNOSTIC_NAMES).join(', '); + const remainingCount = names.length - Math.min(names.length, MAX_PENDING_DIAGNOSTIC_NAMES); + return `${label}: ${listedNames}${remainingCount > 0 ? `, +${remainingCount} more` : ''}`; +} + +function formatCountPendingDiagnostic(count: number | undefined, label: string): string | null { + return count && count > 0 ? `${count} ${label}` : null; +} + +function buildPendingDiagnosticPhrase({ + summary, + memberSpawnStatuses, + memberSpawnSnapshotStatuses, + memberSpawnSnapshotUpdatedAt, + fallbackJoiningPhrase, +}: { + summary: MemberSpawnStatusesSnapshot['summary'] | undefined; + memberSpawnStatuses: MemberSpawnStatusCollection; + memberSpawnSnapshotStatuses?: MemberSpawnStatusesSnapshot['statuses']; + memberSpawnSnapshotUpdatedAt?: string; + fallbackJoiningPhrase: string; +}): string { + const groups = getPendingDiagnosticNameGroups({ + memberSpawnStatuses, + memberSpawnSnapshotStatuses, + memberSpawnSnapshotUpdatedAt, + }); + const namedParts = [ + formatNamedPendingDiagnostic('Shell-only', groups.shellOnly), + formatNamedPendingDiagnostic('Waiting for bootstrap', groups.runtimeProcess), + formatNamedPendingDiagnostic('Process candidates', groups.runtimeCandidate), + formatNamedPendingDiagnostic('Awaiting permission', groups.permission), + formatNamedPendingDiagnostic('No runtime found', groups.noRuntime), + ].filter(Boolean); + if (namedParts.length > 0) { + return namedParts.join(', '); + } + if (!summary) { + return fallbackJoiningPhrase; + } + const countParts = [ + formatCountPendingDiagnostic(summary.shellOnlyPendingCount, 'shell-only'), + formatCountPendingDiagnostic(summary.runtimeProcessPendingCount, 'waiting for bootstrap'), + formatCountPendingDiagnostic(summary.runtimeCandidatePendingCount, 'process candidates'), + formatCountPendingDiagnostic(summary.permissionPendingCount, 'awaiting permission'), + formatCountPendingDiagnostic(summary.noRuntimePendingCount, 'no runtime found'), + ].filter(Boolean); + return countParts.length > 0 ? countParts.join(', ') : fallbackJoiningPhrase; +} + const ACTIVE_PROVISIONING_STATES = new Set([ 'validating', 'spawning', @@ -189,6 +340,56 @@ function getFailedSpawnDetails(params: { .sort((left, right) => left.name.localeCompare(right.name)); } +function getSkippedSpawnDetails(params: { + memberSpawnStatuses: MemberSpawnStatusCollection; + memberSpawnSnapshotStatuses?: MemberSpawnStatusesSnapshot['statuses']; + memberSpawnSnapshotUpdatedAt?: string; +}): SkippedSpawnDetail[] { + const names = new Set(); + 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); + } + + if (names.size === 0) { + return []; + } + + return [...names] + .map((name) => { + const liveEntry = + params.memberSpawnStatuses instanceof Map + ? params.memberSpawnStatuses.get(name) + : params.memberSpawnStatuses?.[name]; + const snapshotEntry = params.memberSpawnSnapshotStatuses?.[name]; + return [ + name, + getPreferredSpawnEntry({ + liveEntry, + snapshotEntry, + snapshotUpdatedAt: params.memberSpawnSnapshotUpdatedAt, + }), + ] as const; + }) + .filter(([, entry]) => isSkippedSpawnEntry(entry)) + .map(([name, entry]) => ({ + name, + reason: + typeof entry?.skipReason === 'string' && entry.skipReason.trim().length > 0 + ? entry.skipReason.trim() + : null, + })) + .sort((left, right) => left.name.localeCompare(right.name)); +} + function truncateFailureReason(reason: string, maxLength = 160): string { const normalized = reason.replace(/\s+/g, ' ').trim(); if (normalized.length <= maxLength) { @@ -244,6 +445,42 @@ function buildGenericFailedSpawnPanelMessage( return `${failedSpawnCount}/${Math.max(expectedTeammateCount, failedSpawnCount)} teammates failed to start`; } +function buildSkippedSpawnPanelMessage( + skippedSpawnDetails: readonly SkippedSpawnDetail[] +): string | null { + if (skippedSpawnDetails.length === 0) { + return null; + } + if (skippedSpawnDetails.length === 1) { + const [skipped] = skippedSpawnDetails; + return skipped.reason + ? `${skipped.name} skipped for this launch - ${truncateFailureReason(skipped.reason, 220)}` + : `${skipped.name} skipped for this launch`; + } + const listedSkipped = skippedSpawnDetails + .slice(0, 3) + .map((skipped) => + skipped.reason + ? `${skipped.name} - ${truncateFailureReason(skipped.reason, 100)}` + : skipped.name + ) + .join('; '); + const remainingCount = skippedSpawnDetails.length - Math.min(skippedSpawnDetails.length, 3); + return `Skipped teammates: ${listedSkipped}${remainingCount > 0 ? `; +${remainingCount} more` : ''}`; +} + +function buildSkippedSpawnCompactDetail( + skippedSpawnDetails: readonly SkippedSpawnDetail[] +): string | null { + if (skippedSpawnDetails.length === 0) { + return null; + } + if (skippedSpawnDetails.length === 1) { + return `${skippedSpawnDetails[0].name} skipped`; + } + return `${skippedSpawnDetails.length} teammates skipped`; +} + export interface TeamProvisioningPresentation { progress: TeamProvisioningProgress; isActive: boolean; @@ -256,6 +493,7 @@ export interface TeamProvisioningPresentation { processOnlyAliveCount: number; pendingSpawnCount: number; failedSpawnCount: number; + skippedSpawnCount: number; allTeammatesConfirmedAlive: boolean; hasMembersStillJoining: boolean; remainingJoinCount: number; @@ -317,6 +555,7 @@ export function buildTeamProvisioningPresentation({ processOnlyAliveCount, pendingSpawnCount, failedSpawnCount, + skippedSpawnCount, } = getLaunchJoinMilestonesFromMembers({ members, memberSpawnStatuses, @@ -333,6 +572,13 @@ export function buildTeamProvisioningPresentation({ failedSpawnCount, expectedTeammateCount ); + const skippedSpawnDetails = getSkippedSpawnDetails({ + memberSpawnStatuses, + memberSpawnSnapshotStatuses: memberSpawnSnapshot?.statuses, + memberSpawnSnapshotUpdatedAt: memberSpawnSnapshot?.updatedAt, + }); + const skippedSpawnPanelMessage = buildSkippedSpawnPanelMessage(skippedSpawnDetails); + const skippedSpawnCompactDetail = buildSkippedSpawnCompactDetail(skippedSpawnDetails); const permissionBlockedCount = countPermissionBlockedMembers({ memberSpawnStatuses, memberSpawnSnapshotStatuses: memberSpawnSnapshot?.statuses, @@ -346,6 +592,7 @@ export function buildTeamProvisioningPresentation({ processOnlyAliveCount, pendingSpawnCount, failedSpawnCount, + skippedSpawnCount, }); const progressStepIndex = getDisplayStepIndex({ @@ -355,6 +602,7 @@ export function buildTeamProvisioningPresentation({ processOnlyAliveCount, pendingSpawnCount, failedSpawnCount, + skippedSpawnCount, }); if (isFailed) { @@ -370,6 +618,7 @@ export function buildTeamProvisioningPresentation({ processOnlyAliveCount, pendingSpawnCount, failedSpawnCount, + skippedSpawnCount, allTeammatesConfirmedAlive, hasMembersStillJoining, remainingJoinCount, @@ -394,36 +643,54 @@ export function buildTeamProvisioningPresentation({ permissionBlockedCount === remainingJoinCount; const pendingDetailPhrase = pendingMembersAwaitApproval ? buildAwaitingPermissionPhrase(permissionBlockedCount) - : joiningPhrase; + : buildPendingDiagnosticPhrase({ + summary: memberSpawnSnapshot?.summary, + memberSpawnStatuses, + memberSpawnSnapshotStatuses: memberSpawnSnapshot?.statuses, + memberSpawnSnapshotUpdatedAt: memberSpawnSnapshot?.updatedAt, + fallbackJoiningPhrase: joiningPhrase, + }); const readyCompactDetail = failedSpawnCount > 0 ? (failedSpawnCompactDetail ?? `${failedSpawnCount} teammate${failedSpawnCount === 1 ? '' : 's'} failed to start`) - : hasMembersStillJoining - ? pendingDetailPhrase - : expectedTeammateCount === 0 - ? 'Lead online' - : `All ${expectedTeammateCount} teammates joined`; + : skippedSpawnCount > 0 + ? (skippedSpawnCompactDetail ?? + `${skippedSpawnCount} teammate${skippedSpawnCount === 1 ? '' : 's'} skipped`) + : hasMembersStillJoining + ? pendingDetailPhrase + : expectedTeammateCount === 0 + ? 'Lead online' + : `All ${expectedTeammateCount} teammates joined`; const readyDetailMessage = failedSpawnCount > 0 ? (failedSpawnPanelMessage ?? genericFailedSpawnPanelMessage ?? progress.message) - : expectedTeammateCount === 0 - ? 'Team provisioned - lead online' - : allTeammatesConfirmedAlive - ? `Team provisioned - all ${expectedTeammateCount} teammates joined` - : hasMembersStillJoining - ? pendingDetailPhrase - : 'Team provisioned - teammates are still joining'; + : skippedSpawnCount > 0 + ? (skippedSpawnPanelMessage ?? + `${skippedSpawnCount}/${Math.max(expectedTeammateCount, skippedSpawnCount)} teammates skipped for this launch`) + : expectedTeammateCount === 0 + ? 'Team provisioned - lead online' + : allTeammatesConfirmedAlive + ? `Team provisioned - all ${expectedTeammateCount} teammates joined` + : hasMembersStillJoining + ? pendingDetailPhrase + : 'Team provisioned - teammates are still joining'; const readyDetailSeverity = - failedSpawnCount > 0 ? 'warning' : hasMembersStillJoining ? 'info' : undefined; + failedSpawnCount > 0 || skippedSpawnCount > 0 + ? 'warning' + : hasMembersStillJoining + ? 'info' + : undefined; const readyMessage = failedSpawnCount > 0 ? `Launch finished with errors - ${failedSpawnCount}/${Math.max(expectedTeammateCount, failedSpawnCount)} teammates failed to start` - : expectedTeammateCount === 0 - ? 'Team launched - lead online' - : allTeammatesConfirmedAlive - ? `Team launched - all ${expectedTeammateCount} teammates joined` - : 'Finishing launch'; + : skippedSpawnCount > 0 + ? `Launch continued - ${skippedSpawnCount}/${Math.max(expectedTeammateCount, skippedSpawnCount)} teammates skipped` + : expectedTeammateCount === 0 + ? 'Team launched - lead online' + : allTeammatesConfirmedAlive + ? `Team launched - all ${expectedTeammateCount} teammates joined` + : 'Finishing launch'; return { progress, @@ -436,27 +703,45 @@ export function buildTeamProvisioningPresentation({ processOnlyAliveCount, pendingSpawnCount, failedSpawnCount, + skippedSpawnCount, allTeammatesConfirmedAlive, hasMembersStillJoining, remainingJoinCount, panelTitle: 'Launch details', - panelMessage: failedSpawnCount > 0 || hasMembersStillJoining ? readyDetailMessage : null, + panelMessage: + failedSpawnCount > 0 || skippedSpawnCount > 0 || hasMembersStillJoining + ? readyDetailMessage + : null, panelMessageSeverity: readyDetailSeverity, successMessage: readyMessage, successMessageSeverity: - failedSpawnCount > 0 ? 'warning' : hasMembersStillJoining ? 'info' : 'success', + failedSpawnCount > 0 || skippedSpawnCount > 0 + ? 'warning' + : hasMembersStillJoining + ? 'info' + : 'success', defaultLiveOutputOpen: false, compactTitle: failedSpawnCount > 0 ? 'Launch finished with errors' - : hasMembersStillJoining - ? 'Finishing launch' - : 'Team launched', + : skippedSpawnCount > 0 + ? 'Launch continued with skipped teammates' + : hasMembersStillJoining + ? 'Finishing launch' + : 'Team launched', compactDetail: readyCompactDetail, compactTone: - failedSpawnCount > 0 ? 'warning' : hasMembersStillJoining ? 'default' : 'success', + failedSpawnCount > 0 || skippedSpawnCount > 0 + ? 'warning' + : hasMembersStillJoining + ? 'default' + : 'success', currentStepIndex: - failedSpawnCount > 0 ? 2 : hasMembersStillJoining ? 2 : DISPLAY_COMPLETE_STEP_INDEX, + failedSpawnCount > 0 || skippedSpawnCount > 0 + ? 2 + : hasMembersStillJoining + ? 2 + : DISPLAY_COMPLETE_STEP_INDEX, }; } @@ -471,7 +756,13 @@ export function buildTeamProvisioningPresentation({ permissionBlockedCount > 0 && permissionBlockedCount === remainingJoinCount ? buildAwaitingPermissionPhrase(permissionBlockedCount) - : activeJoiningPhrase; + : buildPendingDiagnosticPhrase({ + summary: memberSpawnSnapshot?.summary, + memberSpawnStatuses, + memberSpawnSnapshotStatuses: memberSpawnSnapshot?.statuses, + memberSpawnSnapshotUpdatedAt: memberSpawnSnapshot?.updatedAt, + fallbackJoiningPhrase: activeJoiningPhrase, + }); return { progress, isActive: true, @@ -484,6 +775,7 @@ export function buildTeamProvisioningPresentation({ processOnlyAliveCount, pendingSpawnCount, failedSpawnCount, + skippedSpawnCount, allTeammatesConfirmedAlive, hasMembersStillJoining, remainingJoinCount, @@ -491,26 +783,33 @@ export function buildTeamProvisioningPresentation({ panelMessage: failedSpawnCount > 0 ? (failedSpawnPanelMessage ?? genericFailedSpawnPanelMessage ?? progress.message) - : hasMembersStillJoining && - permissionBlockedCount > 0 && - permissionBlockedCount === remainingJoinCount - ? activePendingDetailPhrase - : progress.message, - panelMessageSeverity: failedSpawnCount > 0 ? 'warning' : progress.messageSeverity, + : skippedSpawnCount > 0 + ? (skippedSpawnPanelMessage ?? + `${skippedSpawnCount}/${Math.max(expectedTeammateCount, skippedSpawnCount)} teammates skipped for this launch`) + : hasMembersStillJoining && + permissionBlockedCount > 0 && + permissionBlockedCount === remainingJoinCount + ? activePendingDetailPhrase + : progress.message, + panelMessageSeverity: + failedSpawnCount > 0 || skippedSpawnCount > 0 ? 'warning' : progress.messageSeverity, defaultLiveOutputOpen: false, compactTitle: 'Launching team', compactDetail: failedSpawnCount > 0 ? (failedSpawnCompactDetail ?? `${failedSpawnCount} teammate${failedSpawnCount === 1 ? '' : 's'} failed to start`) - : hasMembersStillJoining && failedSpawnCount === 0 && permissionBlockedCount > 0 - ? permissionBlockedCount === remainingJoinCount - ? buildAwaitingPermissionPhrase(permissionBlockedCount) - : `${heartbeatConfirmedCount}/${expectedTeammateCount} teammates confirmed` - : expectedTeammateCount > 0 && progressStepIndex >= 2 - ? `${heartbeatConfirmedCount}/${expectedTeammateCount} teammates confirmed` - : progress.message, - compactTone: failedSpawnCount > 0 ? 'warning' : 'default', + : skippedSpawnCount > 0 + ? (skippedSpawnCompactDetail ?? + `${skippedSpawnCount} teammate${skippedSpawnCount === 1 ? '' : 's'} skipped`) + : hasMembersStillJoining && failedSpawnCount === 0 && permissionBlockedCount > 0 + ? permissionBlockedCount === remainingJoinCount + ? buildAwaitingPermissionPhrase(permissionBlockedCount) + : `${heartbeatConfirmedCount}/${expectedTeammateCount} teammates confirmed` + : expectedTeammateCount > 0 && progressStepIndex >= 2 + ? `${heartbeatConfirmedCount}/${expectedTeammateCount} teammates confirmed` + : progress.message, + compactTone: failedSpawnCount > 0 || skippedSpawnCount > 0 ? 'warning' : 'default', }; } diff --git a/src/shared/types/api.ts b/src/shared/types/api.ts index ff84e0c1..9cbc679e 100644 --- a/src/shared/types/api.ts +++ b/src/shared/types/api.ts @@ -542,6 +542,7 @@ export interface TeamsAPI { getMemberSpawnStatuses: (teamName: string) => Promise; getTeamAgentRuntime: (teamName: string) => Promise; restartMember: (teamName: string, memberName: string) => Promise; + skipMemberForLaunch: (teamName: string, memberName: string) => Promise; softDeleteTask: (teamName: string, taskId: string) => Promise; restoreTask: (teamName: string, taskId: string) => Promise; getDeletedTasks: (teamName: string) => Promise; diff --git a/src/shared/types/team.ts b/src/shared/types/team.ts index 67328426..334c3725 100644 --- a/src/shared/types/team.ts +++ b/src/shared/types/team.ts @@ -73,6 +73,8 @@ export interface TeamSummary { confirmedMemberCount?: number; /** Missing teammate names from the last partial launch marker. */ missingMembers?: string[]; + /** Teammates intentionally skipped for the last launch. */ + skippedMembers?: string[]; /** Durable aggregate launch state derived from persisted launch-state evidence. */ teamLaunchState?: TeamLaunchAggregateState; /** ISO timestamp of the last durable launch-state evaluation. */ @@ -81,7 +83,13 @@ export interface TeamSummary { confirmedCount?: number; pendingCount?: number; failedCount?: number; + skippedCount?: number; runtimeAlivePendingCount?: number; + shellOnlyPendingCount?: number; + runtimeProcessPendingCount?: number; + runtimeCandidatePendingCount?: number; + noRuntimePendingCount?: number; + permissionPendingCount?: number; } export type TeamTaskStatus = 'pending' | 'in_progress' | 'completed' | 'deleted'; @@ -687,14 +695,19 @@ export interface AddTaskCommentRequest { export type MemberStatus = 'active' | 'idle' | 'terminated' | 'unknown'; -export type MemberSpawnStatus = 'offline' | 'waiting' | 'spawning' | 'online' | 'error'; +export type MemberSpawnStatus = 'offline' | 'waiting' | 'spawning' | 'online' | 'error' | 'skipped'; export type MemberLaunchState = | 'starting' | 'runtime_pending_bootstrap' | 'runtime_pending_permission' | 'confirmed_alive' - | 'failed_to_start'; -export type TeamLaunchAggregateState = 'clean_success' | 'partial_pending' | 'partial_failure'; + | 'failed_to_start' + | 'skipped_for_launch'; +export type TeamLaunchAggregateState = + | 'clean_success' + | 'partial_pending' + | 'partial_failure' + | 'partial_skipped'; export type PersistedTeamLaunchPhase = 'active' | 'finished' | 'reconciled'; export type KanbanColumnId = 'todo' | 'in_progress' | 'done' | 'review' | 'approved'; @@ -943,6 +956,9 @@ export interface PersistedTeamLaunchMemberState { laneOwnerProviderId?: TeamProviderId; launchIdentity?: ProviderModelLaunchIdentity; launchState: MemberLaunchState; + skippedForLaunch?: boolean; + skipReason?: string; + skippedAt?: string; agentToolAccepted: boolean; runtimeAlive: boolean; bootstrapConfirmed: boolean; @@ -950,6 +966,12 @@ export interface PersistedTeamLaunchMemberState { hardFailureReason?: string; pendingPermissionRequestIds?: string[]; runtimePid?: number; + runtimeSessionId?: string; + livenessKind?: TeamAgentRuntimeLivenessKind; + pidSource?: TeamAgentRuntimePidSource; + runtimeDiagnostic?: string; + runtimeDiagnosticSeverity?: TeamAgentRuntimeDiagnosticSeverity; + runtimeLastSeenAt?: string; firstSpawnAcceptedAt?: string; lastHeartbeatAt?: string; lastRuntimeAliveAt?: string; @@ -962,7 +984,13 @@ export interface PersistedTeamLaunchSummary { confirmedCount: number; pendingCount: number; failedCount: number; + skippedCount?: number; runtimeAlivePendingCount: number; + shellOnlyPendingCount?: number; + runtimeProcessPendingCount?: number; + runtimeCandidatePendingCount?: number; + noRuntimePendingCount?: number; + permissionPendingCount?: number; } export interface PersistedTeamLaunchSnapshot { @@ -993,6 +1021,27 @@ export type MemberSpawnLivenessSource = 'heartbeat' | 'process'; export type TeamAgentRuntimeBackendType = 'lead' | 'tmux' | 'iterm2' | 'in-process' | 'process'; +export type TeamAgentRuntimeLivenessKind = + | 'confirmed_bootstrap' + | 'runtime_process' + | 'runtime_process_candidate' + | 'permission_blocked' + | 'shell_only' + | 'registered_only' + | 'stale_metadata' + | 'not_found'; + +export type TeamAgentRuntimePidSource = + | 'lead_process' + | 'tmux_pane' + | 'tmux_child' + | 'agent_process_table' + | 'opencode_bridge' + | 'runtime_bootstrap' + | 'persisted_metadata'; + +export type TeamAgentRuntimeDiagnosticSeverity = 'info' | 'warning' | 'error'; + export interface TeamAgentRuntimeEntry { memberName: string; alive: boolean; @@ -1005,6 +1054,21 @@ export interface TeamAgentRuntimeEntry { pid?: number; runtimeModel?: string; rssBytes?: number; + livenessKind?: TeamAgentRuntimeLivenessKind; + pidSource?: TeamAgentRuntimePidSource; + processCommand?: string; + paneId?: string; + panePid?: number; + paneCurrentCommand?: string; + runtimePid?: number; + runtimeSessionId?: string; + runtimeLeaseExpiresAt?: string; + runtimeLastSeenAt?: string; + /** True when a previous/persisted launch confirmed bootstrap, separate from current live liveness. */ + historicalBootstrapConfirmed?: boolean; + runtimeDiagnostic?: string; + runtimeDiagnosticSeverity?: TeamAgentRuntimeDiagnosticSeverity; + diagnostics?: string[]; updatedAt: string; } @@ -1049,6 +1113,10 @@ export interface MemberSpawnStatusEntry { error?: string; /** Hard failure reason for failed_to_start. */ hardFailureReason?: string; + /** True when the user intentionally skipped this teammate for the current launch only. */ + skippedForLaunch?: boolean; + skipReason?: string; + skippedAt?: string; /** * Optional provenance for `online`. * - heartbeat: teammate sent a real inbox/native message after bootstrap @@ -1071,6 +1139,14 @@ export interface MemberSpawnStatusEntry { lastHeartbeatAt?: string; /** Live runtime model observed from the teammate process, when available. */ runtimeModel?: string; + /** Compact runtime liveness classification for launch UI. */ + livenessKind?: TeamAgentRuntimeLivenessKind; + /** Short user-facing liveness diagnostic. */ + runtimeDiagnostic?: string; + /** Visual severity for runtimeDiagnostic. */ + runtimeDiagnosticSeverity?: TeamAgentRuntimeDiagnosticSeverity; + /** ISO timestamp of the last liveness evaluation. */ + livenessLastCheckedAt?: string; /** ISO timestamp of the last status change. */ updatedAt: string; } @@ -1185,6 +1261,28 @@ export interface TeamProvisioningProgress { assistantOutput?: string; /** True once provisioning has written a readable config.json for this team. */ configReady?: boolean; + /** Bounded structured launch diagnostics for the progress UI. */ + launchDiagnostics?: TeamLaunchDiagnosticItem[]; +} + +export interface TeamLaunchDiagnosticItem { + id: string; + memberName?: string; + severity: TeamAgentRuntimeDiagnosticSeverity; + code: + | 'spawn_accepted' + | 'runtime_process_detected' + | 'runtime_process_candidate' + | 'tmux_shell_only' + | 'runtime_not_found' + | 'permission_pending' + | 'bootstrap_confirmed' + | 'bootstrap_stalled' + | 'stale_runtime_event_rejected' + | 'process_table_unavailable'; + label: string; + detail?: string; + observedAt: string; } export interface TeamRuntimeState { diff --git a/src/shared/utils/reviewState.ts b/src/shared/utils/reviewState.ts index 6dea4400..231cdd9b 100644 --- a/src/shared/utils/reviewState.ts +++ b/src/shared/utils/reviewState.ts @@ -24,13 +24,28 @@ export function getReviewStateFromTask(task: ReviewStateLike): TeamReviewState { } } - const explicit = normalizeReviewState(task.reviewState); - if (explicit !== 'none') { + const fallbackStatus = typeof task.status === 'string' ? task.status : null; + const normalizeFallback = (value: unknown): TeamReviewState | null => { + const explicit = normalizeReviewState(value); + if (explicit === 'none') return null; + + if (fallbackStatus === 'in_progress' || fallbackStatus === 'deleted') { + return 'none'; + } + if (fallbackStatus === 'pending') { + return explicit === 'needsFix' ? 'needsFix' : 'none'; + } + if (fallbackStatus === 'completed') { + return explicit === 'review' || explicit === 'approved' ? explicit : 'none'; + } return explicit; - } + }; + + const explicit = normalizeFallback(task.reviewState); + if (explicit) return explicit; if (task.kanbanColumn === 'review' || task.kanbanColumn === 'approved') { - return task.kanbanColumn; + return normalizeFallback(task.kanbanColumn) ?? 'none'; } return 'none'; diff --git a/src/shared/utils/taskChangeState.ts b/src/shared/utils/taskChangeState.ts index cb55e0c5..e0c7e4e0 100644 --- a/src/shared/utils/taskChangeState.ts +++ b/src/shared/utils/taskChangeState.ts @@ -1,6 +1,6 @@ -import { getDerivedReviewState } from './taskHistory'; +import { getReviewStateFromTask } from './reviewState'; -import type { TaskHistoryEvent, TeamReviewState } from '@shared/types'; +import type { TeamReviewState } from '@shared/types'; export type TaskChangeStateBucket = 'approved' | 'review' | 'completed' | 'active'; @@ -11,25 +11,8 @@ interface TaskChangeStateLike { kanbanColumn?: 'review' | 'approved' | null; } -function normalizeReviewState(value: unknown): TeamReviewState { - return value === 'review' || value === 'needsFix' || value === 'approved' ? value : 'none'; -} - function getEffectiveReviewState(task: TaskChangeStateLike): TeamReviewState { - if (Array.isArray(task.historyEvents) && task.historyEvents.length > 0) { - return getDerivedReviewState({ historyEvents: task.historyEvents as TaskHistoryEvent[] }); - } - - const explicit = normalizeReviewState(task.reviewState); - if (explicit !== 'none') { - return explicit; - } - - if (task.kanbanColumn === 'review' || task.kanbanColumn === 'approved') { - return task.kanbanColumn; - } - - return 'none'; + return getReviewStateFromTask(task); } export function getTaskChangeStateBucket(task: TaskChangeStateLike): TaskChangeStateBucket { diff --git a/test/features/recent-projects/core/application/ListDashboardRecentProjectsUseCase.test.ts b/test/features/recent-projects/core/application/ListDashboardRecentProjectsUseCase.test.ts index b68d88f3..9a5d96aa 100644 --- a/test/features/recent-projects/core/application/ListDashboardRecentProjectsUseCase.test.ts +++ b/test/features/recent-projects/core/application/ListDashboardRecentProjectsUseCase.test.ts @@ -155,7 +155,7 @@ describe('ListDashboardRecentProjectsUseCase', () => { }), ], }); - expect(cache.set).toHaveBeenCalledWith('recent-projects:fresh', result, 1_500); + expect(cache.set).toHaveBeenCalledWith('recent-projects:fresh', result, 30_000); expect(logger.warn).toHaveBeenCalledWith('recent-projects source failed', { sourceId: 'source-1', sourceIndex: 1, @@ -165,7 +165,7 @@ describe('ListDashboardRecentProjectsUseCase', () => { cacheKey: 'recent-projects:fresh', count: 1, degradedSources: 1, - cacheTtlMs: 1_500, + cacheTtlMs: 30_000, durationMs: 250, }); }); @@ -242,7 +242,7 @@ describe('ListDashboardRecentProjectsUseCase', () => { expect(cache.set).toHaveBeenCalledWith( 'recent-projects:timeout', { ids: ['repo:fast'], sources: ['claude'] }, - 1_500 + 30_000 ); } finally { vi.useRealTimers(); @@ -311,13 +311,13 @@ describe('ListDashboardRecentProjectsUseCase', () => { expect(cache.set).toHaveBeenCalledWith( 'recent-projects:stale', { ids: ['repo:fresh'], sources: ['claude'] }, - 1_500 + 30_000 ); expect(logger.info).toHaveBeenCalledWith('recent-projects loaded', { cacheKey: 'recent-projects:stale', count: 1, degradedSources: 1, - cacheTtlMs: 1_500, + cacheTtlMs: 30_000, durationMs: 200, }); }); @@ -364,11 +364,11 @@ describe('ListDashboardRecentProjectsUseCase', () => { await expect(useCase.execute('recent-projects:stale')).resolves.toEqual(stale); expect(output.present).not.toHaveBeenCalled(); - expect(cache.set).toHaveBeenCalledWith('recent-projects:stale', stale, 1_500); + expect(cache.set).toHaveBeenCalledWith('recent-projects:stale', stale, 30_000); expect(logger.info).toHaveBeenCalledWith('recent-projects served stale cache', { cacheKey: 'recent-projects:stale', degradedSources: 1, - cacheTtlMs: 1_500, + cacheTtlMs: 30_000, durationMs: 200, }); }); @@ -431,13 +431,13 @@ describe('ListDashboardRecentProjectsUseCase', () => { expect(cache.set).toHaveBeenCalledWith( 'recent-projects:explicit-degraded', { ids: ['repo:alpha'], sources: ['claude'] }, - 1_500 + 30_000 ); expect(logger.info).toHaveBeenCalledWith('recent-projects loaded', { cacheKey: 'recent-projects:explicit-degraded', count: 1, degradedSources: 1, - cacheTtlMs: 1_500, + cacheTtlMs: 30_000, durationMs: 0, }); }); diff --git a/test/features/recent-projects/main/adapters/output/CodexRecentProjectsSourceAdapter.test.ts b/test/features/recent-projects/main/adapters/output/CodexRecentProjectsSourceAdapter.test.ts index 634aabff..62606254 100644 --- a/test/features/recent-projects/main/adapters/output/CodexRecentProjectsSourceAdapter.test.ts +++ b/test/features/recent-projects/main/adapters/output/CodexRecentProjectsSourceAdapter.test.ts @@ -166,7 +166,18 @@ describe('CodexRecentProjectsSourceAdapter', () => { candidates: [], degraded: true, }); + await expect(adapter.list()).resolves.toEqual({ + candidates: [], + degraded: true, + }); + expect(appServerClient.listRecentThreads).toHaveBeenCalledTimes(1); expect(appServerClient.listRecentLiveThreads).not.toHaveBeenCalled(); + expect(logger.info).toHaveBeenCalledWith( + 'codex recent-projects source cooldown active', + expect.objectContaining({ + reason: 'codex app-server thread/list timed out after 8500ms', + }) + ); }); it('drops Codex appstyle temp workspaces from dashboard candidates', async () => { @@ -209,4 +220,81 @@ describe('CodexRecentProjectsSourceAdapter', () => { expect(identityResolver.resolve).not.toHaveBeenCalled(); }); + + it('serves stale Codex candidates during a later full thread-list failure', async () => { + const logger = createLogger(); + const appServerClient = { + listRecentThreads: vi + .fn() + .mockResolvedValueOnce({ + live: { + threads: [ + { + id: 'thread-live', + cwd: '/Users/belief/dev/projects/headless', + source: 'cli', + updatedAt: 1_700_000_000, + gitInfo: { branch: 'main' }, + }, + ], + }, + archived: { + threads: [], + }, + }) + .mockResolvedValueOnce({ + live: { + threads: [], + error: 'JSON-RPC request timed out: thread/list live', + }, + archived: { + threads: [], + error: 'JSON-RPC request timed out: thread/list archived', + }, + }), + listRecentLiveThreads: vi.fn(), + } as unknown as CodexAppServerClient; + const identityResolver = { + resolve: vi.fn().mockResolvedValue({ + id: 'repo:headless', + name: 'headless', + }), + } as unknown as RecentProjectIdentityResolver; + + const adapter = new CodexRecentProjectsSourceAdapter({ + getActiveContext: () => ({ type: 'local', id: 'local-1' }) as never, + getLocalContext: () => ({ type: 'local', id: 'local-1' }) as never, + resolveBinary: vi.fn().mockResolvedValue('/usr/local/bin/codex'), + appServerClient, + identityResolver, + logger, + }); + + await expect(adapter.list()).resolves.toEqual({ + candidates: [ + expect.objectContaining({ + identity: 'repo:headless', + primaryPath: '/Users/belief/dev/projects/headless', + }), + ], + degraded: false, + }); + + await expect(adapter.list()).resolves.toEqual({ + candidates: [ + expect.objectContaining({ + identity: 'repo:headless', + primaryPath: '/Users/belief/dev/projects/headless', + }), + ], + degraded: true, + }); + + expect(identityResolver.resolve).toHaveBeenCalledTimes(1); + expect(logger.info).toHaveBeenCalledWith('codex recent-projects served stale candidates', { + count: 1, + reason: + 'live: JSON-RPC request timed out: thread/list live; archived: JSON-RPC request timed out: thread/list archived', + }); + }); }); diff --git a/test/features/recent-projects/main/infrastructure/CodexAppServerClient.test.ts b/test/features/recent-projects/main/infrastructure/CodexAppServerClient.test.ts index ab677e9f..de6c2661 100644 --- a/test/features/recent-projects/main/infrastructure/CodexAppServerClient.test.ts +++ b/test/features/recent-projects/main/infrastructure/CodexAppServerClient.test.ts @@ -21,8 +21,9 @@ function createSession( describe('CodexAppServerClient', () => { it('loads live and archived threads in a single app-server session', async () => { - const session = createSession( - vi.fn().mockImplementation((method: string, params?: { archived?: boolean }) => { + const request = vi + .fn() + .mockImplementation((method: string, params?: { archived?: boolean }) => { if (method === 'initialize') { return Promise.resolve({}); } @@ -40,8 +41,8 @@ describe('CodexAppServerClient', () => { } return Promise.reject(new Error(`Unexpected method: ${method}`)); - }) - ); + }); + const session = createSession(request); const withSession = vi.fn().mockImplementation((_options, handler) => handler(session)); const client = new CodexAppServerClient({ withSession } as unknown as JsonRpcStdioClient); @@ -58,11 +59,23 @@ describe('CodexAppServerClient', () => { expect.objectContaining({ binaryPath: '/usr/local/bin/codex', requestTimeoutMs: 4500, - totalTimeoutMs: 12000, + totalTimeoutMs: 14500, }), expect.any(Function) ); expect(session.notify).toHaveBeenCalledWith('initialized'); + expect(request).toHaveBeenNthCalledWith( + 2, + 'thread/list', + expect.objectContaining({ archived: false }), + 4500 + ); + expect(request).toHaveBeenNthCalledWith( + 3, + 'thread/list', + expect.objectContaining({ archived: true }), + 2500 + ); expect(result).toEqual({ live: { threads: [{ id: 'live-1', cwd: '/Users/test/live-project', source: 'cli' }], @@ -113,7 +126,7 @@ describe('CodexAppServerClient', () => { }); }); - it('raises the session timeout budget above the longest request timeout', async () => { + it('raises the session timeout budget above sequential request timeouts', async () => { const session = createSession( vi.fn().mockImplementation((method: string, params?: { archived?: boolean }) => { if (method === 'initialize') { @@ -140,7 +153,7 @@ describe('CodexAppServerClient', () => { expect(withSession).toHaveBeenCalledWith( expect.objectContaining({ - totalTimeoutMs: 12000, + totalTimeoutMs: 14500, }), expect.any(Function) ); @@ -187,18 +200,20 @@ describe('CodexAppServerClient', () => { }); it('uses the longer initialize timeout for app-server startup', async () => { - const request = vi.fn().mockImplementation((method: string, _params?: unknown, timeoutMs?: number) => { - if (method === 'initialize') { - expect(timeoutMs).toBe(6000); - return Promise.resolve({}); - } + const request = vi + .fn() + .mockImplementation((method: string, _params?: unknown, timeoutMs?: number) => { + if (method === 'initialize') { + expect(timeoutMs).toBe(6000); + return Promise.resolve({}); + } - if (method === 'thread/list') { - return Promise.resolve({ data: [] }); - } + if (method === 'thread/list') { + return Promise.resolve({ data: [] }); + } - return Promise.reject(new Error(`Unexpected method: ${method}`)); - }); + return Promise.reject(new Error(`Unexpected method: ${method}`)); + }); const session = createSession(request); const withSession = vi.fn().mockImplementation((_options, handler) => handler(session)); diff --git a/test/features/recent-projects/renderer/utils/recentProjectsClientCache.test.ts b/test/features/recent-projects/renderer/utils/recentProjectsClientCache.test.ts index d23e49a9..bf14e3b1 100644 --- a/test/features/recent-projects/renderer/utils/recentProjectsClientCache.test.ts +++ b/test/features/recent-projects/renderer/utils/recentProjectsClientCache.test.ts @@ -103,7 +103,7 @@ describe('recentProjectsClientCache', () => { await expect(second).resolves.toEqual(payload('alpha')); }); - it('marks degraded payload snapshots stale faster than healthy payloads', async () => { + it('keeps degraded payload snapshots fresh long enough to avoid hot retry loops', async () => { vi.useFakeTimers(); vi.setSystemTime(new Date('2026-04-14T12:00:00.000Z')); @@ -121,7 +121,13 @@ describe('recentProjectsClientCache', () => { isStale: false, }); - vi.setSystemTime(new Date('2026-04-14T12:00:02.000Z')); + vi.setSystemTime(new Date('2026-04-14T12:00:20.000Z')); + expect(getRecentProjectsClientSnapshot()).toMatchObject({ + payload: payload('alpha', { degraded: true }), + isStale: false, + }); + + vi.setSystemTime(new Date('2026-04-14T12:00:31.000Z')); expect(getRecentProjectsClientSnapshot()).toMatchObject({ payload: payload('alpha', { degraded: true }), isStale: true, @@ -129,7 +135,9 @@ describe('recentProjectsClientCache', () => { }); it('normalizes legacy array responses from the loader during mixed-version dev reloads', async () => { - const loader = vi.fn<() => Promise>().mockResolvedValue([project('alpha')]); + const loader = vi + .fn<() => Promise>() + .mockResolvedValue([project('alpha')]); await expect(loadRecentProjectsWithClientCache(loader)).resolves.toEqual(payload('alpha')); expect(getRecentProjectsClientSnapshot()?.payload).toEqual(payload('alpha')); diff --git a/test/main/features/tmux-installer/TmuxPlatformCommandExecutor.test.ts b/test/main/features/tmux-installer/TmuxPlatformCommandExecutor.test.ts new file mode 100644 index 00000000..76ddfc43 --- /dev/null +++ b/test/main/features/tmux-installer/TmuxPlatformCommandExecutor.test.ts @@ -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' }, + ]); + }); +}); diff --git a/test/main/services/infrastructure/FileWatcher.test.ts b/test/main/services/infrastructure/FileWatcher.test.ts index 4f2ed047..08cbbc9f 100644 --- a/test/main/services/infrastructure/FileWatcher.test.ts +++ b/test/main/services/infrastructure/FileWatcher.test.ts @@ -2,6 +2,7 @@ import { EventEmitter } from 'events'; import type * as FsType from 'fs'; import * as os from 'os'; import * as path from 'path'; +import { Readable } from 'stream'; import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest'; vi.mock('@shared/utils/logger', () => ({ @@ -500,6 +501,69 @@ describe('FileWatcher', () => { watcher.stop(); }); + + it('retires catch-up files after repeated stat timeouts', async () => { + vi.useRealTimers(); + vi.mocked(errorDetector.detectErrors).mockClear(); + + const fsProvider = { + type: 'local' as const, + exists: vi.fn().mockResolvedValue(true), + readFile: vi.fn().mockResolvedValue(''), + stat: vi.fn().mockRejectedValue(new Error('stat timeout')), + readdir: vi.fn().mockResolvedValue([]), + createReadStream: vi.fn(() => Readable.from([])), + dispose: vi.fn(), + }; + + const dataCache = new DataCache(50, 10, false); + const notificationManager = createMockNotificationManager(); + const watcher = new FileWatcher( + dataCache, + '/watch-root/projects', + '/watch-root/todos', + fsProvider + ); + watcher.setNotificationManager(notificationManager); + + const filePath = '/watch-root/projects/test-project/session-timeout.jsonl'; + const watcherAny = watcher as unknown as { + isWatching: boolean; + activeSessionFiles: Map< + string, + { projectId: string; sessionId: string; lastObservedAt: number } + >; + catchUpStatFailures: Map; + lastProcessedSize: Map; + lastProcessedLineCount: Map; + runCatchUpScan: () => Promise; + }; + watcherAny.isWatching = true; + watcherAny.activeSessionFiles.set(filePath, { + projectId: 'test-project', + sessionId: 'session-timeout', + lastObservedAt: Date.now(), + }); + watcherAny.lastProcessedSize.set(filePath, 100); + watcherAny.lastProcessedLineCount.set(filePath, 5); + + await watcherAny.runCatchUpScan(); + expect(watcherAny.activeSessionFiles.has(filePath)).toBe(true); + expect(watcherAny.catchUpStatFailures.get(filePath)).toBe(1); + + await watcherAny.runCatchUpScan(); + expect(watcherAny.activeSessionFiles.has(filePath)).toBe(true); + expect(watcherAny.catchUpStatFailures.get(filePath)).toBe(2); + + await watcherAny.runCatchUpScan(); + expect(watcherAny.activeSessionFiles.has(filePath)).toBe(false); + expect(watcherAny.catchUpStatFailures.has(filePath)).toBe(false); + expect(watcherAny.lastProcessedSize.get(filePath)).toBe(100); + expect(watcherAny.lastProcessedLineCount.get(filePath)).toBe(5); + expect(errorDetector.detectErrors).not.toHaveBeenCalled(); + + watcher.stop(); + }); }); // =========================================================================== diff --git a/test/main/services/team/AnthropicRuntimeMemory.live.test.ts b/test/main/services/team/AnthropicRuntimeMemory.live.test.ts new file mode 100644 index 00000000..786306e4 --- /dev/null +++ b/test/main/services/team/AnthropicRuntimeMemory.live.test.ts @@ -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 { + await fs.access(filePath, fsConstants.X_OK); +} + +async function waitUntil( + predicate: () => Promise, + timeoutMs: number, + pollMs = 1_000 +): Promise { + 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'); +} diff --git a/test/main/services/team/OpenCodeLaunchModeEnv.test.ts b/test/main/services/team/OpenCodeLaunchModeEnv.test.ts deleted file mode 100644 index 14b3b4a4..00000000 --- a/test/main/services/team/OpenCodeLaunchModeEnv.test.ts +++ /dev/null @@ -1,33 +0,0 @@ -import { describe, expect, it } from 'vitest'; - -import { resolveOpenCodeTeamLaunchModeFromEnv } from '../../../../src/main/services/team/opencode/config/OpenCodeLaunchModeEnv'; - -describe('resolveOpenCodeTeamLaunchModeFromEnv', () => { - it('defaults to production so OpenCode is visible while strict readiness remains authoritative', () => { - expect(resolveOpenCodeTeamLaunchModeFromEnv({})).toBe('production'); - }); - - it('preserves explicit launch mode overrides', () => { - expect( - resolveOpenCodeTeamLaunchModeFromEnv({ CLAUDE_TEAM_OPENCODE_LAUNCH_MODE: 'disabled' }) - ).toBe('disabled'); - expect( - resolveOpenCodeTeamLaunchModeFromEnv({ CLAUDE_TEAM_OPENCODE_LAUNCH_MODE: 'dogfood' }) - ).toBe('dogfood'); - expect( - resolveOpenCodeTeamLaunchModeFromEnv({ CLAUDE_TEAM_OPENCODE_LAUNCH_MODE: 'production' }) - ).toBe('production'); - }); - - it('keeps the legacy dogfood flag as an explicit opt-in', () => { - expect(resolveOpenCodeTeamLaunchModeFromEnv({ CLAUDE_TEAM_OPENCODE_DOGFOOD: '1' })).toBe( - 'dogfood' - ); - }); - - it('falls back to production for invalid launch mode values', () => { - expect( - resolveOpenCodeTeamLaunchModeFromEnv({ CLAUDE_TEAM_OPENCODE_LAUNCH_MODE: 'enabled' }) - ).toBe('production'); - }); -}); diff --git a/test/main/services/team/OpenCodeMixedRecovery.live.test.ts b/test/main/services/team/OpenCodeMixedRecovery.live.test.ts index 6328ddc0..f46a227b 100644 --- a/test/main/services/team/OpenCodeMixedRecovery.live.test.ts +++ b/test/main/services/team/OpenCodeMixedRecovery.live.test.ts @@ -15,9 +15,7 @@ import { } from '../../../../src/main/services/team/opencode/bridge/OpenCodeBridgeHandshakeClient'; import { OpenCodeReadinessBridge } from '../../../../src/main/services/team/opencode/bridge/OpenCodeReadinessBridge'; import { OpenCodeStateChangingBridgeCommandService } from '../../../../src/main/services/team/opencode/bridge/OpenCodeStateChangingBridgeCommandService'; -import { - getTeamBootstrapStatePath, -} from '../../../../src/main/services/team/TeamBootstrapStateReader'; +import { getTeamBootstrapStatePath } from '../../../../src/main/services/team/TeamBootstrapStateReader'; import { TeamMembersMetaStore } from '../../../../src/main/services/team/TeamMembersMetaStore'; import { TeamMetaStore } from '../../../../src/main/services/team/TeamMetaStore'; import { TeamProvisioningService } from '../../../../src/main/services/team/TeamProvisioningService'; @@ -68,112 +66,106 @@ liveDescribe('OpenCode mixed recovery live e2e', () => { await fs.rm(tempDir, { recursive: true, force: true }); }); - it( - 'recovers active mixed OpenCode side lanes from live runtime reconcile instead of marking them never spawned', - async () => { - const selectedModel = process.env.OPENCODE_E2E_MODEL?.trim() || DEFAULT_MODEL; - const orchestratorCli = - process.env.CLAUDE_AGENT_TEAMS_ORCHESTRATOR_CLI_PATH?.trim() || DEFAULT_ORCHESTRATOR_CLI; - await assertExecutable(orchestratorCli); + it('recovers active mixed OpenCode side lanes from live runtime reconcile instead of marking them never spawned', async () => { + const selectedModel = process.env.OPENCODE_E2E_MODEL?.trim() || DEFAULT_MODEL; + const orchestratorCli = + process.env.CLAUDE_AGENT_TEAMS_ORCHESTRATOR_CLI_PATH?.trim() || DEFAULT_ORCHESTRATOR_CLI; + await assertExecutable(orchestratorCli); - const mcpLaunchSpec = await resolveAgentTeamsMcpLaunchSpec(); - const bridgeEnv = { - ...createStableBridgeEnv(), - PATH: withBunOnPath(process.env.PATH ?? ''), - XDG_DATA_HOME: path.join(tempDir, 'xdg-data-single'), - CLAUDE_MULTIMODEL_AGENT_TEAMS_MCP_COMMAND: mcpLaunchSpec.command, - CLAUDE_MULTIMODEL_AGENT_TEAMS_MCP_ENTRY: mcpLaunchSpec.args[0] ?? '', - }; - const bridgeClient = new OpenCodeBridgeCommandClient({ - binaryPath: orchestratorCli, - tempDirectory: path.join(tempDir, 'bridge-input'), - env: bridgeEnv, - }); - const stateChangingCommands = createStateChangingCommands({ - bridge: bridgeClient, - controlDir: path.join(tempDir, 'control'), - }); - const readinessBridge = new OpenCodeReadinessBridge(bridgeClient, { - stateChangingCommands, - timeoutMs: 180_000, - launchTimeoutMs: 180_000, - reconcileTimeoutMs: 90_000, - stopTimeoutMs: 90_000, - }); - const adapter = new OpenCodeTeamRuntimeAdapter(readinessBridge, { - launchMode: 'dogfood', - }); - const svc = new TeamProvisioningService(); - svc.setRuntimeAdapterRegistry(new TeamRuntimeAdapterRegistry([adapter])); + const mcpLaunchSpec = await resolveAgentTeamsMcpLaunchSpec(); + const bridgeEnv = { + ...createStableBridgeEnv(), + PATH: withBunOnPath(process.env.PATH ?? ''), + XDG_DATA_HOME: path.join(tempDir, 'xdg-data-single'), + CLAUDE_MULTIMODEL_AGENT_TEAMS_MCP_COMMAND: mcpLaunchSpec.command, + CLAUDE_MULTIMODEL_AGENT_TEAMS_MCP_ENTRY: mcpLaunchSpec.args[0] ?? '', + }; + const bridgeClient = new OpenCodeBridgeCommandClient({ + binaryPath: orchestratorCli, + tempDirectory: path.join(tempDir, 'bridge-input'), + env: bridgeEnv, + }); + const stateChangingCommands = createStateChangingCommands({ + bridge: bridgeClient, + controlDir: path.join(tempDir, 'control'), + }); + const readinessBridge = new OpenCodeReadinessBridge(bridgeClient, { + stateChangingCommands, + timeoutMs: 180_000, + launchTimeoutMs: 180_000, + reconcileTimeoutMs: 90_000, + stopTimeoutMs: 90_000, + }); + const adapter = new OpenCodeTeamRuntimeAdapter(readinessBridge); + const svc = new TeamProvisioningService(); + svc.setRuntimeAdapterRegistry(new TeamRuntimeAdapterRegistry([adapter])); - const teamName = `mixed-opencode-recovery-${Date.now()}`; - const launchedLanes: TeamRuntimeLaunchInput[] = []; + const teamName = `mixed-opencode-recovery-${Date.now()}`; + const launchedLanes: TeamRuntimeLaunchInput[] = []; - await writeMixedRecoveryFixtures({ + await writeMixedRecoveryFixtures({ + teamName, + projectPath: PROJECT_PATH, + secondaryMembers: ['bob'], + }); + + try { + const launchInput = createSecondaryLaneLaunchInput({ teamName, - projectPath: PROJECT_PATH, - secondaryMembers: ['bob'], + laneId: 'secondary:opencode:bob', + memberName: 'bob', + selectedModel, + }); + launchedLanes.push(launchInput); + const launchResult = await adapter.launch(launchInput); + expect(launchResult.teamLaunchState).toBe('clean_success'); + expect(launchResult.members.bob).toMatchObject({ + launchState: 'confirmed_alive', + runtimeAlive: true, + bootstrapConfirmed: true, }); - try { - const launchInput = createSecondaryLaneLaunchInput({ - teamName, - laneId: 'secondary:opencode:bob', - memberName: 'bob', - selectedModel, - }); - launchedLanes.push(launchInput); - const launchResult = await adapter.launch(launchInput); - expect(launchResult.teamLaunchState).toBe('clean_success'); - expect(launchResult.members.bob).toMatchObject({ - launchState: 'confirmed_alive', - runtimeAlive: true, - bootstrapConfirmed: true, - }); + await upsertOpenCodeRuntimeLaneIndexEntry({ + teamsBasePath: getTeamsBasePath(), + teamName, + laneId: launchInput.laneId ?? 'secondary:opencode:bob', + state: 'active', + }); - await upsertOpenCodeRuntimeLaneIndexEntry({ - teamsBasePath: getTeamsBasePath(), - teamName, - laneId: launchInput.laneId ?? 'secondary:opencode:bob', - state: 'active', - }); + const result = await svc.getMemberSpawnStatuses(teamName); - const result = await svc.getMemberSpawnStatuses(teamName); - - expect(result.expectedMembers).toEqual(expect.arrayContaining(['alice', 'bob'])); - expect(result.statuses.bob).toMatchObject({ - status: 'online', - launchState: 'confirmed_alive', - }); - expect(result.statuses.bob.error).toBeUndefined(); - await expect(readOpenCodeRuntimeLaneIndex(getTeamsBasePath(), teamName)).resolves.toMatchObject( - { - lanes: { - [launchInput.laneId ?? 'secondary:opencode:bob']: { - state: 'active', - }, - }, - } - ); - } finally { - for (const launchInput of launchedLanes) { - await adapter - .stop({ - runId: launchInput.runId, - laneId: launchInput.laneId, - teamName, - cwd: PROJECT_PATH, - providerId: 'opencode', - reason: 'cleanup', - previousLaunchState: null, - force: true, - } satisfies TeamRuntimeStopInput) - .catch(() => undefined); - } + expect(result.expectedMembers).toEqual(expect.arrayContaining(['alice', 'bob'])); + expect(result.statuses.bob).toMatchObject({ + status: 'online', + launchState: 'confirmed_alive', + }); + expect(result.statuses.bob.error).toBeUndefined(); + await expect( + readOpenCodeRuntimeLaneIndex(getTeamsBasePath(), teamName) + ).resolves.toMatchObject({ + lanes: { + [launchInput.laneId ?? 'secondary:opencode:bob']: { + state: 'active', + }, + }, + }); + } finally { + for (const launchInput of launchedLanes) { + await adapter + .stop({ + runId: launchInput.runId, + laneId: launchInput.laneId, + teamName, + cwd: PROJECT_PATH, + providerId: 'opencode', + reason: 'cleanup', + previousLaunchState: null, + force: true, + } satisfies TeamRuntimeStopInput) + .catch(() => undefined); } - }, - 240_000 - ); + } + }, 240_000); liveMultiLaneIt( 'recovers multiple active mixed OpenCode side lanes from live runtime reconcile', @@ -207,9 +199,7 @@ liveDescribe('OpenCode mixed recovery live e2e', () => { reconcileTimeoutMs: 90_000, stopTimeoutMs: 90_000, }); - const adapter = new OpenCodeTeamRuntimeAdapter(readinessBridge, { - launchMode: 'dogfood', - }); + const adapter = new OpenCodeTeamRuntimeAdapter(readinessBridge); const svc = new TeamProvisioningService(); svc.setRuntimeAdapterRegistry(new TeamRuntimeAdapterRegistry([adapter])); @@ -259,16 +249,16 @@ liveDescribe('OpenCode mixed recovery live e2e', () => { }); expect(result.statuses[memberName]?.error).toBeUndefined(); } - await expect(readOpenCodeRuntimeLaneIndex(getTeamsBasePath(), teamName)).resolves.toMatchObject( - { - lanes: Object.fromEntries( - sideMembers.map((memberName) => [ - `secondary:opencode:${memberName}`, - { state: 'active' }, - ]) - ), - } - ); + await expect( + readOpenCodeRuntimeLaneIndex(getTeamsBasePath(), teamName) + ).resolves.toMatchObject({ + lanes: Object.fromEntries( + sideMembers.map((memberName) => [ + `secondary:opencode:${memberName}`, + { state: 'active' }, + ]) + ), + }); } finally { for (const launchInput of launchedLanes) { await adapter @@ -360,10 +350,7 @@ async function writeMixedRecoveryFixtures(input: { name: input.teamName, projectPath: input.projectPath, leadSessionId: 'lead-session', - members: [ - { name: 'team-lead', agentType: 'team-lead' }, - { name: 'alice' }, - ], + members: [{ name: 'team-lead', agentType: 'team-lead' }, { name: 'alice' }], }, null, 2 diff --git a/test/main/services/team/OpenCodeProductionE2EEvidence.test.ts b/test/main/services/team/OpenCodeProductionE2EEvidence.test.ts deleted file mode 100644 index 2069c9da..00000000 --- a/test/main/services/team/OpenCodeProductionE2EEvidence.test.ts +++ /dev/null @@ -1,404 +0,0 @@ -import { promises as fs } from 'fs'; -import * as os from 'os'; -import * as path from 'path'; - -import { afterEach, beforeEach, describe, expect, it } from 'vitest'; - -import { - assertOpenCodeProductionE2EArtifactGate, - buildOpenCodeProjectPathFingerprint, - OPENCODE_PRODUCTION_E2E_READY_CHECKPOINTS, - OPENCODE_PRODUCTION_E2E_REQUIRED_SIGNALS, - validateOpenCodeProductionE2EEvidence, - type OpenCodeProductionE2EEvidence, -} from '../../../../src/main/services/team/opencode/e2e/OpenCodeProductionE2EEvidence'; -import { OpenCodeProductionE2EEvidenceStore } from '../../../../src/main/services/team/opencode/e2e/OpenCodeProductionE2EEvidenceStore'; -import { - REQUIRED_AGENT_TEAMS_APP_TOOL_IDS, -} from '../../../../src/main/services/team/opencode/mcp/OpenCodeMcpToolAvailability'; - -describe('OpenCodeProductionE2EEvidence', () => { - let tempDir: string; - const now = new Date('2026-04-21T12:00:00.000Z'); - - beforeEach(async () => { - tempDir = await fs.mkdtemp(path.join(os.tmpdir(), 'opencode-production-e2e-')); - }); - - afterEach(async () => { - await fs.rm(tempDir, { recursive: true, force: true }); - }); - - it('accepts production evidence when runtime identity, project context and required MCP tools match', () => { - const evidence = passingEvidence(); - - expect(validateOpenCodeProductionE2EEvidence(evidence)).toEqual(evidence); - expect( - assertOpenCodeProductionE2EArtifactGate({ - evidence, - artifactPath: '/tmp/opencode-e2e', - now, - expected: { - opencodeVersion: '1.14.19', - binaryFingerprint: 'version:1.14.19', - capabilitySnapshotId: 'cap-1', - selectedModel: 'openai/gpt-5.4-mini', - projectPathFingerprint: 'project-a', - requiredMcpTools: REQUIRED_AGENT_TEAMS_APP_TOOL_IDS, - }, - }) - ).toEqual({ - ok: true, - diagnostics: [], - }); - }); - - it('rejects stale runtime-only evidence when production expects full app MCP tools', () => { - const runtimeOnlyToolIds = ['agent-teams_runtime_deliver_message']; - const evidence = passingEvidence({ - mcpTools: { - requiredTools: runtimeOnlyToolIds, - observedTools: runtimeOnlyToolIds, - }, - }); - - expect( - assertOpenCodeProductionE2EArtifactGate({ - evidence, - artifactPath: '/tmp/opencode-e2e', - now, - expected: { - opencodeVersion: '1.14.19', - binaryFingerprint: 'version:1.14.19', - capabilitySnapshotId: 'cap-1', - selectedModel: 'openai/gpt-5.4-mini', - projectPathFingerprint: 'project-a', - requiredMcpTools: REQUIRED_AGENT_TEAMS_APP_TOOL_IDS, - }, - }) - ).toMatchObject({ - ok: false, - diagnostics: expect.arrayContaining([ - expect.stringContaining('agent-teams_message_send'), - expect.stringContaining('agent-teams_member_briefing'), - ]), - }); - }); - - it('fails closed for stale, mismatched or incomplete evidence', () => { - const expired = passingEvidence({ - expiresAt: '2026-04-21T11:59:59.000Z', - selectedModel: 'openrouter/anthropic/claude-sonnet-4.5', - requiredSignals: requiredSignals({ stale_run_rejected: false }), - mcpTools: { - requiredTools: ['agent-teams_runtime_deliver_message'], - observedTools: [], - }, - }); - - expect( - assertOpenCodeProductionE2EArtifactGate({ - evidence: expired, - artifactPath: '/tmp/opencode-e2e', - now, - expected: { - opencodeVersion: '1.14.19', - binaryFingerprint: 'version:1.14.19', - capabilitySnapshotId: 'cap-1', - selectedModel: 'openai/gpt-5.4-mini', - projectPathFingerprint: 'project-a', - requiredMcpTools: ['agent-teams_runtime_deliver_message'], - }, - }) - ).toMatchObject({ - ok: false, - diagnostics: expect.arrayContaining([ - 'OpenCode production E2E evidence is expired', - 'OpenCode production E2E evidence is missing signals: stale_run_rejected', - 'OpenCode production E2E evidence is missing observed MCP tools: agent-teams_runtime_deliver_message', - ]), - }); - }); - - it('reads missing evidence as a production-blocking diagnostic and quarantines corrupt artifacts', async () => { - const filePath = path.join(tempDir, 'production-e2e-evidence.json'); - const store = new OpenCodeProductionE2EEvidenceStore({ - filePath, - clock: () => now, - }); - - await expect(store.read()).resolves.toMatchObject({ - ok: true, - evidence: null, - artifactPath: filePath, - diagnostics: ['OpenCode production E2E evidence artifact has not been written yet'], - }); - - await fs.mkdir(path.dirname(filePath), { recursive: true }); - await fs.writeFile(filePath, '{broken', 'utf8'); - const corrupt = await store.read(); - expect(corrupt).toMatchObject({ - ok: false, - evidence: null, - artifactPath: filePath, - }); - expect(corrupt.diagnostics[0]).toContain( - 'OpenCode production E2E evidence store is unreadable' - ); - }); - - it('writes evidence with the store path as artifactPath when the input omits it', async () => { - const filePath = path.join(tempDir, 'production-e2e-evidence.json'); - const store = new OpenCodeProductionE2EEvidenceStore({ - filePath, - clock: () => now, - }); - - await store.write({ - ...passingEvidence(), - artifactPath: null, - }); - - await expect(store.read()).resolves.toMatchObject({ - ok: true, - evidence: { - artifactPath: filePath, - evidenceId: 'e2e-1', - }, - diagnostics: [], - }); - }); - - it('stores production evidence for multiple raw model ids and reads exact model matches when no project context is provided', async () => { - const filePath = path.join(tempDir, 'production-e2e-evidence.json'); - const store = new OpenCodeProductionE2EEvidenceStore({ - filePath, - clock: () => now, - }); - - await store.write(passingEvidence({ selectedModel: 'opencode/big-pickle' })); - await store.write( - passingEvidence({ - evidenceId: 'e2e-2', - selectedModel: 'opencode/minimax-m2.5-free', - }) - ); - - await expect( - store.read({ selectedModel: 'opencode/minimax-m2.5-free' }) - ).resolves.toMatchObject({ - ok: true, - evidence: { - evidenceId: 'e2e-2', - selectedModel: 'opencode/minimax-m2.5-free', - }, - diagnostics: [], - }); - - await expect(store.read({ selectedModel: 'openai/gpt-5.4-mini' })).resolves.toMatchObject({ - ok: true, - evidence: null, - diagnostics: [ - 'OpenCode production E2E evidence artifact has no entry for selected model openai/gpt-5.4-mini', - ], - }); - }); - - it('reuses the current project production proof even when the requested OpenCode model differs', async () => { - const filePath = path.join(tempDir, 'production-e2e-evidence.json'); - const store = new OpenCodeProductionE2EEvidenceStore({ - filePath, - clock: () => now, - }); - - await store.write( - passingEvidence({ - evidenceId: 'e2e-project-a', - selectedModel: 'opencode/minimax-m2.5-free', - projectPathFingerprint: buildOpenCodeProjectPathFingerprint('/repo-a'), - }) - ); - await store.write( - passingEvidence({ - evidenceId: 'e2e-project-b', - selectedModel: 'opencode/minimax-m2.5-free', - projectPathFingerprint: buildOpenCodeProjectPathFingerprint('/repo-b'), - }) - ); - - await expect( - store.read({ - selectedModel: 'opencode/nemotron-3-super-free', - projectPathFingerprint: buildOpenCodeProjectPathFingerprint('/repo-b'), - }) - ).resolves.toMatchObject({ - ok: true, - evidence: { - evidenceId: 'e2e-project-b', - selectedModel: 'opencode/minimax-m2.5-free', - }, - diagnostics: [], - }); - }); - - it('prefers a runtime-compatible project proof over a newer stale one from the same cwd', async () => { - const filePath = path.join(tempDir, 'production-e2e-evidence.json'); - const store = new OpenCodeProductionE2EEvidenceStore({ - filePath, - clock: () => now, - }); - - await store.write( - passingEvidence({ - evidenceId: 'stale-newer', - createdAt: '2026-04-21T12:05:00.000Z', - selectedModel: 'opencode/big-pickle', - projectPathFingerprint: buildOpenCodeProjectPathFingerprint('/repo-a'), - capabilitySnapshotId: 'cap-stale', - }) - ); - await store.write( - passingEvidence({ - evidenceId: 'matching-older', - createdAt: '2026-04-21T12:00:00.000Z', - selectedModel: 'opencode/minimax-m2.5-free', - projectPathFingerprint: buildOpenCodeProjectPathFingerprint('/repo-a'), - capabilitySnapshotId: 'cap-current', - }) - ); - - await expect( - store.read({ - selectedModel: 'opencode/nemotron-3-super-free', - projectPathFingerprint: buildOpenCodeProjectPathFingerprint('/repo-a'), - opencodeVersion: '1.14.19', - binaryFingerprint: 'version:1.14.19', - capabilitySnapshotId: 'cap-current', - }) - ).resolves.toMatchObject({ - ok: true, - evidence: { - evidenceId: 'matching-older', - selectedModel: 'opencode/minimax-m2.5-free', - }, - diagnostics: [], - }); - }); - - it('stores production evidence for the same raw model across multiple project contexts', async () => { - const filePath = path.join(tempDir, 'production-e2e-evidence.json'); - const store = new OpenCodeProductionE2EEvidenceStore({ - filePath, - clock: () => now, - }); - - await store.write( - passingEvidence({ - evidenceId: 'e2e-project-a', - selectedModel: 'opencode/minimax-m2.5-free', - projectPathFingerprint: buildOpenCodeProjectPathFingerprint('/repo-a'), - }) - ); - await store.write( - passingEvidence({ - evidenceId: 'e2e-project-b', - selectedModel: 'opencode/minimax-m2.5-free', - projectPathFingerprint: buildOpenCodeProjectPathFingerprint('/repo-b'), - }) - ); - - await expect( - store.read({ - selectedModel: 'opencode/minimax-m2.5-free', - projectPathFingerprint: buildOpenCodeProjectPathFingerprint('/repo-b'), - }) - ).resolves.toMatchObject({ - ok: true, - evidence: { - evidenceId: 'e2e-project-b', - selectedModel: 'opencode/minimax-m2.5-free', - }, - diagnostics: [], - }); - - await expect( - store.read({ - selectedModel: 'opencode/minimax-m2.5-free', - projectPathFingerprint: buildOpenCodeProjectPathFingerprint('/repo-c'), - }) - ).resolves.toMatchObject({ - ok: true, - evidence: null, - diagnostics: ['OpenCode production E2E evidence artifact has no entry for the current working directory'], - }); - }); -}); - -function passingEvidence( - overrides: Partial = {} -): OpenCodeProductionE2EEvidence { - const createdAt = '2026-04-21T12:00:00.000Z'; - const sessionId = 'session-1'; - const requiredToolIds = REQUIRED_AGENT_TEAMS_APP_TOOL_IDS; - - return { - schemaVersion: 1, - evidenceId: 'e2e-1', - createdAt, - expiresAt: '2026-04-21T12:10:00.000Z', - version: '1.14.19', - passed: true, - artifactPath: '/tmp/opencode-e2e', - binaryFingerprint: 'version:1.14.19', - capabilitySnapshotId: 'cap-1', - selectedModel: 'openai/gpt-5.4-mini', - projectPathFingerprint: 'project-a', - requiredSignals: requiredSignals(), - mcpTools: { - requiredTools: requiredToolIds, - observedTools: requiredToolIds, - }, - launch: { - runId: 'run-1', - teamId: 'team-a', - teamLaunchState: 'ready', - memberCount: 1, - sessions: [ - { - memberName: 'Dev', - sessionId, - launchState: 'confirmed_alive', - }, - ], - durableCheckpoints: OPENCODE_PRODUCTION_E2E_READY_CHECKPOINTS.map((name) => ({ - name, - observedAt: createdAt, - })), - }, - reconcile: { - runId: 'run-1', - teamLaunchState: 'ready', - memberCount: 1, - }, - stop: { - runId: 'run-1', - stopped: true, - stoppedSessionIds: [sessionId], - }, - logProjection: { - observed: true, - projectedMessageCount: 1, - }, - ...overrides, - }; -} - -function requiredSignals( - overrides: Partial< - Record<(typeof OPENCODE_PRODUCTION_E2E_REQUIRED_SIGNALS)[number], boolean> - > = {} -) { - return Object.fromEntries( - OPENCODE_PRODUCTION_E2E_REQUIRED_SIGNALS.map((signal) => [signal, overrides[signal] ?? true]) - ) as OpenCodeProductionE2EEvidence['requiredSignals']; -} diff --git a/test/main/services/team/OpenCodeProductionE2EEvidencePath.test.ts b/test/main/services/team/OpenCodeProductionE2EEvidencePath.test.ts deleted file mode 100644 index 999335b9..00000000 --- a/test/main/services/team/OpenCodeProductionE2EEvidencePath.test.ts +++ /dev/null @@ -1,33 +0,0 @@ -import * as path from 'path'; - -import { describe, expect, it } from 'vitest'; - -import { - OPENCODE_PRODUCTION_E2E_EVIDENCE_FILE, - OPENCODE_PRODUCTION_E2E_EVIDENCE_PATH_ENV, - resolveOpenCodeProductionE2EEvidencePath, -} from '../../../../src/main/services/team/opencode/e2e/OpenCodeProductionE2EEvidencePath'; - -describe('OpenCodeProductionE2EEvidencePath', () => { - it('defaults to the app-owned bridge control directory', () => { - expect( - resolveOpenCodeProductionE2EEvidencePath({ - bridgeControlDir: '/app/user-data/opencode-bridge', - env: {}, - }) - ).toBe(path.join('/app/user-data/opencode-bridge', OPENCODE_PRODUCTION_E2E_EVIDENCE_FILE)); - }); - - it('allows release and local proof runs to point production at an explicit artifact', () => { - const relativeOverride = 'tmp/opencode-production-evidence.json'; - - expect( - resolveOpenCodeProductionE2EEvidencePath({ - bridgeControlDir: '/app/user-data/opencode-bridge', - env: { - [OPENCODE_PRODUCTION_E2E_EVIDENCE_PATH_ENV]: ` ${relativeOverride} `, - }, - }) - ).toBe(path.resolve(relativeOverride)); - }); -}); diff --git a/test/main/services/team/OpenCodeProductionGate.live.test.ts b/test/main/services/team/OpenCodeProductionGate.live.test.ts deleted file mode 100644 index f308ffc6..00000000 --- a/test/main/services/team/OpenCodeProductionGate.live.test.ts +++ /dev/null @@ -1,467 +0,0 @@ -import { constants as fsConstants, promises as fs } from 'fs'; -import * as os from 'os'; -import * as path from 'path'; - -import { afterEach, beforeEach, describe, expect, it } from 'vitest'; - -import { OpenCodeBridgeCommandClient } from '../../../../src/main/services/team/opencode/bridge/OpenCodeBridgeCommandClient'; -import { - createOpenCodeBridgeCommandLeaseStore, - createOpenCodeBridgeCommandLedgerStore, -} from '../../../../src/main/services/team/opencode/bridge/OpenCodeBridgeCommandLedgerStore'; -import { - createOpenCodeBridgeClientIdentity, - OpenCodeBridgeCommandHandshakePort, -} from '../../../../src/main/services/team/opencode/bridge/OpenCodeBridgeHandshakeClient'; -import { OpenCodeReadinessBridge } from '../../../../src/main/services/team/opencode/bridge/OpenCodeReadinessBridge'; -import { OpenCodeStateChangingBridgeCommandService } from '../../../../src/main/services/team/opencode/bridge/OpenCodeStateChangingBridgeCommandService'; -import { - assertOpenCodeProductionE2EArtifactGate, - buildOpenCodeProjectPathFingerprint, - OPENCODE_PRODUCTION_E2E_EVIDENCE_SCHEMA_VERSION, - OPENCODE_PRODUCTION_E2E_EVIDENCE_MAX_AGE_MS, - OPENCODE_PRODUCTION_E2E_READY_CHECKPOINTS, - OPENCODE_PRODUCTION_E2E_REQUIRED_SIGNALS, - type OpenCodeProductionE2EEvidence, -} from '../../../../src/main/services/team/opencode/e2e/OpenCodeProductionE2EEvidence'; -import { OpenCodeProductionE2EEvidenceStore } from '../../../../src/main/services/team/opencode/e2e/OpenCodeProductionE2EEvidenceStore'; -import { - REQUIRED_AGENT_TEAMS_APP_TOOL_IDS, -} from '../../../../src/main/services/team/opencode/mcp/OpenCodeMcpToolAvailability'; -import { resolveAgentTeamsMcpLaunchSpec } from '../../../../src/main/services/team/TeamMcpConfigBuilder'; -import { applyOpenCodeAutoUpdatePolicy } from '../../../../src/main/services/runtime/openCodeAutoUpdatePolicy'; - -import type { - OpenCodeBridgeRuntimeSnapshot, - OpenCodeLaunchTeamCommandData, - OpenCodeStopTeamCommandData, - RuntimeStoreManifestEvidence, -} from '../../../../src/main/services/team/opencode/bridge/OpenCodeBridgeCommandContract'; -import type { RuntimeStoreManifestReader } from '../../../../src/main/services/team/opencode/bridge/OpenCodeStateChangingBridgeCommandService'; -import type { OpenCodeBridgeCommandExecutor } from '../../../../src/main/services/team/opencode/bridge/OpenCodeStateChangingBridgeCommandService'; - -const liveDescribe = process.env.OPENCODE_E2E === '1' ? describe : describe.skip; - -const DEFAULT_APP_PRODUCTION_E2E_EVIDENCE_PATH = path.join( - os.userInfo().homedir, - 'Library', - 'Application Support', - 'Agent Teams UI', - 'opencode-bridge', - 'production-e2e-evidence.json' -); -const PROJECT_PATH = process.env.OPENCODE_E2E_PROJECT_PATH?.trim() || process.cwd(); -const DEFAULT_ORCHESTRATOR_CLI = '/Users/belief/dev/projects/claude/agent_teams_orchestrator/cli'; -const DEFAULT_MODEL = 'opencode/big-pickle'; - -liveDescribe('OpenCode production gate live e2e', () => { - let tempDir: string; - - beforeEach(async () => { - tempDir = await fs.mkdtemp(path.join(os.tmpdir(), 'opencode-production-gate-e2e-')); - }); - - afterEach(async () => { - await fs.rm(tempDir, { recursive: true, force: true }); - }); - - it('runs live launch/reconcile/transcript/stop and accepts production evidence with app MCP tool proof', async () => { - const selectedModel = process.env.OPENCODE_E2E_MODEL?.trim() || DEFAULT_MODEL; - const orchestratorCli = - process.env.CLAUDE_AGENT_TEAMS_ORCHESTRATOR_CLI_PATH?.trim() || DEFAULT_ORCHESTRATOR_CLI; - await assertExecutable(orchestratorCli); - - const mcpLaunchSpec = await resolveAgentTeamsMcpLaunchSpec(); - const bridgeEnv = { - ...createStableBridgeEnv(), - PATH: withBunOnPath(process.env.PATH ?? ''), - CLAUDE_MULTIMODEL_AGENT_TEAMS_MCP_COMMAND: mcpLaunchSpec.command, - CLAUDE_MULTIMODEL_AGENT_TEAMS_MCP_ENTRY: mcpLaunchSpec.args[0] ?? '', - }; - const bridgeClient = new OpenCodeBridgeCommandClient({ - binaryPath: orchestratorCli, - tempDirectory: path.join(tempDir, 'bridge-input'), - env: bridgeEnv, - }); - const stateChangingCommands = createStateChangingCommands({ - bridge: bridgeClient, - controlDir: path.join(tempDir, 'control'), - }); - const readinessBridge = new OpenCodeReadinessBridge(bridgeClient, { - stateChangingCommands, - timeoutMs: 180_000, - launchTimeoutMs: 180_000, - reconcileTimeoutMs: 90_000, - stopTimeoutMs: 90_000, - }); - - const readiness = await readinessBridge.checkOpenCodeTeamLaunchReadiness({ - projectPath: PROJECT_PATH, - selectedModel, - requireExecutionProbe: false, - }); - const initialRuntime = readinessBridge.getLastOpenCodeRuntimeSnapshot(PROJECT_PATH); - if (!initialRuntime) { - throw new Error( - `OpenCode live readiness did not return runtime snapshot: ${[ - ...readiness.diagnostics, - ...readiness.missing, - ].join('; ')}` - ); - } - expect(initialRuntime?.version).toBe('1.14.19'); - expect(initialRuntime?.capabilitySnapshotId).toBeTruthy(); - - const runId = `opencode-e2e-${Date.now()}`; - const teamName = `opencode-e2e-team-${Date.now()}`; - const memberName = 'E2E'; - let launch: OpenCodeLaunchTeamCommandData | null = null; - let reconcile: OpenCodeLaunchTeamCommandData | null = null; - let stop: OpenCodeStopTeamCommandData | null = null; - let transcriptMessages = 0; - let staleRunRejected = false; - - try { - launch = await readinessBridge.launchOpenCodeTeam({ - mode: 'dogfood', - runId, - laneId: 'primary', - teamId: teamName, - teamName, - projectPath: PROJECT_PATH, - selectedModel, - members: [ - { - name: memberName, - role: 'e2e', - prompt: 'Reply with exactly: opencode-production-gate-e2e', - }, - ], - leadPrompt: 'Live OpenCode production gate e2e', - expectedCapabilitySnapshotId: initialRuntime?.capabilitySnapshotId ?? null, - manifestHighWatermark: null, - }); - - expect(launch.teamLaunchState).toBe('ready'); - expect(launch.members[memberName]?.launchState).toBe('confirmed_alive'); - - reconcile = await readinessBridge.reconcileOpenCodeTeam({ - runId, - laneId: 'primary', - teamId: teamName, - teamName, - projectPath: PROJECT_PATH, - expectedCapabilitySnapshotId: initialRuntime?.capabilitySnapshotId ?? null, - manifestHighWatermark: null, - expectedMembers: [{ name: memberName, model: selectedModel }], - reason: 'production_gate_e2e', - }); - expect(reconcile.teamLaunchState).toBe('ready'); - - const transcript = await bridgeClient.execute< - { teamId: string; teamName: string; laneId: string; memberName: string }, - { logProjection?: { messages?: unknown[] }; messages?: unknown[] } - >( - 'opencode.getRuntimeTranscript', - { teamId: teamName, teamName, laneId: 'primary', memberName }, - { cwd: PROJECT_PATH, timeoutMs: 60_000 } - ); - expect(transcript.ok).toBe(true); - if (transcript.ok) { - transcriptMessages = - transcript.data.logProjection?.messages?.length ?? transcript.data.messages?.length ?? 0; - expect(transcriptMessages).toBeGreaterThan(0); - } - - staleRunRejected = await rejectsStaleCapability({ - stateChangingCommands, - teamName, - runId: `${runId}-stale`, - selectedModel, - }); - - stop = await readinessBridge.stopOpenCodeTeam({ - runId, - laneId: 'primary', - teamId: teamName, - teamName, - projectPath: PROJECT_PATH, - expectedCapabilitySnapshotId: initialRuntime?.capabilitySnapshotId ?? null, - manifestHighWatermark: null, - reason: 'production_gate_e2e_cleanup', - force: true, - }); - expect(stop.stopped).toBe(true); - - const finalReadiness = await readinessBridge.checkOpenCodeTeamLaunchReadiness({ - projectPath: PROJECT_PATH, - selectedModel, - requireExecutionProbe: true, - }); - const finalRuntime = readinessBridge.getLastOpenCodeRuntimeSnapshot(PROJECT_PATH); - if (!finalRuntime) { - throw new Error( - `OpenCode final readiness did not return runtime snapshot: ${[ - ...finalReadiness.diagnostics, - ...finalReadiness.missing, - ].join('; ')}` - ); - } - expect(finalRuntime.version).toBe('1.14.19'); - expect(finalRuntime.capabilitySnapshotId).toBeTruthy(); - - const candidate = buildCandidateEvidence({ - runId, - teamName, - memberName, - selectedModel, - runtime: finalRuntime, - readinessObservedTools: readiness.evidence.observedMcpTools, - launch, - reconcile, - stop, - transcriptMessages, - staleRunRejected, - appMcpToolsVisible: readiness.requiredToolsPresent, - }); - const missingObservedAppToolIds = REQUIRED_AGENT_TEAMS_APP_TOOL_IDS.filter( - (toolId) => !readiness.evidence.observedMcpTools.includes(toolId) - ); - expect(missingObservedAppToolIds).toEqual([]); - const gate = assertOpenCodeProductionE2EArtifactGate({ - evidence: candidate, - artifactPath: candidate.artifactPath, - expected: { - opencodeVersion: finalRuntime.version ?? null, - binaryFingerprint: finalRuntime.binaryFingerprint ?? null, - capabilitySnapshotId: finalRuntime.capabilitySnapshotId ?? null, - selectedModel, - projectPathFingerprint: buildOpenCodeProjectPathFingerprint(PROJECT_PATH), - requiredMcpTools: REQUIRED_AGENT_TEAMS_APP_TOOL_IDS, - }, - }); - - expect(gate).toEqual({ - ok: true, - diagnostics: [], - }); - await writeProductionEvidenceIfRequested(candidate); - } finally { - if (!stop) { - await readinessBridge - .stopOpenCodeTeam({ - runId, - laneId: 'primary', - teamId: teamName, - teamName, - projectPath: PROJECT_PATH, - expectedCapabilitySnapshotId: initialRuntime?.capabilitySnapshotId ?? null, - manifestHighWatermark: null, - reason: 'production_gate_e2e_finally_cleanup', - force: true, - }) - .catch(() => undefined); - } - } - }, 240_000); -}); - -async function writeProductionEvidenceIfRequested( - evidence: OpenCodeProductionE2EEvidence -): Promise { - const explicitPath = process.env.OPENCODE_E2E_WRITE_EVIDENCE_PATH?.trim(); - const writeAppEvidence = process.env.OPENCODE_E2E_WRITE_APP_EVIDENCE === '1'; - const filePath = - explicitPath || (writeAppEvidence ? DEFAULT_APP_PRODUCTION_E2E_EVIDENCE_PATH : ''); - if (!filePath) { - return; - } - - const store = new OpenCodeProductionE2EEvidenceStore({ filePath }); - await store.write({ - ...evidence, - artifactPath: filePath, - }); -} - -function createStateChangingCommands(input: { - bridge: OpenCodeBridgeCommandExecutor; - controlDir: string; -}): OpenCodeStateChangingBridgeCommandService { - const clientIdentity = createOpenCodeBridgeClientIdentity({ - appVersion: '1.3.0-e2e', - gitSha: null, - buildId: 'opencode-production-gate-e2e', - }); - - return new OpenCodeStateChangingBridgeCommandService({ - expectedClientIdentity: clientIdentity, - handshakePort: new OpenCodeBridgeCommandHandshakePort({ - bridge: input.bridge, - clientIdentity, - }), - leaseStore: createOpenCodeBridgeCommandLeaseStore({ - filePath: path.join(input.controlDir, 'leases.json'), - }), - ledger: createOpenCodeBridgeCommandLedgerStore({ - filePath: path.join(input.controlDir, 'ledger.json'), - }), - bridge: input.bridge, - manifestReader: new StaticManifestReader(), - }); -} - -class StaticManifestReader implements RuntimeStoreManifestReader { - async read(): Promise { - return { - highWatermark: 0, - activeRunId: null, - capabilitySnapshotId: null, - }; - } -} - -async function rejectsStaleCapability(input: { - stateChangingCommands: OpenCodeStateChangingBridgeCommandService; - teamName: string; - runId: string; - selectedModel: string; -}): Promise { - try { - await input.stateChangingCommands.execute({ - command: 'opencode.reconcileTeam', - teamName: input.teamName, - laneId: 'primary', - runId: input.runId, - capabilitySnapshotId: 'opencode:stale-capability', - behaviorFingerprint: null, - body: { - runId: input.runId, - laneId: 'primary', - teamId: input.teamName, - teamName: input.teamName, - projectPath: PROJECT_PATH, - expectedCapabilitySnapshotId: 'opencode:stale-capability', - manifestHighWatermark: null, - expectedMembers: [{ name: 'E2E', model: input.selectedModel }], - reason: 'production_gate_stale_run_probe', - }, - cwd: PROJECT_PATH, - timeoutMs: 30_000, - }); - return false; - } catch (error) { - return error instanceof Error && error.message.includes('capability snapshot mismatch'); - } -} - -function buildCandidateEvidence(input: { - runId: string; - teamName: string; - memberName: string; - selectedModel: string; - runtime: OpenCodeBridgeRuntimeSnapshot; - readinessObservedTools: string[]; - launch: OpenCodeLaunchTeamCommandData; - reconcile: OpenCodeLaunchTeamCommandData; - stop: OpenCodeStopTeamCommandData; - transcriptMessages: number; - staleRunRejected: boolean; - appMcpToolsVisible: boolean; -}): OpenCodeProductionE2EEvidence { - const now = new Date(); - const createdAt = now.toISOString(); - const sessionId = input.launch.members[input.memberName]?.sessionId ?? 'missing-session'; - const checkpointByName = new Map(); - for (const checkpoint of input.launch.durableCheckpoints ?? []) { - checkpointByName.set(checkpoint.name, { - name: checkpoint.name, - observedAt: checkpoint.observedAt, - }); - } - for (const evidence of input.launch.members[input.memberName]?.evidence ?? []) { - checkpointByName.set(evidence.kind, { - name: evidence.kind, - observedAt: evidence.observedAt, - }); - } - for (const name of OPENCODE_PRODUCTION_E2E_READY_CHECKPOINTS) { - checkpointByName.set(name, checkpointByName.get(name) ?? { name, observedAt: createdAt }); - } - - return { - schemaVersion: OPENCODE_PRODUCTION_E2E_EVIDENCE_SCHEMA_VERSION, - evidenceId: `live-${input.runId}`, - createdAt, - expiresAt: new Date(now.getTime() + OPENCODE_PRODUCTION_E2E_EVIDENCE_MAX_AGE_MS).toISOString(), - version: input.runtime.version ?? 'unknown', - passed: true, - artifactPath: path.join(os.tmpdir(), `opencode-production-e2e-${input.runId}.json`), - binaryFingerprint: input.runtime.binaryFingerprint ?? 'unknown', - capabilitySnapshotId: input.runtime.capabilitySnapshotId ?? 'unknown', - selectedModel: input.selectedModel, - projectPathFingerprint: buildOpenCodeProjectPathFingerprint(PROJECT_PATH), - requiredSignals: { - ...Object.fromEntries( - OPENCODE_PRODUCTION_E2E_REQUIRED_SIGNALS.map((signal) => [signal, true]) - ), - app_mcp_tools_visible: input.appMcpToolsVisible, - stale_run_rejected: input.staleRunRejected, - } as OpenCodeProductionE2EEvidence['requiredSignals'], - mcpTools: { - requiredTools: REQUIRED_AGENT_TEAMS_APP_TOOL_IDS, - observedTools: input.readinessObservedTools, - }, - launch: { - runId: input.runId, - teamId: input.teamName, - teamLaunchState: 'ready', - memberCount: 1, - sessions: [ - { - memberName: input.memberName, - sessionId, - launchState: 'confirmed_alive', - }, - ], - durableCheckpoints: Array.from(checkpointByName.values()), - }, - reconcile: { - runId: input.reconcile.runId, - teamLaunchState: 'ready', - memberCount: Object.keys(input.reconcile.members).length, - }, - stop: { - runId: input.stop.runId, - stopped: true, - stoppedSessionIds: Object.values(input.stop.members) - .map((member) => member.sessionId) - .filter((value): value is string => Boolean(value)), - }, - logProjection: { - observed: true, - projectedMessageCount: input.transcriptMessages, - }, - }; -} - -async function assertExecutable(filePath: string): Promise { - await fs.access(filePath, fsConstants.X_OK); -} - -function withBunOnPath(pathValue: string): string { - const bunDir = '/Users/belief/.bun/bin'; - return pathValue.split(path.delimiter).includes(bunDir) - ? pathValue - : `${bunDir}${path.delimiter}${pathValue}`; -} - -function createStableBridgeEnv(): NodeJS.ProcessEnv { - const realHome = os.userInfo().homedir; - const env = applyOpenCodeAutoUpdatePolicy({ ...process.env }); - return { - ...env, - HOME: realHome, - USERPROFILE: realHome, - }; -} diff --git a/test/main/services/team/OpenCodeReadinessBridge.test.ts b/test/main/services/team/OpenCodeReadinessBridge.test.ts index f8d77c2a..8a170f89 100644 --- a/test/main/services/team/OpenCodeReadinessBridge.test.ts +++ b/test/main/services/team/OpenCodeReadinessBridge.test.ts @@ -3,14 +3,7 @@ import { describe, expect, it, vi } from 'vitest'; import { OpenCodeReadinessBridge, type OpenCodeReadinessBridgeCommandExecutor, - type OpenCodeProductionE2EEvidenceReadPort, } from '../../../../src/main/services/team/opencode/bridge/OpenCodeReadinessBridge'; -import { - OPENCODE_PRODUCTION_E2E_READY_CHECKPOINTS, - OPENCODE_PRODUCTION_E2E_REQUIRED_SIGNALS, - buildOpenCodeProjectPathFingerprint, - type OpenCodeProductionE2EEvidence, -} from '../../../../src/main/services/team/opencode/e2e/OpenCodeProductionE2EEvidence'; import { REQUIRED_AGENT_TEAMS_APP_TOOL_IDS, } from '../../../../src/main/services/team/opencode/mcp/OpenCodeMcpToolAvailability'; @@ -92,114 +85,6 @@ describe('OpenCodeReadinessBridge', () => { expect(bridge.getLastOpenCodeRuntimeSnapshot('/repo')).toBeNull(); }); - it('blocks production readiness when strict production E2E evidence is missing', async () => { - const executor = fakeExecutor( - bridgeSuccess(readiness({ state: 'ready', launchAllowed: true })) - ); - const evidence = fakeEvidenceStore(null); - const bridge = new OpenCodeReadinessBridge(executor, { productionE2eEvidence: evidence }); - - await expect( - bridge.checkOpenCodeTeamLaunchReadiness({ - projectPath: '/repo', - selectedModel: 'openai/gpt-5.4-mini', - requireExecutionProbe: true, - launchMode: 'production', - }) - ).resolves.toMatchObject({ - state: 'e2e_missing', - launchAllowed: false, - supportLevel: 'supported_e2e_pending', - missing: ['OpenCode production launch requires a current production E2E evidence artifact'], - diagnostics: [ - 'OpenCode production launch requires a current production E2E evidence artifact', - ], - }); - expect(evidence.read).toHaveBeenCalledOnce(); - }); - - it('allows dogfood readiness while surfacing missing production E2E evidence diagnostics', async () => { - const executor = fakeExecutor( - bridgeSuccess(readiness({ state: 'ready', launchAllowed: true })) - ); - const bridge = new OpenCodeReadinessBridge(executor, { - productionE2eEvidence: fakeEvidenceStore(null), - }); - - await expect( - bridge.checkOpenCodeTeamLaunchReadiness({ - projectPath: '/repo', - selectedModel: 'openai/gpt-5.4-mini', - requireExecutionProbe: true, - launchMode: 'dogfood', - }) - ).resolves.toMatchObject({ - state: 'ready', - launchAllowed: true, - supportLevel: 'supported_e2e_pending', - diagnostics: [ - 'OpenCode production launch requires a current production E2E evidence artifact', - ], - }); - }); - - it('keeps production readiness open when evidence matches runtime identity and project context', async () => { - const executor = fakeExecutor( - bridgeSuccess(readiness({ state: 'ready', launchAllowed: true })) - ); - const evidence = fakeEvidenceStore(productionEvidence()); - const bridge = new OpenCodeReadinessBridge(executor, { - productionE2eEvidence: evidence, - }); - - await expect( - bridge.checkOpenCodeTeamLaunchReadiness({ - projectPath: '/repo', - selectedModel: 'openai/gpt-5.4-mini', - requireExecutionProbe: true, - launchMode: 'production', - }) - ).resolves.toMatchObject({ - state: 'ready', - launchAllowed: true, - supportLevel: 'production_supported', - diagnostics: [], - }); - expect(evidence.read).toHaveBeenCalledWith({ - selectedModel: 'openai/gpt-5.4-mini', - projectPathFingerprint: buildOpenCodeProjectPathFingerprint('/repo'), - opencodeVersion: '1.14.19', - binaryFingerprint: 'bin-1', - capabilitySnapshotId: 'cap-1', - }); - }); - - it('accepts production evidence recorded with a different OpenCode model when runtime identity matches', async () => { - const executor = fakeExecutor( - bridgeSuccess(readiness({ state: 'ready', launchAllowed: true })) - ); - const evidence = fakeEvidenceStore( - productionEvidence({ selectedModel: 'opencode/minimax-m2.5-free' }) - ); - const bridge = new OpenCodeReadinessBridge(executor, { - productionE2eEvidence: evidence, - }); - - await expect( - bridge.checkOpenCodeTeamLaunchReadiness({ - projectPath: '/repo', - selectedModel: 'opencode/nemotron-3-super-free', - requireExecutionProbe: true, - launchMode: 'production', - }) - ).resolves.toMatchObject({ - state: 'ready', - launchAllowed: true, - supportLevel: 'production_supported', - diagnostics: [], - }); - }); - it('routes state-changing launch commands through the guarded command service when configured', async () => { const executor = fakeExecutor( bridgeFailure('internal_error', 'direct bridge must not run', []) @@ -230,7 +115,6 @@ describe('OpenCodeReadinessBridge', () => { await expect( bridge.launchOpenCodeTeam({ - mode: 'dogfood', runId: 'run-1', laneId: 'primary', teamId: 'team-a', @@ -270,19 +154,6 @@ function fakeExecutor( }; } -function fakeEvidenceStore( - evidence: OpenCodeProductionE2EEvidence | null -): OpenCodeProductionE2EEvidenceReadPort & { read: ReturnType } { - return { - read: vi.fn(async () => ({ - ok: true, - evidence, - artifactPath: '/tmp/opencode-production-e2e.json', - diagnostics: [], - })), - }; -} - function bridgeSuccess( data: OpenCodeTeamLaunchReadiness ): OpenCodeBridgeSuccess { @@ -378,63 +249,3 @@ function readiness( ...overrides, }; } - -function productionEvidence( - overrides: Partial = {} -): OpenCodeProductionE2EEvidence { - const createdAt = new Date().toISOString(); - const sessionId = 'session-1'; - const requiredToolIds = REQUIRED_AGENT_TEAMS_APP_TOOL_IDS; - return { - schemaVersion: 1, - evidenceId: 'e2e-1', - createdAt, - expiresAt: new Date(Date.now() + 60_000).toISOString(), - version: '1.14.19', - passed: true, - artifactPath: '/tmp/opencode-production-e2e.json', - binaryFingerprint: 'bin-1', - capabilitySnapshotId: 'cap-1', - selectedModel: 'openai/gpt-5.4-mini', - projectPathFingerprint: buildOpenCodeProjectPathFingerprint('/repo'), - requiredSignals: Object.fromEntries( - OPENCODE_PRODUCTION_E2E_REQUIRED_SIGNALS.map((signal) => [signal, true]) - ) as OpenCodeProductionE2EEvidence['requiredSignals'], - mcpTools: { - requiredTools: requiredToolIds, - observedTools: requiredToolIds, - }, - launch: { - runId: 'run-1', - teamId: 'team-a', - teamLaunchState: 'ready', - memberCount: 1, - sessions: [ - { - memberName: 'Dev', - sessionId, - launchState: 'confirmed_alive', - }, - ], - durableCheckpoints: OPENCODE_PRODUCTION_E2E_READY_CHECKPOINTS.map((name) => ({ - name, - observedAt: createdAt, - })), - }, - reconcile: { - runId: 'run-1', - teamLaunchState: 'ready', - memberCount: 1, - }, - stop: { - runId: 'run-1', - stopped: true, - stoppedSessionIds: [sessionId], - }, - logProjection: { - observed: true, - projectedMessageCount: 1, - }, - ...overrides, - }; -} diff --git a/test/main/services/team/OpenCodeTeamLaunchReadiness.test.ts b/test/main/services/team/OpenCodeTeamLaunchReadiness.test.ts index c05b26d3..7c67dd1c 100644 --- a/test/main/services/team/OpenCodeTeamLaunchReadiness.test.ts +++ b/test/main/services/team/OpenCodeTeamLaunchReadiness.test.ts @@ -1,17 +1,12 @@ import { describe, expect, it, vi } from 'vitest'; import { createEmptyEndpointMap } from '../../../../src/main/services/team/opencode/capabilities/OpenCodeApiCapabilities'; -import { - OPENCODE_PRODUCTION_E2E_READY_CHECKPOINTS, - OPENCODE_PRODUCTION_E2E_REQUIRED_SIGNALS, -} from '../../../../src/main/services/team/opencode/e2e/OpenCodeProductionE2EEvidence'; import { REQUIRED_AGENT_TEAMS_RUNTIME_TOOLS } from '../../../../src/main/services/team/opencode/mcp/OpenCodeMcpToolAvailability'; import { OpenCodeTeamLaunchReadinessService, type OpenCodeApiCapabilityPort, type OpenCodeModelExecutionProbePort, type OpenCodeMcpToolProofPort, - type OpenCodeProductionE2EEvidencePort, type OpenCodeRuntimeInventory, type OpenCodeRuntimeInventoryPort, type OpenCodeRuntimeStoreReadinessPort, @@ -23,7 +18,6 @@ import type { } from '../../../../src/main/services/team/opencode/capabilities/OpenCodeApiCapabilities'; import type { OpenCodeMcpToolProof } from '../../../../src/main/services/team/opencode/mcp/OpenCodeMcpToolAvailability'; import type { RuntimeStoreReadinessCheck } from '../../../../src/main/services/team/opencode/store/RuntimeStoreManifest'; -import type { OpenCodeProductionE2EEvidence } from '../../../../src/main/services/team/opencode/version/OpenCodeVersionPolicy'; describe('OpenCodeTeamLaunchReadinessService', () => { it('returns not_installed before probing deeper runtime dependencies', async () => { @@ -84,16 +78,13 @@ describe('OpenCodeTeamLaunchReadinessService', () => { }); }); - it('blocks capability-compatible versions until production E2E evidence exists', async () => { - const ports = createPorts({ - evidence: null, - }); + it('does not require project-specific E2E evidence before runtime readiness checks', async () => { + const ports = createPorts(); await expect(service(ports).check(readinessInput())).resolves.toMatchObject({ - state: 'e2e_missing', - launchAllowed: false, - supportLevel: 'supported_e2e_pending', - missing: ['OpenCode version is capability-compatible but production E2E evidence is missing'], + state: 'ready', + launchAllowed: true, + supportLevel: 'production_supported', }); }); @@ -159,46 +150,10 @@ describe('OpenCodeTeamLaunchReadinessService', () => { }); }); - it('fails closed behind adapter feature gate after all runtime evidence is healthy', async () => { + it('allows launch when inventory, capabilities, stores, MCP and model probe are healthy', async () => { const ports = createPorts(); - await expect( - service(ports, { adapterEnabled: false }).check(readinessInput()) - ).resolves.toMatchObject({ - state: 'adapter_disabled', - launchAllowed: false, - missing: ['OpenCode team launch adapter is disabled by feature gate'], - }); - expect(ports.inventory.probe).not.toHaveBeenCalled(); - }); - - it('allows dogfood launch to continue without production E2E evidence after runtime checks pass', async () => { - const ports = createPorts({ evidence: null }); - - await expect( - service(ports, { launchMode: 'dogfood' }).check( - readinessInput({ requireExecutionProbe: true }) - ) - ).resolves.toMatchObject({ - state: 'ready', - launchAllowed: true, - supportLevel: 'supported_e2e_pending', - requiredToolsPresent: true, - runtimeStoresReady: true, - diagnostics: [ - 'OpenCode production E2E evidence is missing; dogfood launch remains allowed after runtime checks.', - ], - }); - expect(ports.mcpTools.prove).toHaveBeenCalled(); - expect(ports.modelExecution.verify).toHaveBeenCalled(); - }); - - it('allows launch only when inventory, capabilities, E2E, stores, MCP and model probe are healthy', async () => { - const ports = createPorts(); - - await expect( - service(ports, { adapterEnabled: true }).check(readinessInput()) - ).resolves.toMatchObject({ + await expect(service(ports).check(readinessInput())).resolves.toMatchObject({ state: 'ready', launchAllowed: true, modelId: 'openai/gpt-5.4-mini', @@ -218,20 +173,13 @@ describe('OpenCodeTeamLaunchReadinessService', () => { }); }); -function service( - ports: ReturnType, - options: { adapterEnabled?: boolean; launchMode?: 'disabled' | 'dogfood' | 'production' } = {} -): OpenCodeTeamLaunchReadinessService { +function service(ports: ReturnType): OpenCodeTeamLaunchReadinessService { return new OpenCodeTeamLaunchReadinessService( ports.inventory, ports.capabilities, ports.mcpTools, ports.runtimeStores, - ports.modelExecution, - ports.e2eEvidence, - options.launchMode - ? { launchMode: options.launchMode } - : { adapterEnabled: options.adapterEnabled ?? true } + ports.modelExecution ); } @@ -261,7 +209,6 @@ function createPorts( reason: string | null; diagnostics: string[]; }; - evidence?: OpenCodeProductionE2EEvidence | null; } = {} ): { inventory: OpenCodeRuntimeInventoryPort & { probe: ReturnType }; @@ -269,7 +216,6 @@ function createPorts( mcpTools: OpenCodeMcpToolProofPort & { prove: ReturnType }; runtimeStores: OpenCodeRuntimeStoreReadinessPort & { check: ReturnType }; modelExecution: OpenCodeModelExecutionProbePort & { verify: ReturnType }; - e2eEvidence: OpenCodeProductionE2EEvidencePort & { read: ReturnType }; } { return { inventory: { @@ -287,9 +233,6 @@ function createPorts( modelExecution: { verify: vi.fn(async () => overrides.modelProbe ?? modelProbe()), }, - e2eEvidence: { - read: vi.fn(async () => (overrides.evidence === undefined ? evidence() : overrides.evidence)), - }, }; } @@ -373,60 +316,3 @@ function modelProbe() { diagnostics: [], }; } - -function evidence(): OpenCodeProductionE2EEvidence { - const createdAt = new Date().toISOString(); - const sessionId = 'session-1'; - const requiredToolIds = REQUIRED_AGENT_TEAMS_RUNTIME_TOOLS.map((tool) => `agent_teams_${tool}`); - return { - schemaVersion: 1, - evidenceId: 'e2e-1', - createdAt, - expiresAt: new Date(Date.now() + 60_000).toISOString(), - version: '1.14.19', - passed: true, - artifactPath: '/tmp/opencode-e2e', - binaryFingerprint: 'version:1.14.19', - capabilitySnapshotId: 'cap-1', - selectedModel: 'openai/gpt-5.4-mini', - projectPathFingerprint: 'project-a', - requiredSignals: Object.fromEntries( - OPENCODE_PRODUCTION_E2E_REQUIRED_SIGNALS.map((signal) => [signal, true]) - ) as OpenCodeProductionE2EEvidence['requiredSignals'], - mcpTools: { - requiredTools: requiredToolIds, - observedTools: requiredToolIds, - }, - launch: { - runId: 'run-1', - teamId: 'team-a', - teamLaunchState: 'ready', - memberCount: 1, - sessions: [ - { - memberName: 'Dev', - sessionId, - launchState: 'confirmed_alive', - }, - ], - durableCheckpoints: OPENCODE_PRODUCTION_E2E_READY_CHECKPOINTS.map((name) => ({ - name, - observedAt: createdAt, - })), - }, - reconcile: { - runId: 'run-1', - teamLaunchState: 'ready', - memberCount: 1, - }, - stop: { - runId: 'run-1', - stopped: true, - stoppedSessionIds: [sessionId], - }, - logProjection: { - observed: true, - projectedMessageCount: 1, - }, - }; -} diff --git a/test/main/services/team/OpenCodeTeamProvisioning.live.test.ts b/test/main/services/team/OpenCodeTeamProvisioning.live.test.ts index 7ed8cbc1..f40e0ceb 100644 --- a/test/main/services/team/OpenCodeTeamProvisioning.live.test.ts +++ b/test/main/services/team/OpenCodeTeamProvisioning.live.test.ts @@ -56,126 +56,120 @@ liveDescribe('OpenCode team provisioning live e2e', () => { await fs.rm(tempDir, { recursive: true, force: true }); }); - it( - 'creates and stops a pure OpenCode team through TeamProvisioningService using the live runtime adapter', - async () => { - const selectedModel = process.env.OPENCODE_E2E_MODEL?.trim() || DEFAULT_MODEL; - const orchestratorCli = - process.env.CLAUDE_AGENT_TEAMS_ORCHESTRATOR_CLI_PATH?.trim() || DEFAULT_ORCHESTRATOR_CLI; - await assertExecutable(orchestratorCli); + it('creates and stops a pure OpenCode team through TeamProvisioningService using the live runtime adapter', async () => { + const selectedModel = process.env.OPENCODE_E2E_MODEL?.trim() || DEFAULT_MODEL; + const orchestratorCli = + process.env.CLAUDE_AGENT_TEAMS_ORCHESTRATOR_CLI_PATH?.trim() || DEFAULT_ORCHESTRATOR_CLI; + await assertExecutable(orchestratorCli); - const mcpLaunchSpec = await resolveAgentTeamsMcpLaunchSpec(); - const bridgeEnv = { - ...createStableBridgeEnv(), - PATH: withBunOnPath(process.env.PATH ?? ''), - XDG_DATA_HOME: path.join(tempDir, 'xdg-data'), - CLAUDE_MULTIMODEL_AGENT_TEAMS_MCP_COMMAND: mcpLaunchSpec.command, - CLAUDE_MULTIMODEL_AGENT_TEAMS_MCP_ENTRY: mcpLaunchSpec.args[0] ?? '', - }; - const bridgeClient = new OpenCodeBridgeCommandClient({ - binaryPath: orchestratorCli, - tempDirectory: path.join(tempDir, 'bridge-input'), - env: bridgeEnv, - }); - const stateChangingCommands = createStateChangingCommands({ - bridge: bridgeClient, - controlDir: path.join(tempDir, 'control'), - }); - const readinessBridge = new OpenCodeReadinessBridge(bridgeClient, { - stateChangingCommands, - timeoutMs: 180_000, - launchTimeoutMs: 180_000, - reconcileTimeoutMs: 90_000, - stopTimeoutMs: 90_000, - }); - const adapter = new OpenCodeTeamRuntimeAdapter(readinessBridge, { - launchMode: 'dogfood', - }); - const svc = new TeamProvisioningService(); - svc.setRuntimeAdapterRegistry(new TeamRuntimeAdapterRegistry([adapter])); + const mcpLaunchSpec = await resolveAgentTeamsMcpLaunchSpec(); + const bridgeEnv = { + ...createStableBridgeEnv(), + PATH: withBunOnPath(process.env.PATH ?? ''), + XDG_DATA_HOME: path.join(tempDir, 'xdg-data'), + CLAUDE_MULTIMODEL_AGENT_TEAMS_MCP_COMMAND: mcpLaunchSpec.command, + CLAUDE_MULTIMODEL_AGENT_TEAMS_MCP_ENTRY: mcpLaunchSpec.args[0] ?? '', + }; + const bridgeClient = new OpenCodeBridgeCommandClient({ + binaryPath: orchestratorCli, + tempDirectory: path.join(tempDir, 'bridge-input'), + env: bridgeEnv, + }); + const stateChangingCommands = createStateChangingCommands({ + bridge: bridgeClient, + controlDir: path.join(tempDir, 'control'), + }); + const readinessBridge = new OpenCodeReadinessBridge(bridgeClient, { + stateChangingCommands, + timeoutMs: 180_000, + launchTimeoutMs: 180_000, + reconcileTimeoutMs: 90_000, + stopTimeoutMs: 90_000, + }); + const adapter = new OpenCodeTeamRuntimeAdapter(readinessBridge); + const svc = new TeamProvisioningService(); + svc.setRuntimeAdapterRegistry(new TeamRuntimeAdapterRegistry([adapter])); - const teamName = `opencode-team-provisioning-${Date.now()}`; - const progressEvents: TeamProvisioningProgress[] = []; + const teamName = `opencode-team-provisioning-${Date.now()}`; + const progressEvents: TeamProvisioningProgress[] = []; - try { - const { runId } = await svc.createTeam( - { - teamName, - cwd: PROJECT_PATH, - providerId: 'opencode', - model: selectedModel, - skipPermissions: true, - members: [ - { - name: 'alice', - role: 'Developer', - providerId: 'opencode', - model: selectedModel, - }, - { - name: 'bob', - role: 'Reviewer', - providerId: 'opencode', - model: selectedModel, - }, - ], - }, - (progress) => { - progressEvents.push(progress); - } - ); - - expect(runId).toBeTruthy(); - const progressDump = progressEvents - .map((progress) => - [ - progress.state, - progress.message, - progress.messageSeverity, - progress.error, - progress.cliLogsTail, - ] - .filter(Boolean) - .join(' | ') - ) - .join('\n'); - expect( - progressEvents.some((progress) => - progress.message.includes('OpenCode team launch is ready') - ), - progressDump - ).toBe(true); - - const runtimeSnapshot = await svc.getTeamAgentRuntimeSnapshot(teamName); - expect(runtimeSnapshot.members.alice).toMatchObject({ - alive: true, - runtimeModel: selectedModel, - }); - expect(runtimeSnapshot.members.bob).toMatchObject({ - alive: true, - runtimeModel: selectedModel, - }); - await expect(readOpenCodeRuntimeLaneIndex(getTeamsBasePath(), teamName)).resolves.toMatchObject( - { - lanes: { - primary: { - state: 'active', - }, + try { + const { runId } = await svc.createTeam( + { + teamName, + cwd: PROJECT_PATH, + providerId: 'opencode', + model: selectedModel, + skipPermissions: true, + members: [ + { + name: 'alice', + role: 'Developer', + providerId: 'opencode', + model: selectedModel, }, - } - ); + { + name: 'bob', + role: 'Reviewer', + providerId: 'opencode', + model: selectedModel, + }, + ], + }, + (progress) => { + progressEvents.push(progress); + } + ); - svc.stopTeam(teamName); - await waitUntil(async () => { - const laneIndex = await readOpenCodeRuntimeLaneIndex(getTeamsBasePath(), teamName); - return Object.keys(laneIndex.lanes).length === 0; - }, 90_000); - } finally { - svc.stopTeam(teamName); - } - }, - 300_000 - ); + expect(runId).toBeTruthy(); + const progressDump = progressEvents + .map((progress) => + [ + progress.state, + progress.message, + progress.messageSeverity, + progress.error, + progress.cliLogsTail, + ] + .filter(Boolean) + .join(' | ') + ) + .join('\n'); + expect( + progressEvents.some((progress) => + progress.message.includes('OpenCode team launch is ready') + ), + progressDump + ).toBe(true); + + const runtimeSnapshot = await svc.getTeamAgentRuntimeSnapshot(teamName); + expect(runtimeSnapshot.members.alice).toMatchObject({ + alive: true, + runtimeModel: selectedModel, + }); + expect(runtimeSnapshot.members.bob).toMatchObject({ + alive: true, + runtimeModel: selectedModel, + }); + await expect( + readOpenCodeRuntimeLaneIndex(getTeamsBasePath(), teamName) + ).resolves.toMatchObject({ + lanes: { + primary: { + state: 'active', + }, + }, + }); + + svc.stopTeam(teamName); + await waitUntil(async () => { + const laneIndex = await readOpenCodeRuntimeLaneIndex(getTeamsBasePath(), teamName); + return Object.keys(laneIndex.lanes).length === 0; + }, 90_000); + } finally { + svc.stopTeam(teamName); + } + }, 300_000); }); function createStateChangingCommands(input: { diff --git a/test/main/services/team/OpenCodeTeamRuntimeAdapter.test.ts b/test/main/services/team/OpenCodeTeamRuntimeAdapter.test.ts index e8e4cf51..21d4ce23 100644 --- a/test/main/services/team/OpenCodeTeamRuntimeAdapter.test.ts +++ b/test/main/services/team/OpenCodeTeamRuntimeAdapter.test.ts @@ -21,7 +21,7 @@ describe('OpenCodeTeamRuntimeAdapter', () => { diagnostics: ['OpenCode missing canonical app MCP tool id'], }) ); - const adapter = new OpenCodeTeamRuntimeAdapter(bridge, { launchMode: 'production' }); + const adapter = new OpenCodeTeamRuntimeAdapter(bridge); await expect(adapter.prepare(launchInput())).resolves.toEqual({ ok: false, @@ -35,13 +35,12 @@ describe('OpenCodeTeamRuntimeAdapter', () => { projectPath: '/repo', selectedModel: 'openai/gpt-5.4-mini', requireExecutionProbe: true, - launchMode: 'production', }); }); it('uses runtime-only readiness for model-less preflight checks', async () => { const bridge = bridgePort(readiness({ state: 'ready', launchAllowed: true, modelId: null })); - const adapter = new OpenCodeTeamRuntimeAdapter(bridge, { launchMode: 'production' }); + const adapter = new OpenCodeTeamRuntimeAdapter(bridge); await expect( adapter.prepare(launchInput({ model: undefined, runtimeOnly: true })) @@ -55,7 +54,6 @@ describe('OpenCodeTeamRuntimeAdapter', () => { projectPath: '/repo', selectedModel: null, requireExecutionProbe: false, - launchMode: undefined, }); }); @@ -70,7 +68,7 @@ describe('OpenCodeTeamRuntimeAdapter', () => { missing: ['OpenCode bridge command timed out'], }) ); - const adapter = new OpenCodeTeamRuntimeAdapter(bridge, { launchMode: 'production' }); + const adapter = new OpenCodeTeamRuntimeAdapter(bridge); await expect(adapter.launch(launchInput())).resolves.toMatchObject({ teamLaunchState: 'partial_failure', @@ -88,25 +86,12 @@ describe('OpenCodeTeamRuntimeAdapter', () => { }); }); - it('fails closed when launch mode is disabled', async () => { - const bridge = bridgePort(readiness({ state: 'ready', launchAllowed: true })); - const adapter = new OpenCodeTeamRuntimeAdapter(bridge); - - await expect(adapter.prepare(launchInput())).resolves.toMatchObject({ - ok: false, - providerId: 'opencode', - reason: 'opencode_team_launch_disabled', - retryable: false, - }); - expect(bridge.checkOpenCodeTeamLaunchReadiness).not.toHaveBeenCalled(); - }); - it('rejects non-OpenCode members before readiness or launch bridge dispatch', async () => { const launchOpenCodeTeam = vi.fn(); const bridge = bridgePort(readiness({ state: 'ready', launchAllowed: true }), { launchOpenCodeTeam, }); - const adapter = new OpenCodeTeamRuntimeAdapter(bridge, { launchMode: 'production' }); + const adapter = new OpenCodeTeamRuntimeAdapter(bridge); const result = await adapter.launch( launchInput({ @@ -139,7 +124,7 @@ describe('OpenCodeTeamRuntimeAdapter', () => { const bridge = bridgePort(readiness({ state: 'ready', launchAllowed: true }), { launchOpenCodeTeam, }); - const adapter = new OpenCodeTeamRuntimeAdapter(bridge, { launchMode: 'production' }); + const adapter = new OpenCodeTeamRuntimeAdapter(bridge); const result = await adapter.launch(launchInput({ expectedMembers: [] })); @@ -188,8 +173,7 @@ describe('OpenCodeTeamRuntimeAdapter', () => { capabilitySnapshotId: 'cap-1', })), launchOpenCodeTeam, - }), - { launchMode: 'dogfood' } + }) ); await expect(adapter.launch(launchInput())).resolves.toMatchObject({ @@ -258,8 +242,7 @@ describe('OpenCodeTeamRuntimeAdapter', () => { const adapter = new OpenCodeTeamRuntimeAdapter( bridgePort(readiness({ state: 'ready', launchAllowed: true }), { launchOpenCodeTeam, - }), - { launchMode: 'dogfood' } + }) ); const result = await adapter.launch({ @@ -312,7 +295,7 @@ describe('OpenCodeTeamRuntimeAdapter', () => { alice: { providerId: 'opencode', launchState: 'runtime_pending_bootstrap', - runtimeAlive: true, + runtimeAlive: false, bootstrapConfirmed: false, }, }, @@ -432,8 +415,7 @@ describe('OpenCodeTeamRuntimeAdapter', () => { const adapter = new OpenCodeTeamRuntimeAdapter( bridgePort(readiness({ state: 'ready', launchAllowed: true }), { reconcileOpenCodeTeam, - }), - { launchMode: 'dogfood' } + }) ); const result = await adapter.reconcile({ @@ -527,8 +509,7 @@ describe('OpenCodeTeamRuntimeAdapter', () => { capabilitySnapshotId: 'cap-1', })), launchOpenCodeTeam, - }), - { launchMode: 'dogfood' } + }) ); const result = await adapter.launch(launchInput()); @@ -540,8 +521,9 @@ describe('OpenCodeTeamRuntimeAdapter', () => { providerId: 'opencode', launchState: 'runtime_pending_permission', pendingPermissionRequestIds: ['perm-1', 'perm-2'], - runtimeAlive: true, + runtimeAlive: false, agentToolAccepted: true, + livenessKind: 'permission_blocked', bootstrapConfirmed: false, hardFailure: false, }, @@ -556,6 +538,114 @@ 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, + }) + ); + + 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, + }) + ); + + 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, + }) + ); + + const result = await adapter.launch(launchInput()); + + expect(result.members.alice).toMatchObject({ + launchState: 'runtime_pending_bootstrap', + agentToolAccepted: true, + runtimeAlive: false, + livenessKind: 'runtime_process_candidate', + runtimeDiagnostic: 'OpenCode session exists without verified runtime pid', + }); + }); + it('keeps missing bridge members in bootstrap pending even when another member blocks on permission', async () => { const launchOpenCodeTeam = vi.fn( async () => @@ -591,8 +681,7 @@ describe('OpenCodeTeamRuntimeAdapter', () => { capabilitySnapshotId: 'cap-1', })), launchOpenCodeTeam, - }), - { launchMode: 'dogfood' } + }) ); const result = await adapter.launch( diff --git a/test/main/services/team/OpenCodeVersionPolicy.test.ts b/test/main/services/team/OpenCodeVersionPolicy.test.ts index 991cacfc..cdf95be1 100644 --- a/test/main/services/team/OpenCodeVersionPolicy.test.ts +++ b/test/main/services/team/OpenCodeVersionPolicy.test.ts @@ -10,7 +10,6 @@ import { type OpenCodeApiEndpointKey, } from '../../../../src/main/services/team/opencode/capabilities/OpenCodeApiCapabilities'; import { - assertOpenCodeProductionE2EGate, buildOpenCodeBinaryFingerprint, evaluateOpenCodeSupport, parseOpenCodeSemver, @@ -19,12 +18,6 @@ import { type OpenCodeCompatibilitySnapshot, type OpenCodeRouteCompatibilityCache, } from '../../../../src/main/services/team/opencode/version/OpenCodeVersionPolicy'; -import { - OPENCODE_PRODUCTION_E2E_READY_CHECKPOINTS, - OPENCODE_PRODUCTION_E2E_REQUIRED_SIGNALS, - type OpenCodeProductionE2EEvidence, -} from '../../../../src/main/services/team/opencode/e2e/OpenCodeProductionE2EEvidence'; -import { REQUIRED_AGENT_TEAMS_RUNTIME_TOOLS } from '../../../../src/main/services/team/opencode/mcp/OpenCodeMcpToolAvailability'; describe('OpenCodeVersionPolicy', () => { let tempDir: string; @@ -58,7 +51,6 @@ describe('OpenCodeVersionPolicy', () => { evaluateOpenCodeSupport({ version: '1.4.0', capabilities: readyCapabilities(), - evidence: passingEvidence(), }) ).toMatchObject({ supported: false, @@ -70,7 +62,6 @@ describe('OpenCodeVersionPolicy', () => { evaluateOpenCodeSupport({ version: '1.14.19-beta.1', capabilities: readyCapabilities(), - evidence: passingEvidence(), }) ).toMatchObject({ supported: false, @@ -79,12 +70,11 @@ describe('OpenCodeVersionPolicy', () => { }); }); - it('requires capabilities and production E2E evidence before production support', () => { + it('requires capabilities before support', () => { expect( evaluateOpenCodeSupport({ version: '1.14.19', capabilities: missingCapabilities(['POST permission reply route']), - evidence: passingEvidence(), }) ).toMatchObject({ supported: false, @@ -96,23 +86,6 @@ describe('OpenCodeVersionPolicy', () => { evaluateOpenCodeSupport({ version: '1.14.19', capabilities: readyCapabilities(), - evidence: null, - }) - ).toMatchObject({ - supported: false, - supportLevel: 'supported_e2e_pending', - diagnostics: [ - 'OpenCode version is capability-compatible but production E2E evidence is missing', - ], - }); - }); - - it('accepts supported version only when capabilities and E2E evidence pass', () => { - expect( - evaluateOpenCodeSupport({ - version: '1.14.19', - capabilities: readyCapabilities(), - evidence: passingEvidence(), }) ).toMatchObject({ supported: true, @@ -121,31 +94,16 @@ describe('OpenCodeVersionPolicy', () => { }); }); - it('rejects stale or incomplete production E2E evidence', () => { + it('accepts supported version when capabilities pass', () => { expect( - assertOpenCodeProductionE2EGate({ - evidence: passingEvidence({ version: '1.14.18' }), - testedVersion: '1.14.19', + evaluateOpenCodeSupport({ + version: '1.14.19', + capabilities: readyCapabilities(), }) ).toMatchObject({ - ok: false, - diagnostics: expect.arrayContaining([ - 'OpenCode production E2E evidence version 1.14.18 does not match tested version 1.14.19', - ]), - }); - - expect( - assertOpenCodeProductionE2EGate({ - evidence: passingEvidence({ - requiredSignals: requiredSignals({ canonical_log_projection_observed: false }), - }), - testedVersion: '1.14.19', - }) - ).toMatchObject({ - ok: false, - diagnostics: expect.arrayContaining([ - 'OpenCode production E2E evidence is missing signals: canonical_log_projection_observed', - ]), + supported: true, + supportLevel: 'production_supported', + diagnostics: [], }); }); @@ -260,76 +218,6 @@ function missingCapabilities(missing: string[]) { }; } -function passingEvidence( - overrides: Partial = {} -): OpenCodeProductionE2EEvidence { - const createdAt = new Date().toISOString(); - const expiresAt = new Date(Date.now() + 60_000).toISOString(); - const requiredToolIds = REQUIRED_AGENT_TEAMS_RUNTIME_TOOLS.map((tool) => `agent_teams_${tool}`); - const durableCheckpoints = OPENCODE_PRODUCTION_E2E_READY_CHECKPOINTS.map((name) => ({ - name, - observedAt: createdAt, - })); - - return { - schemaVersion: 1, - evidenceId: 'e2e-1', - createdAt, - expiresAt, - version: '1.14.19', - passed: true, - artifactPath: '/tmp/opencode-e2e', - binaryFingerprint: 'version:1.14.19', - capabilitySnapshotId: 'cap-1', - selectedModel: 'openai/gpt-5.4-mini', - projectPathFingerprint: 'project-a', - requiredSignals: requiredSignals(), - mcpTools: { - requiredTools: requiredToolIds, - observedTools: requiredToolIds, - }, - launch: { - runId: 'run-1', - teamId: 'team-a', - teamLaunchState: 'ready', - memberCount: 1, - sessions: [ - { - memberName: 'Dev', - sessionId: 'ses-1', - launchState: 'confirmed_alive', - }, - ], - durableCheckpoints, - }, - reconcile: { - runId: 'run-1', - teamLaunchState: 'ready', - memberCount: 1, - }, - stop: { - runId: 'run-1', - stopped: true, - stoppedSessionIds: ['ses-1'], - }, - logProjection: { - observed: true, - projectedMessageCount: 1, - }, - ...overrides, - }; -} - -function requiredSignals( - overrides: Partial< - Record<(typeof OPENCODE_PRODUCTION_E2E_REQUIRED_SIGNALS)[number], boolean> - > = {} -) { - return Object.fromEntries( - OPENCODE_PRODUCTION_E2E_REQUIRED_SIGNALS.map((signal) => [signal, overrides[signal] ?? true]) - ) as OpenCodeProductionE2EEvidence['requiredSignals']; -} - function compatibilitySnapshot( overrides: Partial ): OpenCodeCompatibilitySnapshot { @@ -349,7 +237,6 @@ function compatibilitySnapshot( supported: true, supportLevel: 'production_supported', apiCapabilities: readyCapabilities(), - testedEvidencePath: '/tmp/opencode-e2e', diagnostics: [], ...overrides, }; diff --git a/test/main/services/team/TeamAgentLaunchMatrix.safe-e2e.test.ts b/test/main/services/team/TeamAgentLaunchMatrix.safe-e2e.test.ts index 99ccc7f7..144c00c4 100644 --- a/test/main/services/team/TeamAgentLaunchMatrix.safe-e2e.test.ts +++ b/test/main/services/team/TeamAgentLaunchMatrix.safe-e2e.test.ts @@ -211,9 +211,9 @@ describe('Team agent launch matrix safe e2e', () => { const statuses = await svc.getMemberSpawnStatuses('permission-opencode-safe-e2e'); expect(statuses.teamLaunchState).toBe('partial_pending'); expect(statuses.statuses.alice).toMatchObject({ - status: 'online', + status: 'waiting', launchState: 'runtime_pending_permission', - runtimeAlive: true, + runtimeAlive: false, pendingPermissionRequestIds: ['perm-alice'], }); expect(statuses.summary?.pendingCount).toBe(1); @@ -255,9 +255,9 @@ describe('Team agent launch matrix safe e2e', () => { bootstrapConfirmed: true, }); expect(statuses.statuses.bob).toMatchObject({ - status: 'online', + status: 'waiting', launchState: 'runtime_pending_permission', - runtimeAlive: true, + runtimeAlive: false, pendingPermissionRequestIds: ['perm-bob'], }); expect(statuses.statuses.tom).toMatchObject({ @@ -2300,7 +2300,7 @@ describe('Team agent launch matrix safe e2e', () => { confirmedCount: 2, pendingCount: 1, failedCount: 0, - runtimeAlivePendingCount: 1, + runtimeAlivePendingCount: 0, }); expect(statuses.statuses.alice).toMatchObject({ status: 'online', @@ -2313,9 +2313,9 @@ describe('Team agent launch matrix safe e2e', () => { hardFailure: false, }); expect(statuses.statuses.tom).toMatchObject({ - status: 'online', + status: 'waiting', launchState: 'runtime_pending_bootstrap', - runtimeAlive: true, + runtimeAlive: false, bootstrapConfirmed: false, hardFailure: false, }); @@ -2909,7 +2909,7 @@ describe('Team agent launch matrix safe e2e', () => { confirmedCount: 1, pendingCount: 1, failedCount: 0, - runtimeAlivePendingCount: 1, + runtimeAlivePendingCount: 0, }); expect(statuses.statuses.alice).toMatchObject({ status: 'online', @@ -2917,9 +2917,9 @@ describe('Team agent launch matrix safe e2e', () => { hardFailure: false, }); expect(statuses.statuses.bob).toMatchObject({ - status: 'online', + status: 'waiting', launchState: 'runtime_pending_bootstrap', - runtimeAlive: true, + runtimeAlive: false, bootstrapConfirmed: false, hardFailure: false, }); @@ -2972,12 +2972,12 @@ describe('Team agent launch matrix safe e2e', () => { confirmedCount: 1, pendingCount: 1, failedCount: 0, - runtimeAlivePendingCount: 1, + runtimeAlivePendingCount: 0, }); expect(statuses.statuses.bob).toMatchObject({ - status: 'online', + status: 'waiting', launchState: 'runtime_pending_permission', - runtimeAlive: true, + runtimeAlive: false, pendingPermissionRequestIds: ['perm-bob'], hardFailure: false, }); @@ -3301,13 +3301,13 @@ describe('Team agent launch matrix safe e2e', () => { confirmedCount: 1, pendingCount: 1, failedCount: 0, - runtimeAlivePendingCount: 1, + runtimeAlivePendingCount: 0, }); expect(statuses.statuses.bob).toMatchObject({ - status: 'online', + status: 'waiting', launchState: 'runtime_pending_bootstrap', agentToolAccepted: true, - runtimeAlive: true, + runtimeAlive: false, bootstrapConfirmed: false, hardFailure: false, }); @@ -3361,13 +3361,13 @@ describe('Team agent launch matrix safe e2e', () => { confirmedCount: 1, pendingCount: 1, failedCount: 0, - runtimeAlivePendingCount: 1, + runtimeAlivePendingCount: 0, }); expect(statuses.statuses.bob).toMatchObject({ - status: 'online', + status: 'waiting', launchState: 'runtime_pending_permission', agentToolAccepted: true, - runtimeAlive: true, + runtimeAlive: false, pendingPermissionRequestIds: ['perm-bob'], hardFailure: false, }); @@ -3469,7 +3469,7 @@ describe('Team agent launch matrix safe e2e', () => { hardFailure: false, }); expect(statuses.statuses.tom).toMatchObject({ - status: 'online', + status: 'waiting', launchState: 'runtime_pending_permission', hardFailure: false, pendingPermissionRequestIds: ['perm-tom'], @@ -3588,7 +3588,7 @@ describe('Team agent launch matrix safe e2e', () => { hardFailure: false, }); expect(statuses.statuses.tom).toMatchObject({ - status: 'online', + status: 'waiting', launchState: 'runtime_pending_permission', hardFailure: false, pendingPermissionRequestIds: ['perm-tom'], @@ -4129,6 +4129,7 @@ describe('Team agent launch matrix safe e2e', () => { { alive: true, model: 'opencode/minimax-m2.5-free', + livenessKind: 'runtime_process', }, ], ]); @@ -4196,6 +4197,7 @@ describe('Team agent launch matrix safe e2e', () => { { alive: true, model: 'opencode/minimax-m2.5-free', + livenessKind: 'runtime_process', }, ], ]); @@ -4364,9 +4366,9 @@ describe('Team agent launch matrix safe e2e', () => { bootstrapConfirmed: true, }); expect(statuses.statuses.tom).toMatchObject({ - status: 'online', + status: 'waiting', launchState: 'runtime_pending_permission', - runtimeAlive: true, + runtimeAlive: false, pendingPermissionRequestIds: ['perm-tom'], }); }); @@ -4537,12 +4539,12 @@ describe('Team agent launch matrix safe e2e', () => { confirmedCount: 2, pendingCount: 1, failedCount: 0, - runtimeAlivePendingCount: 1, + runtimeAlivePendingCount: 0, }); expect(statuses.statuses.alice).toMatchObject({ - status: 'online', + status: 'waiting', launchState: 'runtime_pending_permission', - runtimeAlive: true, + runtimeAlive: false, bootstrapConfirmed: false, pendingPermissionRequestIds: ['perm-alice'], hardFailure: false, @@ -4591,7 +4593,7 @@ describe('Team agent launch matrix safe e2e', () => { confirmedCount: 2, pendingCount: 1, failedCount: 0, - runtimeAlivePendingCount: 1, + runtimeAlivePendingCount: 0, }); expect(statuses.statuses.alice).toMatchObject({ status: 'online', @@ -4606,9 +4608,9 @@ describe('Team agent launch matrix safe e2e', () => { hardFailure: false, }); expect(statuses.statuses.tom).toMatchObject({ - status: 'online', + status: 'waiting', launchState: 'runtime_pending_bootstrap', - runtimeAlive: true, + runtimeAlive: false, bootstrapConfirmed: false, hardFailure: false, }); @@ -4629,7 +4631,7 @@ describe('Team agent launch matrix safe e2e', () => { expect(runtimeSnapshot.members.tom).toMatchObject({ providerId: 'opencode', laneKind: 'secondary', - alive: true, + alive: false, runtimeModel: 'opencode/nemotron-3-super-free', }); }); @@ -4694,7 +4696,7 @@ describe('Team agent launch matrix safe e2e', () => { confirmedCount: 2, pendingCount: 1, failedCount: 1, - runtimeAlivePendingCount: 1, + runtimeAlivePendingCount: 0, }); expect(statuses.statuses.alice).toMatchObject({ status: 'online', @@ -4715,9 +4717,9 @@ describe('Team agent launch matrix safe e2e', () => { hardFailure: false, }); expect(statuses.statuses.tom).toMatchObject({ - status: 'online', + status: 'waiting', launchState: 'runtime_pending_bootstrap', - runtimeAlive: true, + runtimeAlive: false, bootstrapConfirmed: false, hardFailure: false, }); @@ -4744,7 +4746,7 @@ describe('Team agent launch matrix safe e2e', () => { expect(runtimeSnapshot.members.tom).toMatchObject({ providerId: 'opencode', laneKind: 'secondary', - alive: true, + alive: false, runtimeModel: 'opencode/nemotron-3-super-free', }); }); @@ -4934,7 +4936,7 @@ describe('Team agent launch matrix safe e2e', () => { hardFailure: false, }); expect(statuses.statuses.tom).toMatchObject({ - status: 'online', + status: 'waiting', launchState: 'runtime_pending_permission', hardFailure: false, pendingPermissionRequestIds: ['perm-tom'], @@ -5392,8 +5394,8 @@ describe('Team agent launch matrix safe e2e', () => { const svc = new TeamProvisioningService(); (svc as any).getLiveTeamAgentRuntimeMetadata = async () => new Map([ - ['alice', { alive: true, model: 'haiku' }], - ['bob-2', { alive: true, model: 'sonnet' }], + ['alice', { alive: true, model: 'haiku', livenessKind: 'confirmed_bootstrap' }], + ['bob-2', { alive: true, model: 'sonnet', livenessKind: 'runtime_process' }], ]); const statuses = await svc.getMemberSpawnStatuses(teamName); @@ -5458,8 +5460,15 @@ describe('Team agent launch matrix safe e2e', () => { const svc = new TeamProvisioningService(); (svc as any).getLiveTeamAgentRuntimeMetadata = async () => new Map([ - ['alice', { alive: true, model: 'haiku' }], - ['bob-2', { alive: true, model: 'opencode/minimax-m2.5-free' }], + ['alice', { alive: true, model: 'haiku', livenessKind: 'confirmed_bootstrap' }], + [ + 'bob-2', + { + alive: true, + model: 'opencode/minimax-m2.5-free', + livenessKind: 'runtime_process', + }, + ], ]); const statuses = await svc.getMemberSpawnStatuses(teamName); @@ -6832,8 +6841,8 @@ describe('Team agent launch matrix safe e2e', () => { const svc = new TeamProvisioningService(); (svc as any).getLiveTeamAgentRuntimeMetadata = async () => new Map([ - ['alice', { alive: true, model: 'haiku' }], - ['bob-2', { alive: true, model: 'sonnet' }], + ['alice', { alive: true, model: 'haiku', livenessKind: 'confirmed_bootstrap' }], + ['bob-2', { alive: true, model: 'sonnet', livenessKind: 'runtime_process' }], ]); const statuses = await svc.getMemberSpawnStatuses(teamName); @@ -6966,7 +6975,7 @@ describe('Team agent launch matrix safe e2e', () => { }); const svc = new TeamProvisioningService(); (svc as any).getLiveTeamAgentRuntimeMetadata = async () => - new Map([['alice', { alive: true, model: 'haiku' }]]); + new Map([['alice', { alive: true, model: 'haiku', livenessKind: 'runtime_process' }]]); const statuses = await svc.getMemberSpawnStatuses(teamName); @@ -13102,7 +13111,7 @@ describe('Team agent launch matrix safe e2e', () => { hardFailureReason: 'Gemini pane failed to start', }); expect(statuses.statuses.bob).toMatchObject({ - status: 'online', + status: 'waiting', launchState: 'runtime_pending_permission', pendingPermissionRequestIds: ['perm-bob'], hardFailure: false, @@ -13238,7 +13247,7 @@ describe('Team agent launch matrix safe e2e', () => { launchState: 'confirmed_alive', }); expect(statuses.statuses.bob).toMatchObject({ - status: 'online', + status: 'waiting', launchState: 'runtime_pending_permission', pendingPermissionRequestIds: ['perm-bob'], hardFailure: false, @@ -13291,7 +13300,7 @@ describe('Team agent launch matrix safe e2e', () => { hardFailure: false, }); expect(statuses.statuses.bob).toMatchObject({ - status: 'online', + status: 'waiting', launchState: 'runtime_pending_permission', pendingPermissionRequestIds: ['perm-bob'], hardFailure: false, @@ -13375,7 +13384,7 @@ describe('Team agent launch matrix safe e2e', () => { }); expect(secondStatuses.teamLaunchState).toBe('partial_pending'); expect(secondStatuses.statuses.bob).toMatchObject({ - status: 'online', + status: 'waiting', launchState: 'runtime_pending_permission', pendingPermissionRequestIds: ['perm-bob'], hardFailure: false, @@ -15570,7 +15579,7 @@ describe('Team agent launch matrix safe e2e', () => { hardFailure: false, }); expect(statuses.statuses.tom).toMatchObject({ - status: 'online', + status: 'waiting', launchState: 'runtime_pending_permission', hardFailure: false, pendingPermissionRequestIds: ['perm-tom'], @@ -15622,7 +15631,7 @@ describe('Team agent launch matrix safe e2e', () => { confirmedCount: 1, pendingCount: 2, failedCount: 0, - runtimeAlivePendingCount: 1, + runtimeAlivePendingCount: 0, }); expect(statuses.statuses.bob).toMatchObject({ status: 'online', @@ -15630,9 +15639,9 @@ describe('Team agent launch matrix safe e2e', () => { hardFailure: false, }); expect(statuses.statuses.tom).toMatchObject({ - status: 'online', + status: 'waiting', launchState: 'runtime_pending_bootstrap', - runtimeAlive: true, + runtimeAlive: false, bootstrapConfirmed: false, hardFailure: false, }); @@ -15703,7 +15712,7 @@ describe('Team agent launch matrix safe e2e', () => { hardFailure: false, }); expect(statuses.statuses.tom).toMatchObject({ - status: 'online', + status: 'waiting', launchState: 'runtime_pending_permission', hardFailure: false, pendingPermissionRequestIds: ['perm-tom'], @@ -15763,7 +15772,7 @@ describe('Team agent launch matrix safe e2e', () => { confirmedCount: 1, pendingCount: 3, failedCount: 0, - runtimeAlivePendingCount: 1, + runtimeAlivePendingCount: 0, }); expect(statuses.expectedMembers).toEqual( expect.arrayContaining(['alice', 'reviewer', 'bob', 'tom']) @@ -15776,9 +15785,9 @@ describe('Team agent launch matrix safe e2e', () => { hardFailure: false, }); expect(statuses.statuses.tom).toMatchObject({ - status: 'online', + status: 'waiting', launchState: 'runtime_pending_bootstrap', - runtimeAlive: true, + runtimeAlive: false, bootstrapConfirmed: false, hardFailure: false, }); @@ -16058,6 +16067,18 @@ class FakeOpenCodeRuntimeAdapter implements TeamLaunchRuntimeAdapter { const failed = outcome === 'failed'; const permissionPending = outcome === 'permission'; const bootstrapPending = outcome === 'launching'; + const livenessKind = failed + ? 'not_found' + : permissionPending + ? 'permission_blocked' + : bootstrapPending + ? 'runtime_process_candidate' + : 'confirmed_bootstrap'; + const runtimeDiagnostic = permissionPending + ? 'OpenCode runtime is waiting for permission approval' + : bootstrapPending + ? 'OpenCode runtime pid reported by bridge without local process verification' + : undefined; return { memberName: member.name, providerId: 'opencode', @@ -16069,12 +16090,15 @@ class FakeOpenCodeRuntimeAdapter implements TeamLaunchRuntimeAdapter { ? 'runtime_pending_bootstrap' : 'confirmed_alive', agentToolAccepted: !failed, - runtimeAlive: !failed, + runtimeAlive: !failed && !permissionPending && !bootstrapPending, bootstrapConfirmed: !failed && !permissionPending && !bootstrapPending, hardFailure: failed, hardFailureReason: failed ? 'fake_open_code_launch_failure' : undefined, pendingPermissionRequestIds: permissionPending ? [`perm-${member.name}`] : undefined, runtimePid: failed ? undefined : 10_000 + index, + livenessKind, + pidSource: failed ? undefined : 'opencode_bridge', + runtimeDiagnostic, diagnostics: failed ? ['fake OpenCode launch failure'] : permissionPending diff --git a/test/main/services/team/TeamBootstrapStateReader.test.ts b/test/main/services/team/TeamBootstrapStateReader.test.ts index 2267c540..d3b3c71c 100644 --- a/test/main/services/team/TeamBootstrapStateReader.test.ts +++ b/test/main/services/team/TeamBootstrapStateReader.test.ts @@ -279,6 +279,39 @@ describe('TeamBootstrapStateReader', () => { await expect(readBootstrapRuntimeState('demo')).resolves.toBeNull(); }); + it('does not promote bootstrap-state runtime_alive to strict runtimeAlive', async () => { + hoisted.files.set('/mock/teams/demo/bootstrap-state.json', { + contents: JSON.stringify({ + version: 1, + runId: 'run-123', + teamName: 'demo', + startedAt: 1700000000000, + updatedAt: 1700000000500, + phase: 'spawning_members', + members: [{ name: 'alice', status: 'runtime_alive', lastObservedAt: 1700000000400 }], + }), + }); + + await expect(readBootstrapLaunchSnapshot('demo')).resolves.toMatchObject({ + launchPhase: 'active', + members: { + alice: { + launchState: 'runtime_pending_bootstrap', + agentToolAccepted: true, + runtimeAlive: false, + bootstrapConfirmed: false, + sources: { + configRegistered: true, + }, + diagnostics: [ + 'runtime alive reported by bootstrap state', + 'waiting for strict live verification', + ], + }, + }, + }); + }); + it('reads persisted real-task submission state', async () => { hoisted.files.set('/mock/teams/demo/bootstrap-state.json', { contents: JSON.stringify({ diff --git a/test/main/services/team/TeamDataService.test.ts b/test/main/services/team/TeamDataService.test.ts index 256c2c17..9413efc1 100644 --- a/test/main/services/team/TeamDataService.test.ts +++ b/test/main/services/team/TeamDataService.test.ts @@ -242,10 +242,12 @@ function createForwardingJournalStore(initialEntries: Array true), ensureFile: vi.fn(async () => undefined), - withEntries: vi.fn(async (_teamName: string, fn: (entries: unknown[]) => Promise<{ result: unknown }>) => { - const outcome = await fn(journalEntries); - return outcome.result; - }), + withEntries: vi.fn( + async (_teamName: string, fn: (entries: unknown[]) => Promise<{ result: unknown }>) => { + const outcome = await fn(journalEntries); + return outcome.result; + } + ), }; return { journalEntries, journal }; @@ -262,7 +264,9 @@ function createTaskCommentForwardingService(options: { }; members?: Array<{ name: string; role?: string }>; }) { - const inboxWriter = options.inboxWriter ?? { sendMessage: vi.fn(async () => ({ deliveredToInbox: true, messageId: 'msg-1' })) }; + const inboxWriter = options.inboxWriter ?? { + sendMessage: vi.fn(async () => ({ deliveredToInbox: true, messageId: 'msg-1' })), + }; const journal = options.journal ?? createForwardingJournalStore().journal; const service = new TeamDataService( @@ -328,24 +332,26 @@ function buildDefaultTeamConfig(overrides: Partial = {}): TeamConfig }; } -function createGetTeamDataHarness(options: { - config?: TeamConfig | null; - getTasks?: () => Promise; - listInboxNames?: () => Promise; - getMessages?: () => Promise; - getMembers?: () => Promise; - getTeamMeta?: () => Promise; - getState?: () => Promise; - readMessages?: () => Promise; - resolveMembers?: ( - config: TeamConfig, - metaMembers: TeamConfig['members'], - inboxNames: string[], - tasks: TeamTaskWithKanban[] - ) => ResolvedTeamMember[]; - listProcesses?: () => TeamProcess[]; - getMemberAdvisories?: () => Promise>; -} = {}) { +function createGetTeamDataHarness( + options: { + config?: TeamConfig | null; + getTasks?: () => Promise; + listInboxNames?: () => Promise; + getMessages?: () => Promise; + getMembers?: () => Promise; + getTeamMeta?: () => Promise; + getState?: () => Promise; + readMessages?: () => Promise; + resolveMembers?: ( + config: TeamConfig, + metaMembers: TeamConfig['members'], + inboxNames: string[], + tasks: TeamTaskWithKanban[] + ) => ResolvedTeamMember[]; + listProcesses?: () => TeamProcess[]; + getMemberAdvisories?: () => Promise>; + } = {} +) { const getConfig = vi.fn(async () => options.config === undefined ? buildDefaultTeamConfig() : options.config ); @@ -486,7 +492,9 @@ describe('TeamDataService', () => { {} as never, {} as never, { resolveMembers: vi.fn(() => []) } as never, - { getState: vi.fn(async () => ({ teamName: 'dup-team', reviewers: [], tasks: {} })) } as never, + { + getState: vi.fn(async () => ({ teamName: 'dup-team', reviewers: [], tasks: {} })), + } as never, {} as never, membersMetaStore, { readMessages: vi.fn(async () => []) } as never @@ -518,7 +526,9 @@ describe('TeamDataService', () => { {} as never, {} as never, { resolveMembers: vi.fn(() => []) } as never, - { getState: vi.fn(async () => ({ teamName: 'dup-team', reviewers: [], tasks: {} })) } as never, + { + getState: vi.fn(async () => ({ teamName: 'dup-team', reviewers: [], tasks: {} })), + } as never, {} as never, membersMetaStore, { readMessages: vi.fn(async () => []) } as never @@ -1006,7 +1016,10 @@ describe('TeamDataService', () => { const service = new TeamDataService( { listTeams: vi.fn(), - getConfig: vi.fn(async () => ({ name: 'My team', members: [{ name: 'team-lead', role: 'Lead' }] })), + getConfig: vi.fn(async () => ({ + name: 'My team', + members: [{ name: 'team-lead', role: 'Lead' }], + })), } as never, {} as never, {} as never, @@ -1134,7 +1147,10 @@ describe('TeamDataService', () => { const service = new TeamDataService( { listTeams: vi.fn(), - getConfig: vi.fn(async () => ({ name: 'My team', members: [{ name: 'team-lead', role: 'Lead' }] })), + getConfig: vi.fn(async () => ({ + name: 'My team', + members: [{ name: 'team-lead', role: 'Lead' }], + })), } as never, { getTasks: vi.fn(async () => []), @@ -1305,7 +1321,9 @@ describe('TeamDataService', () => { expect(createTaskMock).toHaveBeenCalledWith( expect.objectContaining({ owner: 'alice', createdBy: 'user' }) ); - expect(createTaskMock).not.toHaveBeenCalledWith(expect.objectContaining({ startImmediately: true })); + expect(createTaskMock).not.toHaveBeenCalledWith( + expect.objectContaining({ startImmediately: true }) + ); }); it('creates task with explicit immediate start only when startImmediately is true', async () => { @@ -1362,7 +1380,9 @@ describe('TeamDataService', () => { prompt: 'Begin immediately.', }) ); - expect(createTaskMock).not.toHaveBeenCalledWith(expect.objectContaining({ status: 'in_progress' })); + expect(createTaskMock).not.toHaveBeenCalledWith( + expect.objectContaining({ status: 'in_progress' }) + ); }); it('persists explicit related task links when creating a task', async () => { @@ -1490,7 +1510,47 @@ describe('TeamDataService', () => { await service.requestReview('my-team', 'task-1'); expect(requestReviewMock).toHaveBeenCalledWith('task-1', { - from: 'user', + from: 'lead', + leadSessionId: 'lead-1', + }); + }); + + it('resolves the canonical lead instead of matching tech-lead role text', async () => { + const requestReviewMock = vi.fn(); + + const service = new TeamDataService( + { + listTeams: vi.fn(), + getConfig: vi.fn(async () => ({ + name: 'My team', + members: [ + { name: 'alice', role: 'tech lead' }, + { name: 'team-lead', agentType: 'team-lead', role: 'lead' }, + ], + leadSessionId: 'lead-1', + })), + } as never, + {} as never, + {} as never, + {} as never, + {} as never, + {} as never, + {} as never, + {} as never, + {} as never, + {} as never, + () => + ({ + review: { + requestReview: requestReviewMock, + }, + }) as never + ); + + await service.requestReview('my-team', 'task-1'); + + expect(requestReviewMock).toHaveBeenCalledWith('task-1', { + from: 'team-lead', leadSessionId: 'lead-1', }); }); @@ -1515,8 +1575,15 @@ describe('TeamDataService', () => { subject: 'Legacy review task', status: 'completed', owner: 'bob', - reviewState: 'review', - historyEvents: [], + reviewState: 'none', + historyEvents: [ + { + id: 'evt-created', + type: 'task_created', + status: 'completed', + timestamp: '2026-03-01T09:00:00.000Z', + }, + ], }, ]), } as never, @@ -1554,6 +1621,129 @@ describe('TeamDataService', () => { }); }); + it('does not leak stale reviewer after review is reset to pending', async () => { + const service = new TeamDataService( + { + listTeams: vi.fn(), + getConfig: vi.fn(async () => ({ + name: 'My team', + members: [ + { name: 'lead', role: 'team lead' }, + { name: 'bob', role: 'developer' }, + { name: 'carol', role: 'reviewer' }, + ], + })), + } as never, + { + getTasks: vi.fn(async () => [ + { + id: 'task-reopened', + subject: 'Reopened task', + status: 'pending', + owner: 'bob', + reviewState: 'none', + historyEvents: [ + { + id: 'evt-review', + type: 'review_requested', + from: 'none', + to: 'review', + reviewer: 'carol', + timestamp: '2026-03-01T10:00:00.000Z', + }, + { + id: 'evt-pending', + type: 'status_changed', + from: 'completed', + to: 'pending', + timestamp: '2026-03-01T10:05:00.000Z', + }, + ], + }, + ]), + } as never, + { + listInboxNames: vi.fn(async () => []), + getMessages: vi.fn(async () => []), + } as never, + {} as never, + {} as never, + { + resolveMembers: vi.fn(() => []), + } as never, + { + getState: vi.fn(async () => ({ teamName: 'my-team', reviewers: [], tasks: {} })), + } as never + ); + + const data = await service.getTeamData('my-team'); + + expect(data.tasks[0]).toMatchObject({ + id: 'task-reopened', + reviewState: 'none', + reviewer: null, + }); + }); + + it('applies kanban overlay review state in global task projections', async () => { + const service = new TeamDataService( + { + listTeams: vi.fn(async () => [ + { + teamName: 'my-team', + displayName: 'My team', + projectPath: '/repo', + }, + ]), + } as never, + { + getAllTasks: vi.fn(async () => [ + { + id: 'task-global-review', + teamName: 'my-team', + subject: 'Global review task', + status: 'completed', + owner: 'bob', + reviewState: 'none', + historyEvents: [ + { + id: 'evt-created', + type: 'task_created', + status: 'completed', + timestamp: '2026-03-01T09:00:00.000Z', + }, + ], + }, + ]), + } as never, + {} as never, + {} as never, + {} as never, + {} as never, + { + getState: vi.fn(async () => ({ + teamName: 'my-team', + reviewers: [], + tasks: { + 'task-global-review': { + column: 'review', + reviewer: 'carol', + movedAt: '2026-03-01T10:00:00.000Z', + }, + }, + })), + } as never + ); + + const tasks = await service.getAllTasks(); + + expect(tasks[0]).toMatchObject({ + id: 'task-global-review', + reviewState: 'review', + kanbanColumn: 'review', + }); + }); + it('propagates leadSessionId for kanban-driven review transitions', async () => { const requestReviewMock = vi.fn(); const approveReviewMock = vi.fn(); @@ -1589,20 +1779,23 @@ describe('TeamDataService', () => { await service.updateKanban('my-team', 'task-1', { op: 'set_column', column: 'review' }); await service.updateKanban('my-team', 'task-1', { op: 'set_column', column: 'approved' }); - await service.updateKanban('my-team', 'task-1', { op: 'request_changes', comment: 'Needs fixes' }); + await service.updateKanban('my-team', 'task-1', { + op: 'request_changes', + comment: 'Needs fixes', + }); expect(requestReviewMock).toHaveBeenCalledWith('task-1', { - from: 'user', + from: 'lead', leadSessionId: 'lead-2', }); expect(approveReviewMock).toHaveBeenCalledWith('task-1', { - from: 'user', + from: 'lead', suppressTaskComment: true, 'notify-owner': true, leadSessionId: 'lead-2', }); expect(requestChangesMock).toHaveBeenCalledWith('task-1', { - from: 'user', + from: 'lead', comment: 'Needs fixes', leadSessionId: 'lead-2', }); @@ -1619,10 +1812,12 @@ describe('TeamDataService', () => { ensureFile: vi.fn(async () => { journalExists = true; }), - withEntries: vi.fn(async (_teamName: string, fn: (entries: unknown[]) => Promise<{ result: unknown }>) => { - const outcome = await fn(journalEntries); - return outcome.result; - }), + withEntries: vi.fn( + async (_teamName: string, fn: (entries: unknown[]) => Promise<{ result: unknown }>) => { + const outcome = await fn(journalEntries); + return outcome.result; + } + ), }; try { @@ -1711,10 +1906,12 @@ describe('TeamDataService', () => { const journal = { exists: vi.fn(async () => true), ensureFile: vi.fn(async () => undefined), - withEntries: vi.fn(async (_teamName: string, fn: (entries: unknown[]) => Promise<{ result: unknown }>) => { - const outcome = await fn(journalEntries); - return outcome.result; - }), + withEntries: vi.fn( + async (_teamName: string, fn: (entries: unknown[]) => Promise<{ result: unknown }>) => { + const outcome = await fn(journalEntries); + return outcome.result; + } + ), }; try { @@ -1780,10 +1977,9 @@ describe('TeamDataService', () => { messageId: 'task-comment-forward:my-team:task-1:comment-1', }) ); - const firstSendRequest = (inboxWriter.sendMessage as unknown as { mock: { calls: unknown[][] } }) - .mock.calls[0]?.[1] as - | { text?: string } - | undefined; + const firstSendRequest = ( + inboxWriter.sendMessage as unknown as { mock: { calls: unknown[][] } } + ).mock.calls[0]?.[1] as { text?: string } | undefined; expect(String(firstSendRequest?.text ?? '')).not.toContain(''); const sentEntry = journalEntries.find((entry) => entry.key === 'task-1:comment-1'); expect(sentEntry).toMatchObject({ @@ -1807,10 +2003,12 @@ describe('TeamDataService', () => { ensureFile: vi.fn(async () => { journalExists = true; }), - withEntries: vi.fn(async (_teamName: string, fn: (entries: unknown[]) => Promise<{ result: unknown }>) => { - const outcome = await fn(journalEntries); - return outcome.result; - }), + withEntries: vi.fn( + async (_teamName: string, fn: (entries: unknown[]) => Promise<{ result: unknown }>) => { + const outcome = await fn(journalEntries); + return outcome.result; + } + ), }; try { @@ -1906,10 +2104,12 @@ describe('TeamDataService', () => { const journal = { exists: vi.fn(async () => true), ensureFile: vi.fn(async () => undefined), - withEntries: vi.fn(async (_teamName: string, fn: (entries: unknown[]) => Promise<{ result: unknown }>) => { - const outcome = await fn(journalEntries); - return outcome.result; - }), + withEntries: vi.fn( + async (_teamName: string, fn: (entries: unknown[]) => Promise<{ result: unknown }>) => { + const outcome = await fn(journalEntries); + return outcome.result; + } + ), }; try { @@ -1987,10 +2187,12 @@ describe('TeamDataService', () => { const journal = { exists: vi.fn(async () => true), ensureFile: vi.fn(async () => undefined), - withEntries: vi.fn(async (_teamName: string, fn: (entries: unknown[]) => Promise<{ result: unknown }>) => { - const outcome = await fn(journalEntries); - return outcome.result; - }), + withEntries: vi.fn( + async (_teamName: string, fn: (entries: unknown[]) => Promise<{ result: unknown }>) => { + const outcome = await fn(journalEntries); + return outcome.result; + } + ), }; try { @@ -2085,15 +2287,20 @@ describe('TeamDataService', () => { }, ]; const inboxWriter = { - sendMessage: vi.fn(async () => ({ deliveredToInbox: true, messageId: 'task-comment-forward:my-team:task-1:comment-1' })), + sendMessage: vi.fn(async () => ({ + deliveredToInbox: true, + messageId: 'task-comment-forward:my-team:task-1:comment-1', + })), }; const journal = { exists: vi.fn(async () => true), ensureFile: vi.fn(async () => undefined), - withEntries: vi.fn(async (_teamName: string, fn: (entries: unknown[]) => Promise<{ result: unknown }>) => { - const outcome = await fn(journalEntries); - return outcome.result; - }), + withEntries: vi.fn( + async (_teamName: string, fn: (entries: unknown[]) => Promise<{ result: unknown }>) => { + const outcome = await fn(journalEntries); + return outcome.result; + } + ), }; try { @@ -2187,10 +2394,12 @@ describe('TeamDataService', () => { const journal = { exists: vi.fn(async () => true), ensureFile: vi.fn(async () => undefined), - withEntries: vi.fn(async (_teamName: string, fn: (entries: unknown[]) => Promise<{ result: unknown }>) => { - const outcome = await fn(journalEntries); - return outcome.result; - }), + withEntries: vi.fn( + async (_teamName: string, fn: (entries: unknown[]) => Promise<{ result: unknown }>) => { + const outcome = await fn(journalEntries); + return outcome.result; + } + ), }; try { @@ -2287,10 +2496,12 @@ describe('TeamDataService', () => { const journal = { exists: vi.fn(async () => true), ensureFile: vi.fn(async () => undefined), - withEntries: vi.fn(async (_teamName: string, fn: (entries: unknown[]) => Promise<{ result: unknown }>) => { - const outcome = await fn(journalEntries); - return outcome.result; - }), + withEntries: vi.fn( + async (_teamName: string, fn: (entries: unknown[]) => Promise<{ result: unknown }>) => { + const outcome = await fn(journalEntries); + return outcome.result; + } + ), }; try { @@ -2372,10 +2583,12 @@ describe('TeamDataService', () => { const journal = { exists: vi.fn(async () => true), ensureFile: vi.fn(async () => undefined), - withEntries: vi.fn(async (_teamName: string, fn: (entries: unknown[]) => Promise<{ result: unknown }>) => { - const outcome = await fn(journalEntries); - return outcome.result; - }), + withEntries: vi.fn( + async (_teamName: string, fn: (entries: unknown[]) => Promise<{ result: unknown }>) => { + const outcome = await fn(journalEntries); + return outcome.result; + } + ), }; try { @@ -2615,15 +2828,19 @@ describe('TeamDataService', () => { const initGate = new Promise((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> = []; const journal = { exists: vi.fn(async () => true), ensureFile: vi.fn(async () => undefined), - withEntries: vi.fn(async (_teamName: string, fn: (entries: unknown[]) => Promise<{ result: unknown }>) => { - const outcome = await fn(journalEntries); - return outcome.result; - }), + withEntries: vi.fn( + async (_teamName: string, fn: (entries: unknown[]) => Promise<{ result: unknown }>) => { + const outcome = await fn(journalEntries); + return outcome.result; + } + ), }; try { @@ -3424,7 +3641,9 @@ describe('TeamDataService', () => { }); const feed = await service.getMessageFeed('my-team'); - const linked = feed.messages.find((message) => message.messageId === 'passive-user-summary-old-1'); + const linked = feed.messages.find( + (message) => message.messageId === 'passive-user-summary-old-1' + ); expect(linked?.relayOfMessageId).toBeUndefined(); }); @@ -3992,10 +4211,7 @@ describe('TeamDataService', () => { ), ]); - const firstSpy = vi.spyOn( - firstService as never, - 'extractLeadAssistantTextsFromJsonl' as never - ); + const firstSpy = vi.spyOn(firstService as never, 'extractLeadAssistantTextsFromJsonl' as never); const secondSpy = vi.spyOn( secondService as never, 'extractLeadAssistantTextsFromJsonl' as never @@ -4110,7 +4326,9 @@ describe('TeamDataService', () => { const service = createResolverBackedService(); const page = await service.getMessagesPage(fixture.teamName, { limit: 20 }); - const leadSessionMessages = page.messages.filter((message) => message.source === 'lead_session'); + const leadSessionMessages = page.messages.filter( + (message) => message.source === 'lead_session' + ); expect( leadSessionMessages.some((message) => @@ -4191,12 +4409,7 @@ describe('TeamDataService', () => { await flushMicrotasks(); expect(order).toEqual( - expect.arrayContaining([ - 'inboxNames:start', - 'meta:start', - 'kanban:start', - 'tasks:start', - ]) + expect.arrayContaining(['inboxNames:start', 'meta:start', 'kanban:start', 'tasks:start']) ); expect(order).not.toContain('processes:start'); expect(order).not.toContain('leadTexts:start'); @@ -4457,7 +4670,11 @@ describe('TeamDataService', () => { const feed = await harness.service.getMessageFeed('my-team'); - expect(feed.messages.map((message) => message.messageId)).toEqual(['sent-1', 'lead-1', 'inbox-1']); + expect(feed.messages.map((message) => message.messageId)).toEqual([ + 'sent-1', + 'lead-1', + 'inbox-1', + ]); }); it('preserves assembled messages and resolver inputs when inbox messages fail', async () => { @@ -4575,10 +4792,12 @@ describe('TeamDataService', () => { }, }); - vi.spyOn(harness.service as never, 'extractLeadSessionTexts' as never).mockImplementation(() => { - order.push('leadTexts:start'); - throw new Error('lead sync fail'); - }); + vi.spyOn(harness.service as never, 'extractLeadSessionTexts' as never).mockImplementation( + () => { + order.push('leadTexts:start'); + throw new Error('lead sync fail'); + } + ); const pending = harness.service.getTeamData('my-team'); await flushMicrotasks(); @@ -4669,7 +4888,16 @@ describe('TeamDataService', () => { }); describe('getMessagesPage', () => { - function createPaginationService(messages: Array<{ from: string; text: string; timestamp: string; messageId?: string; source?: string; leadSessionId?: string }>) { + function createPaginationService( + messages: Array<{ + from: string; + text: string; + timestamp: string; + messageId?: string; + source?: string; + leadSessionId?: string; + }> + ) { return new TeamDataService( { listTeams: vi.fn(), @@ -4682,17 +4910,17 @@ describe('TeamDataService', () => { { getTasks: vi.fn(async () => []) } as never, { listInboxNames: vi.fn(async () => []), - getMessages: vi.fn(async () => - messages.map((m) => ({ ...m, read: true })) - ), + getMessages: vi.fn(async () => messages.map((m) => ({ ...m, read: true }))), } as never, {} as never, {} as never, { resolveMembers: vi.fn(() => []) } as never, - { getState: vi.fn(async () => ({ teamName: 'my-team', reviewers: [], tasks: {} })) } as never, + { + getState: vi.fn(async () => ({ teamName: 'my-team', reviewers: [], tasks: {} })), + } as never, {} as never, {} as never, - { readMessages: vi.fn(async () => []) } as never, + { readMessages: vi.fn(async () => []) } as never ); } @@ -4792,7 +5020,9 @@ describe('TeamDataService', () => { expect(page1.messages[0]?.messageId).toMatch(/^inbox-/); expect(page1.nextCursor).toContain(page1.messages[0]!.messageId!); expect(page2.messages.every((message) => Boolean(message.messageId))).toBe(true); - expect(new Set([...page1.messages, ...page2.messages].map((message) => message.messageId)).size).toBe(3); + expect( + new Set([...page1.messages, ...page2.messages].map((message) => message.messageId)).size + ).toBe(3); }); it('dedups newest-page live overlay against durable lead thoughts that already paged off the first page', async () => { @@ -4876,7 +5106,10 @@ describe('TeamDataService', () => { cursor: page1.nextCursor!, }); - expect(page2.messages.map((message) => message.messageId)).toEqual(['durable-2', 'durable-1']); + expect(page2.messages.map((message) => message.messageId)).toEqual([ + 'durable-2', + 'durable-1', + ]); }); }); }); diff --git a/test/main/services/team/TeamLaunchStateEvaluator.test.ts b/test/main/services/team/TeamLaunchStateEvaluator.test.ts index c0a1f2bf..ddfb4168 100644 --- a/test/main/services/team/TeamLaunchStateEvaluator.test.ts +++ b/test/main/services/team/TeamLaunchStateEvaluator.test.ts @@ -1,6 +1,7 @@ import { describe, expect, it } from 'vitest'; import { + normalizePersistedLaunchSnapshot, snapshotToMemberSpawnStatuses, summarizePersistedLaunchMembers, } from '../../../../src/main/services/team/TeamLaunchStateEvaluator'; @@ -54,32 +55,175 @@ describe('TeamLaunchStateEvaluator', () => { }); expect(statuses.bob).toMatchObject({ launchState: 'runtime_pending_permission', - status: 'online', + status: 'waiting', + runtimeAlive: false, pendingPermissionRequestIds: ['req-1'], }); }); - it('counts persisted members in launch summary even when expectedMembers is stale', () => { - const summary = summarizePersistedLaunchMembers( - ['alice'], - { - alice: { - launchState: 'runtime_pending_bootstrap', - runtimeAlive: false, - }, - bob: { - launchState: 'runtime_pending_permission', - runtimeAlive: true, - }, - } as any - ); + it('does not count weak persisted runtimeAlive without strong liveness evidence', () => { + const summary = summarizePersistedLaunchMembers(['alice'], { + alice: { + launchState: 'runtime_pending_bootstrap', + runtimeAlive: false, + }, + bob: { + launchState: 'runtime_pending_permission', + runtimeAlive: true, + }, + } as any); expect(summary).toEqual({ confirmedCount: 0, pendingCount: 2, failedCount: 0, - runtimeAlivePendingCount: 1, + skippedCount: 0, + runtimeAlivePendingCount: 0, + shellOnlyPendingCount: 0, + runtimeProcessPendingCount: 0, + runtimeCandidatePendingCount: 0, + noRuntimePendingCount: 0, + permissionPendingCount: 1, }); }); + it('keeps skipped members terminal and out of pending counts', () => { + const summary = summarizePersistedLaunchMembers(['alice', 'bob'], { + alice: { + launchState: 'skipped_for_launch', + skippedForLaunch: true, + runtimeAlive: false, + bootstrapConfirmed: false, + hardFailure: false, + }, + bob: { + launchState: 'confirmed_alive', + runtimeAlive: true, + bootstrapConfirmed: true, + hardFailure: false, + }, + } as any); + + expect(summary).toMatchObject({ + confirmedCount: 1, + pendingCount: 0, + failedCount: 0, + skippedCount: 1, + }); + }); + + it('does not preserve runtimeAlive for skipped persisted members', () => { + const snapshot = normalizePersistedLaunchSnapshot('demo', { + version: 2, + teamName: 'demo', + updatedAt: '2026-04-23T00:00:00.000Z', + launchPhase: 'finished', + expectedMembers: ['alice'], + members: { + alice: { + name: 'alice', + launchState: 'skipped_for_launch', + skippedForLaunch: true, + agentToolAccepted: true, + runtimeAlive: true, + bootstrapConfirmed: true, + hardFailure: false, + livenessKind: 'runtime_process', + lastEvaluatedAt: '2026-04-23T00:00:00.000Z', + }, + }, + }); + + expect(snapshot?.members.alice).toMatchObject({ + launchState: 'skipped_for_launch', + runtimeAlive: false, + bootstrapConfirmed: false, + agentToolAccepted: false, + skippedForLaunch: true, + }); + + const statuses = snapshotToMemberSpawnStatuses(snapshot!); + expect(statuses.alice).toMatchObject({ + status: 'skipped', + launchState: 'skipped_for_launch', + runtimeAlive: false, + bootstrapConfirmed: false, + agentToolAccepted: false, + skippedForLaunch: true, + }); + }); + + 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(); + }); }); diff --git a/test/main/services/team/TeamMcpConfigBuilder.test.ts b/test/main/services/team/TeamMcpConfigBuilder.test.ts index fa33b110..22e70f16 100644 --- a/test/main/services/team/TeamMcpConfigBuilder.test.ts +++ b/test/main/services/team/TeamMcpConfigBuilder.test.ts @@ -75,7 +75,9 @@ describe('TeamMcpConfigBuilder', () => { return dir; } - function readGeneratedServer(configPath: string): { command?: string; args?: string[] } | undefined { + function readGeneratedServer( + configPath: string + ): { command?: string; args?: string[] } | undefined { const raw = fs.readFileSync(configPath, 'utf8'); const parsed = JSON.parse(raw) as { mcpServers?: Record; @@ -83,26 +85,72 @@ describe('TeamMcpConfigBuilder', () => { return parsed.mcpServers?.['agent-teams']; } - function expectNodeEntry(server: { command?: string; args?: string[] } | undefined, entry: string): void { + function expectNodeEntry( + server: { command?: string; args?: string[] } | undefined, + entry: string + ): void { expect(server?.args).toEqual([entry]); expect(server?.command).toMatch(/(^node$|[\\/]node(?:\.exe)?$)/); } - function mockPathExists(existingPaths: string[]): void { + function expectTsxEntry( + server: { command?: string; args?: string[] } | undefined, + entry: string + ): void { + expect(server?.args).toEqual([entry]); + expect(server?.command).toMatch(/[\\/]tsx(?:\.cmd)?$/); + } + + function getBuiltWorkspaceEntry(): string { + return path.join(process.cwd(), 'mcp-server', 'dist', 'index.js'); + } + + function getSourceWorkspaceEntry(): string { + return path.join(process.cwd(), 'mcp-server', 'src', 'index.ts'); + } + + function getWorkspaceTsxBin(): string { + return path.join(process.cwd(), 'mcp-server', 'node_modules', '.bin', 'tsx'); + } + + function mockPathExists(existingPaths: string[], options: { strict?: boolean } = {}): void { const originalAccess = fs.promises.access.bind(fs.promises); vi.spyOn(fs.promises, 'access').mockImplementation(async (targetPath, mode) => { const normalizedPath = - typeof targetPath === 'string' ? targetPath : Buffer.isBuffer(targetPath) ? targetPath.toString() : `${targetPath}`; + typeof targetPath === 'string' + ? targetPath + : Buffer.isBuffer(targetPath) + ? targetPath.toString() + : `${targetPath}`; if (existingPaths.includes(normalizedPath)) { return; } + if (options.strict) { + const error = new Error( + `ENOENT: no such file or directory, access '${normalizedPath}'` + ) as NodeJS.ErrnoException; + error.code = 'ENOENT'; + throw error; + } await originalAccess(targetPath, mode); }); } + function mockSourceWorkspaceEntryAvailable(): { + sourceEntry: string; + tsxBin: string; + builtEntry: string; + } { + const sourceEntry = getSourceWorkspaceEntry(); + const tsxBin = getWorkspaceTsxBin(); + const builtEntry = getBuiltWorkspaceEntry(); + mockPathExists([sourceEntry, tsxBin, builtEntry], { strict: true }); + return { sourceEntry, tsxBin, builtEntry }; + } + function mockBuiltWorkspaceEntryAvailable(): string { - const builtEntry = path.join(process.cwd(), 'mcp-server', 'dist', 'index.js'); - mockPathExists([builtEntry]); + const builtEntry = getBuiltWorkspaceEntry(); + mockPathExists([builtEntry], { strict: true }); return builtEntry; } @@ -172,12 +220,26 @@ describe('TeamMcpConfigBuilder', () => { createdPaths.push(configPath); const filename = path.basename(configPath); - expect(filename).toMatch( - new RegExp(`^agent-teams-mcp-${process.pid}-\\d+-[0-9a-f-]+\\.json$`) - ); + expect(filename).toMatch(new RegExp(`^agent-teams-mcp-${process.pid}-\\d+-[0-9a-f-]+\\.json$`)); }); - it('prefers the built workspace MCP entry when available', async () => { + it('prefers the source workspace MCP entry in dev mode when available', async () => { + const { sourceEntry } = mockSourceWorkspaceEntryAvailable(); + const builder = new TeamMcpConfigBuilder(); + + const configPath = await builder.writeConfigFile(); + createdPaths.push(configPath); + + const raw = fs.readFileSync(configPath, 'utf8'); + const parsed = JSON.parse(raw) as { + mcpServers?: Record; + }; + + const server = parsed.mcpServers?.['agent-teams']; + expectTsxEntry(server, sourceEntry); + }); + + it('falls back to the built workspace MCP entry when source execution is unavailable', async () => { const builtEntry = mockBuiltWorkspaceEntryAvailable(); const builder = new TeamMcpConfigBuilder(); @@ -232,7 +294,10 @@ describe('TeamMcpConfigBuilder', () => { createdPaths.push(configPath); const parsed = JSON.parse(fs.readFileSync(configPath, 'utf8')) as { - mcpServers: Record; + mcpServers: Record< + string, + { command?: string; args?: string[]; type?: string; url?: string } + >; }; expect(Object.keys(parsed.mcpServers)).toEqual(['agent-teams']); @@ -246,7 +311,10 @@ describe('TeamMcpConfigBuilder', () => { createdDirs.push(homeDir, projectDir); mockHomeDir = homeDir; - fs.writeFileSync(path.join(homeDir, '.claude.json'), JSON.stringify({ mcpServers: {} }, null, 2)); + fs.writeFileSync( + path.join(homeDir, '.claude.json'), + JSON.stringify({ mcpServers: {} }, null, 2) + ); fs.writeFileSync( path.join(projectDir, '.mcp.json'), JSON.stringify( @@ -273,7 +341,7 @@ describe('TeamMcpConfigBuilder', () => { }); it('generated agent-teams server ignores same-named user MCP entry', async () => { - const builtEntry = mockBuiltWorkspaceEntryAvailable(); + const { sourceEntry } = mockSourceWorkspaceEntryAvailable(); const homeDir = fs.mkdtempSync(path.join(os.tmpdir(), 'team-mcp-home-')); createdDirs.push(homeDir); mockHomeDir = homeDir; @@ -299,7 +367,7 @@ describe('TeamMcpConfigBuilder', () => { mcpServers: Record; }; - expectNodeEntry(parsed.mcpServers['agent-teams'], builtEntry); + expectTsxEntry(parsed.mcpServers['agent-teams'], sourceEntry); }); it('ignores malformed user MCP file', async () => { @@ -494,7 +562,10 @@ describe('TeamMcpConfigBuilder', () => { const configPath = await builder.writeConfigFile(); createdPaths.push(configPath); - expectNodeEntry(readGeneratedServer(configPath), path.join(resourcesDir, 'mcp-server', 'index.js')); + expectNodeEntry( + readGeneratedServer(configPath), + path.join(resourcesDir, 'mcp-server', 'index.js') + ); }); it('packaged mode uses the winner stable copy when atomic rename loses the race', async () => { @@ -526,8 +597,8 @@ describe('TeamMcpConfigBuilder', () => { expectNodeEntry(readGeneratedServer(configPath), path.join(stableDir, 'index.js')); }); - it('packaged mode falls back to the built workspace MCP entry when resourcesPath bundle is missing', async () => { - const builtEntry = mockBuiltWorkspaceEntryAvailable(); + it('packaged mode falls back to the source workspace MCP entry when resourcesPath bundle is missing', async () => { + const { sourceEntry } = mockSourceWorkspaceEntryAvailable(); setPackagedMode(true, '6.0.0'); const resourcesDir = fs.mkdtempSync(path.join(os.tmpdir(), 'team-mcp-resources-')); createdDirs.push(resourcesDir); @@ -537,6 +608,6 @@ describe('TeamMcpConfigBuilder', () => { const configPath = await builder.writeConfigFile(); createdPaths.push(configPath); - expectNodeEntry(readGeneratedServer(configPath), builtEntry); + expectTsxEntry(readGeneratedServer(configPath), sourceEntry); }); }); diff --git a/test/main/services/team/TeamProvisioningService.test.ts b/test/main/services/team/TeamProvisioningService.test.ts index fff4b20c..dfae9753 100644 --- a/test/main/services/team/TeamProvisioningService.test.ts +++ b/test/main/services/team/TeamProvisioningService.test.ts @@ -27,7 +27,9 @@ vi.mock('@main/services/team/ClaudeBinaryResolver', () => ({ vi.mock('@features/tmux-installer/main', () => ({ killTmuxPaneForCurrentPlatformSync: vi.fn(), + listRuntimeProcessesForCurrentTmuxPlatform: vi.fn(async () => []), listTmuxPanePidsForCurrentPlatform: vi.fn(async () => new Map()), + listTmuxPaneRuntimeInfoForCurrentPlatform: vi.fn(async () => new Map()), isTmuxRuntimeReadyForCurrentPlatform: vi.fn(async () => true), })); @@ -146,7 +148,9 @@ import { } from 'agent-teams-controller'; import { killTmuxPaneForCurrentPlatformSync, + listRuntimeProcessesForCurrentTmuxPlatform, listTmuxPanePidsForCurrentPlatform, + listTmuxPaneRuntimeInfoForCurrentPlatform, } from '@features/tmux-installer/main'; import pidusage from 'pidusage'; @@ -411,6 +415,13 @@ function createClaudeLogsRun(overrides: Record = {}) { describe('TeamProvisioningService', () => { beforeEach(() => { vi.clearAllMocks(); + vi.mocked(killTmuxPaneForCurrentPlatformSync).mockReset(); + vi.mocked(listRuntimeProcessesForCurrentTmuxPlatform).mockReset(); + vi.mocked(listRuntimeProcessesForCurrentTmuxPlatform).mockResolvedValue([]); + vi.mocked(listTmuxPanePidsForCurrentPlatform).mockReset(); + vi.mocked(listTmuxPanePidsForCurrentPlatform).mockResolvedValue(new Map()); + vi.mocked(listTmuxPaneRuntimeInfoForCurrentPlatform).mockReset(); + vi.mocked(listTmuxPaneRuntimeInfoForCurrentPlatform).mockResolvedValue(new Map()); tempClaudeRoot = fs.mkdtempSync(path.join(os.tmpdir(), 'claude-team-provisioning-')); tempTeamsBase = path.join(tempClaudeRoot, 'teams'); tempTasksBase = path.join(tempClaudeRoot, 'tasks'); @@ -559,7 +570,18 @@ describe('TeamProvisioningService', () => { cancelRequested: false, spawnContext: null, }); - vi.mocked(listTmuxPanePidsForCurrentPlatform).mockResolvedValueOnce(new Map([['%1', 222]])); + vi.mocked(listTmuxPaneRuntimeInfoForCurrentPlatform).mockResolvedValueOnce( + new Map([ + [ + '%1', + { + paneId: '%1', + panePid: 222, + currentCommand: 'codex', + }, + ], + ]) + ); vi.mocked(pidusage).mockResolvedValueOnce({ '111': createPidusageStat(111, 123_000_000), @@ -652,7 +674,18 @@ describe('TeamProvisioningService', () => { cancelRequested: false, spawnContext: null, }); - vi.mocked(listTmuxPanePidsForCurrentPlatform).mockResolvedValueOnce(new Map([['%1', 222]])); + vi.mocked(listTmuxPaneRuntimeInfoForCurrentPlatform).mockResolvedValueOnce( + new Map([ + [ + '%1', + { + paneId: '%1', + panePid: 222, + currentCommand: 'codex', + }, + ], + ]) + ); vi.mocked(pidusage) .mockRejectedValueOnce(new Error('ps: process exited')) @@ -695,14 +728,14 @@ describe('TeamProvisioningService', () => { cancelRequested: false, spawnContext: null, }); - (svc as any).readUnixProcessTableRows = vi.fn(() => [ + 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 gpt-5.2', }, ]); - vi.mocked(listTmuxPanePidsForCurrentPlatform).mockResolvedValueOnce(new Map()); vi.mocked(pidusage).mockResolvedValueOnce({ '111': createPidusageStat(111, 123_000_000), '333': createPidusageStat(333, 456_000_000), @@ -721,6 +754,71 @@ describe('TeamProvisioningService', () => { }); }); + it('keeps RSS visible for bootstrap-confirmed Anthropic teammates with a verified process', async () => { + const svc = new TeamProvisioningService(); + (svc as any).configReader = { + getConfig: vi.fn(async () => ({ + members: [ + { name: 'team-lead', agentType: 'team-lead' }, + { name: 'alice', providerId: 'anthropic', model: 'claude-sonnet-4-6' }, + ], + })), + }; + (svc as any).readPersistedRuntimeMembers = vi.fn(() => [ + { + name: 'alice', + agentId: 'alice@nice-team', + backendType: 'tmux', + }, + ]); + const run = createMemberSpawnRun({ + teamName: 'nice-team', + expectedMembers: ['alice'], + memberSpawnStatuses: new Map([ + [ + 'alice', + createMemberSpawnStatusEntry({ + status: 'online', + launchState: 'confirmed_alive', + runtimeAlive: true, + bootstrapConfirmed: true, + lastHeartbeatAt: '2026-04-24T12:00:00.000Z', + }), + ], + ]), + }); + run.child = { pid: 111 }; + run.request = { model: 'claude-opus-4-6' }; + run.processKilled = false; + run.cancelRequested = false; + (svc as any).aliveRunByTeam.set('nice-team', run.runId); + (svc as any).runs.set(run.runId, run); + vi.mocked(listRuntimeProcessesForCurrentTmuxPlatform).mockResolvedValueOnce([ + { + pid: 333, + ppid: 1, + command: + '/Users/belief/.bun/bin/bun cli.js --agent-id alice@nice-team --agent-name alice --team-name nice-team --model claude-sonnet-4-6', + }, + ]); + vi.mocked(pidusage).mockResolvedValueOnce({ + '111': createPidusageStat(111, 123_000_000), + '333': createPidusageStat(333, 456_000_000), + } as any); + + const snapshot = await svc.getTeamAgentRuntimeSnapshot('nice-team'); + + expect(pidusage).toHaveBeenCalledWith([111, 333], { maxage: 0 }); + expect(snapshot.members.alice).toMatchObject({ + alive: true, + providerId: 'anthropic', + pid: 333, + pidSource: 'agent_process_table', + rssBytes: 456_000_000, + runtimeModel: 'claude-sonnet-4-6', + }); + }); + it('prefers the newest matching agent pid when multiple processes match the same teammate', async () => { const svc = new TeamProvisioningService(); (svc as any).configReader = { @@ -748,19 +846,20 @@ describe('TeamProvisioningService', () => { cancelRequested: false, spawnContext: null, }); - (svc as any).readUnixProcessTableRows = vi.fn(() => [ + vi.mocked(listRuntimeProcessesForCurrentTmuxPlatform).mockResolvedValueOnce([ { pid: 222, + ppid: 1, command: '/Users/belief/.bun/bin/bun cli.js --agent-id alice@nice-team --agent-name alice --team-name nice-team --model gpt-5.2', }, { pid: 333, + ppid: 1, command: '/Users/belief/.bun/bin/bun cli.js --team-name nice-team --agent-id alice@nice-team --agent-name alice --model gpt-5.2', }, ]); - vi.mocked(listTmuxPanePidsForCurrentPlatform).mockResolvedValueOnce(new Map()); vi.mocked(pidusage).mockResolvedValueOnce({ '111': createPidusageStat(111, 123_000_000), '333': createPidusageStat(333, 456_000_000), @@ -804,8 +903,8 @@ describe('TeamProvisioningService', () => { expect(snapshot.members.alice).toBeUndefined(); }); - it('keeps pure OpenCode launch members alive from confirmed launch snapshot while runtime adapter is tracked', async () => { - const teamName = 'pure-opencode-runtime-team'; + it('keeps historical bootstrap separate from current runtime liveness', async () => { + const teamName = 'pure-opencode-runtime-team-strict'; const projectPath = '/Users/test/project'; writeLaunchConfig(teamName, projectPath, 'lead-session', ['alice']); writeLaunchState(teamName, 'lead-session', { @@ -832,12 +931,102 @@ describe('TeamProvisioningService', () => { const snapshot = await svc.getTeamAgentRuntimeSnapshot(teamName); expect(snapshot.members.alice).toMatchObject({ - alive: true, + alive: false, + historicalBootstrapConfirmed: true, providerId: 'opencode', runtimeModel: 'opencode/big-pickle', }); }); + it('does not treat a reused OpenCode runtime pid as live', async () => { + const teamName = 'pure-opencode-reused-pid-team'; + const projectPath = '/Users/test/project'; + writeLaunchConfig(teamName, projectPath, 'lead-session', ['alice']); + writeLaunchState(teamName, 'lead-session', { + alice: { + providerId: 'opencode', + model: 'opencode/big-pickle', + launchState: 'runtime_pending_bootstrap', + agentToolAccepted: true, + runtimeAlive: true, + bootstrapConfirmed: false, + hardFailure: false, + hardFailureReason: undefined, + runtimePid: 333, + runtimeSessionId: 'session-alice', + }, + }); + vi.mocked(listRuntimeProcessesForCurrentTmuxPlatform).mockResolvedValueOnce([ + { pid: 333, ppid: 1, command: 'node unrelated-worker.js' }, + ]); + vi.mocked(pidusage).mockResolvedValueOnce({ + '333': createPidusageStat(333, 456_000_000), + } as any); + + const svc = new TeamProvisioningService(); + (svc as any).runtimeAdapterRunByTeam.set(teamName, { + runId: 'opencode-runtime-run', + providerId: 'opencode', + cwd: projectPath, + }); + (svc as any).aliveRunByTeam.set(teamName, 'opencode-runtime-run'); + + const snapshot = await svc.getTeamAgentRuntimeSnapshot(teamName); + + expect(snapshot.members.alice).toMatchObject({ + alive: false, + livenessKind: 'runtime_process_candidate', + pidSource: 'opencode_bridge', + runtimeDiagnostic: 'OpenCode runtime pid is alive, but process identity is unverified', + pid: 333, + providerId: 'opencode', + }); + }); + + it('does not carry stale persisted runtimeAlive through launch-state reconcile', async () => { + const teamName = 'persisted-stale-runtime-status-team'; + const projectPath = '/Users/test/project'; + const acceptedAt = new Date(Date.now() - 120_000).toISOString(); + writeLaunchConfig(teamName, projectPath, 'lead-session', ['alice']); + writeLaunchState(teamName, 'lead-session', { + alice: { + providerId: 'codex', + model: 'gpt-5.4', + launchState: 'runtime_pending_bootstrap', + agentToolAccepted: true, + runtimeAlive: true, + bootstrapConfirmed: false, + hardFailure: false, + hardFailureReason: undefined, + firstSpawnAcceptedAt: acceptedAt, + runtimePid: 333, + livenessKind: 'runtime_process', + pidSource: 'agent_process_table', + }, + }); + + const svc = new TeamProvisioningService(); + + const result = await svc.getMemberSpawnStatuses(teamName); + const persisted = JSON.parse(fs.readFileSync(getTeamLaunchStatePath(teamName), 'utf8')); + + expect(result.statuses.alice).toMatchObject({ + status: 'error', + launchState: 'failed_to_start', + runtimeAlive: false, + livenessSource: undefined, + livenessKind: 'stale_metadata', + hardFailure: true, + hardFailureReason: 'Teammate did not join within the launch grace window.', + }); + expect(result.summary).toMatchObject({ + failedCount: 1, + runtimeAlivePendingCount: 0, + }); + expect(persisted.members.alice.runtimeAlive).toBe(false); + expect(persisted.members.alice.sources?.processAlive).toBeUndefined(); + }); + it('excludes removed meta members from live runtime metadata resolution', async () => { const svc = new TeamProvisioningService(); (svc as any).configReader = { @@ -913,14 +1102,20 @@ describe('TeamProvisioningService', () => { ]), }; (svc as any).readPersistedRuntimeMembers = vi.fn(() => []); - (svc as any).findLiveProcessPidByAgentId = vi.fn( - () => - new Map([ - ['alice@signal-ops-6', 17527], - ['atlas@signal-ops-6', 17528], - ]) - ); - vi.mocked(listTmuxPanePidsForCurrentPlatform).mockResolvedValueOnce(new Map()); + vi.mocked(listRuntimeProcessesForCurrentTmuxPlatform).mockResolvedValueOnce([ + { + pid: 17527, + ppid: 1, + command: + '/Users/belief/.bun/bin/bun cli.js --agent-id alice@signal-ops-6 --agent-name alice --team-name signal-ops-6 --model gpt-5.4-mini', + }, + { + pid: 17528, + ppid: 1, + command: + '/Users/belief/.bun/bin/bun cli.js --agent-id atlas@signal-ops-6 --agent-name atlas --team-name signal-ops-6 --model gpt-5.3-codex', + }, + ]); const metadata = await (svc as any).getLiveTeamAgentRuntimeMetadata('signal-ops-6'); @@ -1102,6 +1297,9 @@ describe('TeamProvisioningService', () => { ]; (svc as any).aliveRunByTeam.set('runtime-team', 'run-1'); (svc as any).runs.set('run-1', run); + vi.mocked(listRuntimeProcessesForCurrentTmuxPlatform).mockResolvedValue([ + { pid: 333, ppid: 1, command: 'opencode runtime host' }, + ]); vi.mocked(pidusage).mockReset(); vi.mocked(pidusage).mockImplementation( async (target: number | string | Array) => { @@ -1178,6 +1376,9 @@ describe('TeamProvisioningService', () => { ), }; vi.mocked(pidusage).mockReset(); + vi.mocked(listRuntimeProcessesForCurrentTmuxPlatform).mockResolvedValue([ + { pid: 333, ppid: 1, command: 'opencode runtime host' }, + ]); vi.mocked(pidusage).mockImplementation( async (target: number | string | Array) => { if (Array.isArray(target)) { @@ -1351,6 +1552,191 @@ describe('TeamProvisioningService', () => { expect(restartMessage).toContain('Their workflow: Use the updated checklist'); }); + it('retries a failed teammate without live runtime by resetting spawn status to spawning', async () => { + const svc = new TeamProvisioningService(); + const run = createMemberSpawnRun({ + teamName: 'codex-team', + expectedMembers: ['bob'], + memberSpawnStatuses: new Map([ + [ + 'bob', + createMemberSpawnStatusEntry({ + status: 'error', + launchState: 'failed_to_start', + runtimeAlive: false, + bootstrapConfirmed: false, + hardFailure: true, + hardFailureReason: 'Teammate "bob" failed to start: spawn failed', + error: 'Teammate "bob" failed to start: spawn failed', + agentToolAccepted: false, + firstSpawnAcceptedAt: undefined, + }), + ], + ]), + }); + run.child = { pid: 111 }; + run.processKilled = false; + run.cancelRequested = false; + + const sendMessageToRun = vi.fn(async () => {}); + (svc as any).sendMessageToRun = sendMessageToRun; + (svc as any).configReader = { + getConfig: vi.fn(async () => ({ + name: 'Codex Team', + members: [{ name: 'team-lead', agentType: 'team-lead' }], + })), + }; + (svc as any).membersMetaStore = { + getMembers: vi.fn(async () => [ + { + name: 'bob', + role: 'Developer', + providerId: 'codex', + model: 'gpt-5.2', + effort: 'medium', + agentType: 'general-purpose', + }, + ]), + }; + (svc as any).readPersistedRuntimeMembers = vi.fn(() => []); + (svc as any).getLiveTeamAgentRuntimeMetadata = vi.fn(async () => new Map()); + (svc as any).aliveRunByTeam.set('codex-team', run.runId); + (svc as any).runs.set(run.runId, run); + + await svc.restartMember('codex-team', 'bob'); + + expect(run.memberSpawnStatuses.get('bob')).toMatchObject({ + status: 'spawning', + launchState: 'starting', + runtimeAlive: false, + bootstrapConfirmed: false, + hardFailure: false, + hardFailureReason: undefined, + error: undefined, + agentToolAccepted: false, + }); + expect(run.pendingMemberRestarts.has('bob')).toBe(true); + expect(sendMessageToRun).toHaveBeenCalledTimes(1); + expect(sendMessageToRun).toHaveBeenCalledWith( + run, + expect.stringContaining('Teammate "bob" with role "Developer" was restarted from the UI.') + ); + }); + + it('skips a failed teammate for the current launch without marking it alive', async () => { + const svc = new TeamProvisioningService(); + const run = createMemberSpawnRun({ + teamName: 'codex-team', + expectedMembers: ['bob'], + memberSpawnStatuses: new Map([ + [ + 'bob', + createMemberSpawnStatusEntry({ + status: 'error', + launchState: 'failed_to_start', + runtimeAlive: false, + bootstrapConfirmed: false, + hardFailure: true, + hardFailureReason: 'Teammate "bob" failed to start: spawn failed', + error: 'Teammate "bob" failed to start: spawn failed', + agentToolAccepted: false, + firstSpawnAcceptedAt: undefined, + }), + ], + ]), + }); + run.child = { pid: 111 }; + run.processKilled = false; + run.cancelRequested = false; + run.isLaunch = true; + + const sendMessageToRun = vi.fn(async () => {}); + (svc as any).sendMessageToRun = sendMessageToRun; + (svc as any).configReader = { + getConfig: vi.fn(async () => ({ + name: 'Codex Team', + members: [{ name: 'team-lead', agentType: 'team-lead' }], + })), + }; + (svc as any).membersMetaStore = { + getMembers: vi.fn(async () => [ + { + name: 'bob', + role: 'Developer', + providerId: 'codex', + model: 'gpt-5.2', + effort: 'medium', + agentType: 'general-purpose', + }, + ]), + }; + (svc as any).aliveRunByTeam.set('codex-team', run.runId); + (svc as any).runs.set(run.runId, run); + + await svc.skipMemberForLaunch('codex-team', 'bob'); + + expect(run.memberSpawnStatuses.get('bob')).toMatchObject({ + status: 'skipped', + launchState: 'skipped_for_launch', + skippedForLaunch: true, + runtimeAlive: false, + bootstrapConfirmed: false, + hardFailure: false, + hardFailureReason: undefined, + error: undefined, + agentToolAccepted: false, + }); + expect(run.pendingMemberRestarts.has('bob')).toBe(false); + expect(sendMessageToRun).toHaveBeenCalledWith( + run, + expect.stringContaining('Teammate "bob" was skipped for this launch') + ); + }); + + it('rejects skipping a failed teammate while a retry is already in progress', async () => { + const svc = new TeamProvisioningService(); + const run = createMemberSpawnRun({ + teamName: 'codex-team', + expectedMembers: ['bob'], + memberSpawnStatuses: new Map([ + [ + 'bob', + createMemberSpawnStatusEntry({ + status: 'error', + launchState: 'failed_to_start', + hardFailure: true, + hardFailureReason: 'spawn failed', + error: 'spawn failed', + }), + ], + ]), + }); + run.child = { pid: 111 }; + run.processKilled = false; + run.cancelRequested = false; + run.pendingMemberRestarts.set('bob', { + requestedAt: new Date().toISOString(), + desired: { name: 'bob', role: 'Developer' }, + }); + + (svc as any).configReader = { + getConfig: vi.fn(async () => ({ + name: 'Codex Team', + members: [ + { name: 'team-lead', agentType: 'team-lead' }, + { name: 'bob', role: 'Developer' }, + ], + })), + }; + (svc as any).membersMetaStore = { getMembers: vi.fn(async () => []) }; + (svc as any).aliveRunByTeam.set('codex-team', run.runId); + (svc as any).runs.set(run.runId, run); + + await expect(svc.skipMemberForLaunch('codex-team', 'bob')).rejects.toThrow( + 'already in progress' + ); + }); + it('does not let removed base-member metadata override a suffixed teammate during restart', async () => { const svc = new TeamProvisioningService(); const run = createMemberSpawnRun({ @@ -2200,6 +2586,7 @@ describe('TeamProvisioningService', () => { pendingCount: 1, failedCount: 0, runtimeAlivePendingCount: 1, + runtimeProcessPendingCount: 1, }, { version: 2, @@ -2276,6 +2663,37 @@ describe('TeamProvisioningService', () => { ).toBe('Finishing launch — 1 teammate awaiting permission approval'); }); + it('counts registered-only liveness as no-runtime pending in launch summaries', () => { + const svc = new TeamProvisioningService(); + const run = createMemberSpawnRun({ + expectedMembers: ['alice'], + memberSpawnStatuses: new Map([ + [ + 'alice', + createMemberSpawnStatusEntry({ + status: 'waiting', + launchState: 'runtime_pending_bootstrap', + agentToolAccepted: true, + runtimeAlive: false, + bootstrapConfirmed: false, + livenessKind: 'registered_only', + runtimeDiagnostic: 'registered runtime metadata without live process', + }), + ], + ]), + }); + + const launchSummary = (svc as any).getMemberLaunchSummary(run); + + expect(launchSummary).toMatchObject({ + pendingCount: 1, + noRuntimePendingCount: 1, + }); + expect( + (svc as any).buildPendingBootstrapStatusMessage('Finishing launch', run, launchSummary) + ).toContain('1 no runtime found'); + }); + it('trusts persisted snapshot permission state for pure teams when live run statuses are absent', () => { const svc = new TeamProvisioningService(); const run = createMemberSpawnRun({ @@ -2292,6 +2710,7 @@ describe('TeamProvisioningService', () => { pendingCount: 1, failedCount: 0, runtimeAlivePendingCount: 1, + runtimeProcessPendingCount: 1, }, { version: 2, @@ -2345,6 +2764,7 @@ describe('TeamProvisioningService', () => { pendingCount: 1, failedCount: 0, runtimeAlivePendingCount: 1, + runtimeProcessPendingCount: 1, }, { version: 2, @@ -2365,6 +2785,7 @@ describe('TeamProvisioningService', () => { runtimeAlive: true, bootstrapConfirmed: false, hardFailure: false, + livenessKind: 'runtime_process', lastEvaluatedAt: '2026-04-22T12:00:00.000Z', }, }, @@ -2373,6 +2794,7 @@ describe('TeamProvisioningService', () => { pendingCount: 1, failedCount: 0, runtimeAlivePendingCount: 1, + runtimeProcessPendingCount: 1, }, teamLaunchState: 'partial_pending', } @@ -2382,6 +2804,24 @@ describe('TeamProvisioningService', () => { expect(message).not.toContain('/0'); }); + it('does not use legacy runtimeAlivePendingCount as online launch copy evidence', () => { + const svc = new TeamProvisioningService(); + const run = createMemberSpawnRun({ + teamName: 'pure-team', + expectedMembers: ['alice'], + memberSpawnStatuses: new Map(), + }); + + const message = (svc as any).buildAggregatePendingLaunchMessage('Finishing launch', run, { + confirmedCount: 0, + pendingCount: 1, + failedCount: 0, + runtimeAlivePendingCount: 1, + }); + + expect(message).toBe('Finishing launch — teammates are still starting'); + }); + it('uses the union of persisted expected members and persisted member entries for pending launch copy', () => { const svc = new TeamProvisioningService(); const run = createMemberSpawnRun({ @@ -3143,6 +3583,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] | undefined + )?.[1] as { members?: Record } | undefined; + const diagnostics = writtenSnapshot?.members?.bob?.diagnostics ?? []; + expect(diagnostics).toEqual( + expect.arrayContaining([ + 'existing diagnostic', + 'native heartbeat', + 'runtime pid: 4321', + 'runtime process command: opencode runtime --token [redacted] --safe ok', + 'runtime version: 1.2.3', + 'runtime host pid: 987', + 'runtime cwd: /tmp/project', + 'OpenCode runtime heartbeat accepted', + ]) + ); + expect(diagnostics.join('\n')).not.toContain('super-secret'); + }); + it('preserves richer persisted expectedMembers when OpenCode runtime liveness updates a stale snapshot', async () => { const svc = new TeamProvisioningService(); const previousSnapshot = { @@ -4293,6 +4809,9 @@ describe('TeamProvisioningService', () => { const restartPromise = expect(svc.restartMember('process-team', 'forge')).rejects.toThrow( `Restart for teammate "forge" is still waiting for the previous process to exit (${process.pid}).` ); + await vi.waitFor(() => { + expect(vi.mocked(killProcessByPid)).toHaveBeenCalledWith(process.pid); + }); await vi.advanceTimersByTimeAsync(1_500); await restartPromise; @@ -4340,9 +4859,14 @@ describe('TeamProvisioningService', () => { backendType: 'process', }, ]); - (svc as any).findLiveProcessPidByAgentId = vi.fn( - () => new Map([['forge@process-team', process.pid]]) - ); + vi.mocked(listRuntimeProcessesForCurrentTmuxPlatform).mockResolvedValueOnce([ + { + pid: process.pid, + ppid: 1, + command: + '/Users/belief/.bun/bin/bun cli.js --team-name process-team --agent-id forge@process-team --agent-name forge --model gpt-5.4', + }, + ]); (svc as any).liveTeamAgentRuntimeMetadataCache.set('process-team', { expiresAtMs: Date.now() + 60_000, metadata: new Map([ @@ -4362,6 +4886,9 @@ describe('TeamProvisioningService', () => { const restartPromise = expect(svc.restartMember('process-team', 'forge')).rejects.toThrow( `Restart for teammate "forge" is still waiting for the previous process to exit (${process.pid}).` ); + await vi.waitFor(() => { + expect(vi.mocked(killProcessByPid)).toHaveBeenCalledWith(process.pid); + }); await vi.advanceTimersByTimeAsync(1_500); await restartPromise; @@ -4404,15 +4931,23 @@ describe('TeamProvisioningService', () => { ]), }; (svc as any).readPersistedRuntimeMembers = vi.fn(() => []); - (svc as any).findLiveProcessPidByAgentId = vi.fn( - () => new Map([['forge@process-team', process.pid]]) - ); + vi.mocked(listRuntimeProcessesForCurrentTmuxPlatform).mockResolvedValueOnce([ + { + pid: process.pid, + ppid: 1, + command: + '/Users/belief/.bun/bin/bun cli.js --team-name process-team --agent-id forge@process-team --agent-name forge --model gpt-5.4', + }, + ]); (svc as any).aliveRunByTeam.set('process-team', run.runId); (svc as any).runs.set(run.runId, run); const restartPromise = expect(svc.restartMember('process-team', 'forge')).rejects.toThrow( `Restart for teammate "forge" is still waiting for the previous process to exit (${process.pid}).` ); + await vi.waitFor(() => { + expect(vi.mocked(killProcessByPid)).toHaveBeenCalledWith(process.pid); + }); await vi.advanceTimersByTimeAsync(1_500); await restartPromise; @@ -4581,6 +5116,107 @@ describe('TeamProvisioningService', () => { }); expect(run.pendingMemberRestarts.has('bob')).toBe(false); }); + + it('does not let stale runtimeAlive bypass launch timeout when live metadata is weak', async () => { + const svc = new TeamProvisioningService(); + const run = createMemberSpawnRun({ + teamName: 'codex-team', + expectedMembers: ['bob'], + memberSpawnStatuses: new Map([ + [ + 'bob', + createMemberSpawnStatusEntry({ + status: 'online', + launchState: 'runtime_pending_bootstrap', + agentToolAccepted: true, + runtimeAlive: true, + livenessSource: 'process', + bootstrapConfirmed: false, + firstSpawnAcceptedAt: new Date(Date.now() - 120_000).toISOString(), + }), + ], + ]), + }); + (svc as any).refreshMemberSpawnStatusesFromLeadInbox = vi.fn(async () => {}); + (svc as any).maybeAuditMemberSpawnStatuses = vi.fn(async () => {}); + (svc as any).getLiveTeamAgentRuntimeMetadata = vi.fn( + async () => + new Map([ + [ + 'bob', + { + alive: false, + livenessKind: 'shell_only', + runtimeDiagnostic: 'tmux pane foreground command is zsh', + runtimeDiagnosticSeverity: 'warning', + }, + ], + ]) + ); + + await (svc as any).reevaluateMemberLaunchStatus(run, 'bob'); + + expect(run.memberSpawnStatuses.get('bob')).toMatchObject({ + status: 'error', + launchState: 'failed_to_start', + runtimeAlive: false, + livenessSource: undefined, + bootstrapConfirmed: false, + livenessKind: 'shell_only', + runtimeDiagnostic: 'tmux pane foreground command is zsh', + error: 'tmux pane foreground command is zsh', + }); + }); + + it('keeps verified runtime pending with a warning after the bootstrap stall window', async () => { + const svc = new TeamProvisioningService(); + const run = createMemberSpawnRun({ + teamName: 'codex-team', + expectedMembers: ['bob'], + memberSpawnStatuses: new Map([ + [ + 'bob', + createMemberSpawnStatusEntry({ + status: 'waiting', + launchState: 'runtime_pending_bootstrap', + agentToolAccepted: true, + runtimeAlive: false, + bootstrapConfirmed: false, + firstSpawnAcceptedAt: new Date(Date.now() - 6 * 60_000).toISOString(), + }), + ], + ]), + }); + (svc as any).refreshMemberSpawnStatusesFromLeadInbox = vi.fn(async () => {}); + (svc as any).maybeAuditMemberSpawnStatuses = vi.fn(async () => {}); + (svc as any).getLiveTeamAgentRuntimeMetadata = vi.fn( + async () => + new Map([ + [ + 'bob', + { + alive: true, + livenessKind: 'runtime_process', + runtimeDiagnostic: 'verified runtime process detected', + }, + ], + ]) + ); + + await (svc as any).reevaluateMemberLaunchStatus(run, 'bob'); + + expect(run.memberSpawnStatuses.get('bob')).toMatchObject({ + status: 'online', + launchState: 'runtime_pending_bootstrap', + runtimeAlive: true, + bootstrapConfirmed: false, + livenessSource: 'process', + livenessKind: 'runtime_process', + runtimeDiagnostic: 'Runtime process is alive, but no bootstrap check-in after 5 min.', + runtimeDiagnosticSeverity: 'warning', + hardFailure: false, + }); + }); }); it('removes generated MCP config when createTeam spawn fails synchronously', async () => { @@ -5978,7 +6614,19 @@ describe('TeamProvisioningService', () => { ); const svc = new TeamProvisioningService(); - (svc as any).getLiveTeamAgentNames = vi.fn(() => new Set(['alice'])); + (svc as any).getLiveTeamAgentRuntimeMetadata = vi.fn( + async () => + new Map([ + [ + 'alice', + { + alive: true, + livenessKind: 'runtime_process', + runtimeDiagnostic: 'verified runtime process detected', + }, + ], + ]) + ); const result = await svc.getMemberSpawnStatuses(teamName); @@ -6323,14 +6971,12 @@ describe('TeamProvisioningService', () => { ); const svc = new TeamProvisioningService(); - (svc as any).getLiveTeamAgentNames = vi.fn(() => new Set(['jack'])); const result = await svc.getMemberSpawnStatuses(teamName); expect(result.statuses.jack).toMatchObject({ status: 'error', launchState: 'failed_to_start', - runtimeAlive: true, }); expect(result.statuses.jack?.error).toContain('requested model is not available'); expect(result.statuses.jack?.hardFailureReason).toContain('requested model is not available'); @@ -6674,7 +7320,7 @@ describe('TeamProvisioningService', () => { expect(run.memberSpawnStatuses.get('alice')).toBe(existingEntry); }); - it('treats duplicate_skipped already_running as process-confirmed online', () => { + it('keeps duplicate_skipped already_running pending without strong evidence', () => { const run = createMemberSpawnRun(); run.activeToolCalls.set('tool-agent-1', { memberName: 'alice', @@ -6702,10 +7348,9 @@ describe('TeamProvisioningService', () => { ); expect(run.memberSpawnStatuses.get('alice')).toMatchObject({ - status: 'online', + status: 'waiting', launchState: 'runtime_pending_bootstrap', - runtimeAlive: true, - livenessSource: 'process', + runtimeAlive: false, hardFailure: false, }); }); @@ -6854,6 +7499,7 @@ describe('TeamProvisioningService', () => { { alive: true, model: 'gpt-5.2', + livenessKind: 'runtime_process', }, ], ]) @@ -6891,6 +7537,7 @@ describe('TeamProvisioningService', () => { { alive: true, model: 'gpt-5.2', + livenessKind: 'runtime_process', }, ], ]) @@ -6918,6 +7565,131 @@ describe('TeamProvisioningService', () => { }); }); + it('downgrades stale process liveness to pending when live metadata is weak', async () => { + const svc = new TeamProvisioningService(); + (svc as any).getLiveTeamAgentRuntimeMetadata = vi.fn( + async () => + new Map([ + [ + 'bob', + { + alive: false, + livenessKind: 'runtime_process_candidate', + runtimeDiagnostic: + 'OpenCode runtime pid is alive, but process identity is unverified', + runtimeDiagnosticSeverity: 'warning', + }, + ], + ]) + ); + + const result = await (svc as any).attachLiveRuntimeMetadataToStatuses('beacon-desk-4', { + bob: createMemberSpawnStatusEntry({ + status: 'online', + launchState: 'runtime_pending_bootstrap', + agentToolAccepted: true, + runtimeAlive: true, + livenessSource: 'process', + bootstrapConfirmed: false, + hardFailure: false, + }), + }); + + expect(result.bob).toMatchObject({ + status: 'waiting', + launchState: 'runtime_pending_bootstrap', + runtimeAlive: false, + livenessSource: undefined, + livenessKind: 'runtime_process_candidate', + runtimeDiagnostic: 'OpenCode runtime pid is alive, but process identity is unverified', + runtimeDiagnosticSeverity: 'warning', + }); + }); + + it('keeps process table diagnostics visible when live metadata has no primary diagnostic', async () => { + const svc = new TeamProvisioningService(); + (svc as any).getLiveTeamAgentRuntimeMetadata = vi.fn( + async () => + new Map([ + [ + 'bob', + { + alive: false, + livenessKind: 'not_found', + runtimeDiagnosticSeverity: 'warning', + diagnostics: ['process table is unavailable'], + }, + ], + ]) + ); + + const result = await (svc as any).attachLiveRuntimeMetadataToStatuses('beacon-desk-4', { + bob: createMemberSpawnStatusEntry({ + status: 'waiting', + launchState: 'runtime_pending_bootstrap', + agentToolAccepted: true, + runtimeAlive: false, + bootstrapConfirmed: false, + hardFailure: false, + }), + }); + + expect(result.bob).toMatchObject({ + launchState: 'runtime_pending_bootstrap', + runtimeAlive: false, + livenessKind: 'not_found', + runtimeDiagnostic: 'process table unavailable', + runtimeDiagnosticSeverity: 'warning', + }); + }); + + it('classifies process table unavailable launch diagnostics with natural wording', () => { + const svc = new TeamProvisioningService(); + const onProgress = vi.fn(); + const run = createMemberSpawnRun({ + expectedMembers: ['bob'], + memberSpawnStatuses: new Map([ + [ + 'bob', + createMemberSpawnStatusEntry({ + status: 'waiting', + launchState: 'runtime_pending_bootstrap', + livenessKind: 'shell_only', + runtimeDiagnostic: 'tmux pane foreground command is zsh; process table is unavailable', + }), + ], + ]), + }); + run.isLaunch = true; + run.progress = { + runId: run.runId, + teamName: run.teamName, + status: 'running', + updatedAt: '2026-04-22T12:00:00.000Z', + }; + run.onProgress = onProgress; + + (svc as any).setMemberSpawnStatus(run, 'bob', 'online', undefined, 'process'); + + expect(run.progress.launchDiagnostics).toEqual( + expect.arrayContaining([ + expect.objectContaining({ + memberName: 'bob', + code: 'process_table_unavailable', + severity: 'warning', + detail: 'tmux pane foreground command is zsh; process table is unavailable', + }), + ]) + ); + expect(onProgress).toHaveBeenCalledWith( + expect.objectContaining({ + launchDiagnostics: expect.arrayContaining([ + expect.objectContaining({ code: 'process_table_unavailable' }), + ]), + }) + ); + }); + it('does not clear an explicit restart failure just because the old runtime is still alive', async () => { const svc = new TeamProvisioningService(); (svc as any).getLiveTeamAgentRuntimeMetadata = vi.fn( @@ -7028,7 +7800,6 @@ describe('TeamProvisioningService', () => { expect(result.bob).toMatchObject({ status: 'error', launchState: 'failed_to_start', - runtimeAlive: true, hardFailure: true, hardFailureReason: 'Teammate did not join within the launch grace window.', error: 'Teammate did not join within the launch grace window.', @@ -7036,6 +7807,117 @@ describe('TeamProvisioningService', () => { }); }); + it('does not resurrect a skipped teammate when live runtime metadata is strong', async () => { + const svc = new TeamProvisioningService(); + (svc as any).getLiveTeamAgentRuntimeMetadata = vi.fn( + async () => + new Map([ + [ + 'bob', + { + alive: true, + livenessKind: 'runtime_process', + pid: 123, + providerId: 'codex', + }, + ], + ]) + ); + + const result = await (svc as any).attachLiveRuntimeMetadataToStatuses('codex-team', { + bob: createMemberSpawnStatusEntry({ + status: 'skipped', + launchState: 'skipped_for_launch', + skippedForLaunch: true, + runtimeAlive: false, + bootstrapConfirmed: false, + hardFailure: false, + agentToolAccepted: false, + skipReason: 'Skipped by user after launch failure: spawn failed', + }), + }); + + expect(result.bob).toMatchObject({ + status: 'skipped', + launchState: 'skipped_for_launch', + skippedForLaunch: true, + runtimeAlive: false, + bootstrapConfirmed: false, + hardFailure: false, + error: undefined, + livenessSource: undefined, + }); + }); + + it('does not resurrect a skipped teammate during spawn status audit', async () => { + const run = createMemberSpawnRun({ + expectedMembers: ['bob'], + memberSpawnStatuses: new Map([ + [ + 'bob', + createMemberSpawnStatusEntry({ + status: 'skipped', + launchState: 'skipped_for_launch', + skippedForLaunch: true, + skipReason: 'Skipped by user after launch failure: spawn failed', + agentToolAccepted: false, + runtimeAlive: false, + bootstrapConfirmed: false, + firstSpawnAcceptedAt: undefined, + }), + ], + ]), + }); + const svc = new TeamProvisioningService(); + (svc as any).getRegisteredTeamMemberNames = vi.fn(async () => new Set(['bob'])); + (svc as any).getLiveTeamAgentNames = vi.fn(async () => new Set(['bob'])); + + await (svc as any).auditMemberSpawnStatuses(run); + + expect(run.memberSpawnStatuses.get('bob')).toMatchObject({ + status: 'skipped', + launchState: 'skipped_for_launch', + skippedForLaunch: true, + runtimeAlive: false, + bootstrapConfirmed: false, + hardFailure: false, + }); + }); + + it('does not convert a skipped teammate to failed during final missing-member reconciliation', async () => { + const run = createMemberSpawnRun({ + expectedMembers: ['bob'], + memberSpawnStatuses: new Map([ + [ + 'bob', + createMemberSpawnStatusEntry({ + status: 'skipped', + launchState: 'skipped_for_launch', + skippedForLaunch: true, + skipReason: 'Skipped by user after launch failure: spawn failed', + agentToolAccepted: false, + runtimeAlive: false, + bootstrapConfirmed: false, + firstSpawnAcceptedAt: undefined, + }), + ], + ]), + }); + const svc = new TeamProvisioningService(); + (svc as any).getRegisteredTeamMemberNames = vi.fn(async () => new Set()); + + await (svc as any).finalizeMissingRegisteredMembersAsFailed(run); + + expect(run.memberSpawnStatuses.get('bob')).toMatchObject({ + status: 'skipped', + launchState: 'skipped_for_launch', + skippedForLaunch: true, + runtimeAlive: false, + bootstrapConfirmed: false, + hardFailure: false, + }); + }); + it('does not downgrade an already-online teammate when waiting is reported later', () => { const run = createMemberSpawnRun({ memberSpawnStatuses: new Map([ @@ -7247,7 +8129,7 @@ describe('TeamProvisioningService', () => { }); }); - it('treats suffixed live runtime names as alive during persisted launch reconcile', async () => { + it('keeps suffixed weak runtime metadata pending during persisted launch reconcile', async () => { const teamName = 'suffixed-live-runtime-team'; const leadSessionId = 'lead-session'; writeLaunchConfig(teamName, '/Users/test/proj', leadSessionId, ['alice']); @@ -7264,14 +8146,26 @@ describe('TeamProvisioningService', () => { }); const svc = new TeamProvisioningService(); - (svc as any).getLiveTeamAgentNames = vi.fn(async () => new Set(['alice-2'])); + (svc as any).getLiveTeamAgentRuntimeMetadata = vi.fn( + async () => + new Map([ + [ + 'alice-2', + { + alive: false, + livenessKind: 'registered_only', + runtimeDiagnostic: 'registered runtime metadata without live process', + }, + ], + ]) + ); const result = await svc.getMemberSpawnStatuses(teamName); expect(result.statuses.alice).toMatchObject({ - status: 'online', + status: 'waiting', launchState: 'runtime_pending_bootstrap', - runtimeAlive: true, + runtimeAlive: false, }); }); @@ -7330,7 +8224,7 @@ describe('TeamProvisioningService', () => { 2 ) ); - (svc as any).getLiveTeamAgentNames = vi.fn(async () => new Set()); + (svc as any).getLiveTeamAgentRuntimeMetadata = vi.fn(async () => new Map()); const result = await (svc as any).reconcilePersistedLaunchState(teamName); diff --git a/test/main/services/team/TeamProvisioningServicePrepare.test.ts b/test/main/services/team/TeamProvisioningServicePrepare.test.ts index 2c88a49a..8628a372 100644 --- a/test/main/services/team/TeamProvisioningServicePrepare.test.ts +++ b/test/main/services/team/TeamProvisioningServicePrepare.test.ts @@ -508,11 +508,9 @@ describe('TeamProvisioningService prepare/auth behavior', () => { return { ok: false as const, providerId: 'opencode' as const, - reason: 'e2e_missing', + reason: 'model_unavailable', retryable: false, - diagnostics: [ - 'OpenCode production E2E evidence artifact has no entry for selected model opencode/nemotron-3-super-free', - ], + diagnostics: ['Selected model opencode/nemotron-3-super-free is not available'], warnings: [], }; } @@ -564,7 +562,7 @@ describe('TeamProvisioningService prepare/auth behavior', () => { 'Selected model opencode/minimax-m2.5-free verified for launch.' ); expect(result.message).toBe( - 'Selected model opencode/nemotron-3-super-free is unavailable. OpenCode production E2E evidence artifact has no entry for selected model opencode/nemotron-3-super-free' + 'Selected model opencode/nemotron-3-super-free is unavailable. Selected model opencode/nemotron-3-super-free is not available' ); }); diff --git a/test/main/services/team/TeamProvisioningServicePrompts.test.ts b/test/main/services/team/TeamProvisioningServicePrompts.test.ts index 66fe3c1e..20607fb2 100644 --- a/test/main/services/team/TeamProvisioningServicePrompts.test.ts +++ b/test/main/services/team/TeamProvisioningServicePrompts.test.ts @@ -613,6 +613,8 @@ describe('TeamProvisioningService prompt content (solo mode discipline)', () => expect(prompt).toContain( 'Do NOT ask the user or the lead to send you a task ID, task description, or "next task" right after bootstrap.' ); + expect(prompt).toContain('retry tool search at most once'); + expect(prompt).toContain('Do NOT keep searching for member_briefing'); }); it('launchTeam hydration prompt includes task-comment handling guidance by default', async () => { diff --git a/test/main/services/team/TeamRuntimeLivenessResolver.test.ts b/test/main/services/team/TeamRuntimeLivenessResolver.test.ts new file mode 100644 index 00000000..adaf4956 --- /dev/null +++ b/test/main/services/team/TeamRuntimeLivenessResolver.test.ts @@ -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'); + }); +}); diff --git a/test/main/services/team/TeamRuntimeMemory.safe-e2e.test.ts b/test/main/services/team/TeamRuntimeMemory.safe-e2e.test.ts new file mode 100644 index 00000000..1e2b06ff --- /dev/null +++ b/test/main/services/team/TeamRuntimeMemory.safe-e2e.test.ts @@ -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 { + 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 { + 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 { + 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 { + 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 { + return new Promise((resolve) => setTimeout(resolve, ms)); +} diff --git a/test/main/services/team/cliFlavor.test.ts b/test/main/services/team/cliFlavor.test.ts index 3dd05538..9500a8a9 100644 --- a/test/main/services/team/cliFlavor.test.ts +++ b/test/main/services/team/cliFlavor.test.ts @@ -1,14 +1,6 @@ // @vitest-environment node import { afterEach, describe, expect, it, vi } from 'vitest'; -const getConfigMock = vi.fn(); - -vi.mock('@main/services/infrastructure/ConfigManager', () => ({ - configManager: { - getConfig: () => getConfigMock(), - }, -})); - describe('cliFlavor', () => { afterEach(() => { delete process.env.CLAUDE_TEAM_CLI_FLAVOR; @@ -16,37 +8,20 @@ describe('cliFlavor', () => { vi.clearAllMocks(); }); - it('uses multimodel runtime by default when config enables it', async () => { - getConfigMock.mockReturnValue({ - general: { - multimodelEnabled: true, - }, - }); - + it('uses multimodel runtime by default', async () => { const { getConfiguredCliFlavor } = await import('@main/services/team/cliFlavor'); expect(getConfiguredCliFlavor()).toBe('agent_teams_orchestrator'); }); - it('uses claude runtime when multimodel is disabled in config', async () => { - getConfigMock.mockReturnValue({ - general: { - multimodelEnabled: false, - }, - }); - + it('ignores the legacy persisted multimodel flag', async () => { const { getConfiguredCliFlavor } = await import('@main/services/team/cliFlavor'); - expect(getConfiguredCliFlavor()).toBe('claude'); + expect(getConfiguredCliFlavor()).toBe('agent_teams_orchestrator'); }); - it('lets env override the persisted config', async () => { + it('lets env override the default runtime', async () => { process.env.CLAUDE_TEAM_CLI_FLAVOR = 'claude'; - getConfigMock.mockReturnValue({ - general: { - multimodelEnabled: true, - }, - }); const { getConfiguredCliFlavor } = await import('@main/services/team/cliFlavor'); diff --git a/test/main/services/team/progressPayload.test.ts b/test/main/services/team/progressPayload.test.ts index 8265d24e..3befc313 100644 --- a/test/main/services/team/progressPayload.test.ts +++ b/test/main/services/team/progressPayload.test.ts @@ -3,6 +3,7 @@ import { describe, expect, it } from 'vitest'; import { PROGRESS_LOG_TAIL_LINES, PROGRESS_OUTPUT_TAIL_PARTS, + boundLaunchDiagnostics, buildProgressAssistantOutput, buildProgressLogsTail, } from '../../../../src/main/services/team/progressPayload'; @@ -75,3 +76,32 @@ describe('buildProgressAssistantOutput', () => { expect(result!.split('\n\n')).toHaveLength(PROGRESS_OUTPUT_TAIL_PARTS); }); }); + +describe('boundLaunchDiagnostics', () => { + it('redacts secret CLI flags and caps diagnostic payload size', () => { + const longDetail = `node runtime --token super-secret ${'x'.repeat(800)}`; + const result = boundLaunchDiagnostics([ + { + id: 'bob:tmux_shell_only', + memberName: 'bob', + severity: 'warning', + code: 'tmux_shell_only', + label: 'bob - shell only --api-key abc123', + detail: longDetail, + observedAt: '2026-04-24T12:00:00.000Z', + }, + ]); + + expect(result).toBeDefined(); + expect(result).toHaveLength(1); + const first = result?.[0]; + expect(first).toBeDefined(); + if (!first) { + throw new Error('Expected one bounded launch diagnostic'); + } + expect(first.label).toContain('--api-key [redacted]'); + expect(first.detail).toContain('--token [redacted]'); + expect(first.detail).not.toContain('super-secret'); + expect(first.detail?.length).toBeLessThanOrEqual(500); + }); +}); diff --git a/test/main/utils/electronUserDataMigration.test.ts b/test/main/utils/electronUserDataMigration.test.ts index 14648d1e..d7e4cb84 100644 --- a/test/main/utils/electronUserDataMigration.test.ts +++ b/test/main/utils/electronUserDataMigration.test.ts @@ -99,7 +99,6 @@ describe('electron userData migration', () => { ['mcp-server/1.3.0/package.json', '{"type":"module"}'], ['opencode-bridge/command-ledger.json', '{"commands":[]}'], ['opencode-bridge/command-leases.json', '{"leases":[]}'], - ['opencode-bridge/production-e2e-evidence.json', '{"ok":true}'], ['logs/claude-cli-auth-diag.ndjson', '{"event":"auth"}\n'], ['Local Storage/leveldb/000003.log', 'renderer localStorage bytes'], ['future-feature/state.json', '{"kept":true}'], diff --git a/test/main/utils/pathDecoder.test.ts b/test/main/utils/pathDecoder.test.ts index 894e4aed..48706558 100644 --- a/test/main/utils/pathDecoder.test.ts +++ b/test/main/utils/pathDecoder.test.ts @@ -52,7 +52,7 @@ describe('pathDecoder', () => { }); it('should encode a Windows-style absolute path', () => { - expect(encodePath('C:\\Users\\username\\projectname')).toBe('-C:-Users-username-projectname'); + expect(encodePath('C:\\Users\\username\\projectname')).toBe('C--Users-username-projectname'); }); it('should handle empty string', () => { @@ -177,6 +177,10 @@ describe('pathDecoder', () => { }); it('should return true for valid Windows-style encoded path', () => { + expect(isValidEncodedPath('C--Users-username-projectname')).toBe(true); + }); + + it('should return true for old colon Windows-style encoded path', () => { expect(isValidEncodedPath('-C:-Users-username-projectname')).toBe(true); }); diff --git a/test/renderer/api/httpClient.teamRuntimeFallback.test.ts b/test/renderer/api/httpClient.teamRuntimeFallback.test.ts new file mode 100644 index 00000000..0290cb32 --- /dev/null +++ b/test/renderer/api/httpClient.teamRuntimeFallback.test.ts @@ -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: {}, + }); + }); +}); diff --git a/test/renderer/components/cli/CliStatusVisibility.test.ts b/test/renderer/components/cli/CliStatusVisibility.test.ts index e83f1b47..e950f3f0 100644 --- a/test/renderer/components/cli/CliStatusVisibility.test.ts +++ b/test/renderer/components/cli/CliStatusVisibility.test.ts @@ -325,7 +325,7 @@ describe('CLI status visibility during completed install state', () => { window.localStorage.clear(); }); - it('keeps the Multimodel toggle visible and enabled on the dashboard while login is still required', async () => { + it('shows multimodel status without exposing the legacy runtime toggle', async () => { vi.stubGlobal('IS_REACT_ACT_ENVIRONMENT', true); const host = document.createElement('div'); document.body.appendChild(host); @@ -340,8 +340,7 @@ describe('CLI status visibility during completed install state', () => { expect(host.textContent).toContain('Login'); const toggle = host.querySelector('[data-testid="multimodel-toggle"]'); - expect(toggle).not.toBeNull(); - expect(toggle?.hasAttribute('disabled')).toBe(false); + expect(toggle).toBeNull(); await act(async () => { root.unmount(); diff --git a/test/renderer/components/team/ProvisioningProgressBlock.test.tsx b/test/renderer/components/team/ProvisioningProgressBlock.test.tsx index 5d0a1f61..aa72b645 100644 --- a/test/renderer/components/team/ProvisioningProgressBlock.test.tsx +++ b/test/renderer/components/team/ProvisioningProgressBlock.test.tsx @@ -3,13 +3,8 @@ import { createRoot } from 'react-dom/client'; import { afterEach, describe, expect, it, vi } from 'vitest'; vi.mock('@renderer/components/ui/button', () => ({ - Button: ({ - children, - onClick, - }: { - children: React.ReactNode; - onClick?: () => void; - }) => React.createElement('button', { type: 'button', onClick }, children), + Button: ({ children, onClick }: { children: React.ReactNode; onClick?: () => void }) => + React.createElement('button', { type: 'button', onClick }, children), })); vi.mock('@renderer/components/chat/viewers/MarkdownViewer', () => ({ @@ -77,4 +72,117 @@ describe('ProvisioningProgressBlock', () => { await Promise.resolve(); }); }); + + it('renders bounded launch diagnostics without opening CLI logs', async () => { + vi.stubGlobal('IS_REACT_ACT_ENVIRONMENT', true); + const host = document.createElement('div'); + document.body.appendChild(host); + const root = createRoot(host); + + await act(async () => { + root.render( + React.createElement(ProvisioningProgressBlock, { + title: 'Launching team', + currentStepIndex: 2, + loading: true, + defaultLiveOutputOpen: false, + cliLogsTail: 'tail line', + launchDiagnostics: [ + { + id: 'bob:tmux_shell_only', + memberName: 'bob', + severity: 'warning', + code: 'tmux_shell_only', + label: 'bob - shell only', + detail: 'tmux pane foreground command is zsh', + observedAt: '2026-04-24T12:00:00.000Z', + }, + { + id: 'tom:runtime_not_found', + memberName: 'tom', + severity: 'warning', + code: 'runtime_not_found', + label: 'tom - no runtime found', + detail: 'registered runtime metadata without live process', + observedAt: '2026-04-24T12:00:01.000Z', + }, + { + id: 'jack:process_table_unavailable', + memberName: 'jack', + severity: 'warning', + code: 'process_table_unavailable', + label: 'jack - process table unavailable', + detail: 'runtime pid could not be verified because process table is unavailable', + observedAt: '2026-04-24T12:00:02.000Z', + }, + ], + }) + ); + await Promise.resolve(); + }); + + expect(host.textContent).toContain('Diagnostics'); + expect(host.textContent).not.toContain('logs:tail line'); + + const button = Array.from(host.querySelectorAll('button')).find((candidate) => + candidate.textContent?.includes('Diagnostics') + ); + expect(button).toBeTruthy(); + + await act(async () => { + button?.dispatchEvent(new MouseEvent('click', { bubbles: true })); + await Promise.resolve(); + }); + + expect(host.textContent).toContain('bob - shell only'); + expect(host.textContent).toContain('tmux pane foreground command is zsh'); + expect(host.textContent).toContain('tom - no runtime found'); + expect(host.textContent).toContain('registered runtime metadata without live process'); + expect(host.textContent).toContain('jack - process table unavailable'); + expect(host.textContent).toContain( + 'runtime pid could not be verified because process table is unavailable' + ); + + await act(async () => { + root.unmount(); + await Promise.resolve(); + }); + }); + + it('hides launch diagnostics when all entries are informational', async () => { + vi.stubGlobal('IS_REACT_ACT_ENVIRONMENT', true); + const host = document.createElement('div'); + document.body.appendChild(host); + const root = createRoot(host); + + await act(async () => { + root.render( + React.createElement(ProvisioningProgressBlock, { + title: 'Launching team', + currentStepIndex: 2, + loading: true, + defaultLiveOutputOpen: false, + launchDiagnostics: [ + { + id: 'alice:bootstrap_confirmed', + memberName: 'alice', + severity: 'info', + code: 'bootstrap_confirmed', + label: 'alice - bootstrap confirmed', + observedAt: '2026-04-24T12:00:00.000Z', + }, + ], + }) + ); + await Promise.resolve(); + }); + + expect(host.textContent).not.toContain('Diagnostics'); + expect(host.textContent).not.toContain('alice - bootstrap confirmed'); + + await act(async () => { + root.unmount(); + await Promise.resolve(); + }); + }); }); diff --git a/test/renderer/components/team/TeamModelSelectorDisabledState.test.ts b/test/renderer/components/team/TeamModelSelectorDisabledState.test.ts index 3de2f5e4..d0edde5c 100644 --- a/test/renderer/components/team/TeamModelSelectorDisabledState.test.ts +++ b/test/renderer/components/team/TeamModelSelectorDisabledState.test.ts @@ -746,7 +746,7 @@ describe('TeamModelSelector disabled Codex models', () => { supported: true, authenticated: true, statusMessage: 'OpenCode team launch is gated', - detailMessage: 'OpenCode production E2E evidence is missing', + detailMessage: 'OpenCode runtime store needs recovery', capabilities: { teamLaunch: false }, models: [], }, @@ -773,7 +773,7 @@ describe('TeamModelSelector disabled Codex models', () => { ); expect(openCodeButton?.hasAttribute('disabled')).toBe(true); expect(openCodeButton?.getAttribute('title')).toContain( - 'OpenCode production E2E evidence is missing' + 'OpenCode runtime store needs recovery' ); expect(openCodeButton?.textContent).toContain('Gate'); diff --git a/test/renderer/components/team/TeamProvisioningBanner.test.ts b/test/renderer/components/team/TeamProvisioningBanner.test.ts index f3fc17d3..a9af3989 100644 --- a/test/renderer/components/team/TeamProvisioningBanner.test.ts +++ b/test/renderer/components/team/TeamProvisioningBanner.test.ts @@ -167,7 +167,10 @@ describe('TeamProvisioningBanner launch-step alignment', () => { cliLogsTail: '', assistantOutput: '', }; - storeState.memberSpawnSnapshotsByTeam['northstar-core'] = undefined as unknown as Record; + storeState.memberSpawnSnapshotsByTeam['northstar-core'] = undefined as unknown as Record< + string, + unknown + >; storeState.memberSpawnStatusesByTeam['northstar-core'] = { alice: { status: 'waiting', launchState: 'starting' }, bob: { status: 'waiting', launchState: 'starting' }, @@ -281,6 +284,7 @@ describe('TeamProvisioningBanner launch-step alignment', () => { pendingCount: 3, failedCount: 0, runtimeAlivePendingCount: 3, + runtimeProcessPendingCount: 3, }, source: 'merged', }; diff --git a/test/renderer/components/team/dialogs/ProvisioningProviderStatusList.test.ts b/test/renderer/components/team/dialogs/ProvisioningProviderStatusList.test.ts index 6fe64e72..5f82f2cd 100644 --- a/test/renderer/components/team/dialogs/ProvisioningProviderStatusList.test.ts +++ b/test/renderer/components/team/dialogs/ProvisioningProviderStatusList.test.ts @@ -327,16 +327,13 @@ describe('ProvisioningProviderStatusList', () => { { providerId: 'opencode', status: 'failed', - details: [ - 'nemotron-3-super-free - unavailable - OpenCode production E2E evidence artifact has no entry for selected model opencode/nemotron-3-super-free', - ], + details: ['nemotron-3-super-free - unavailable - selected model is not available'], }, ], }) ).toEqual({ state: 'failed', - message: - 'nemotron-3-super-free - unavailable - OpenCode production E2E evidence artifact has no entry for selected model opencode/nemotron-3-super-free', + message: 'nemotron-3-super-free - unavailable - selected model is not available', }); }); diff --git a/test/renderer/components/team/members/MemberCard.test.ts b/test/renderer/components/team/members/MemberCard.test.ts index 3ce27e9b..4e964b65 100644 --- a/test/renderer/components/team/members/MemberCard.test.ts +++ b/test/renderer/components/team/members/MemberCard.test.ts @@ -2,7 +2,7 @@ import React, { act } from 'react'; import { createRoot } from 'react-dom/client'; import { afterEach, describe, expect, it, vi } from 'vitest'; -import type { ResolvedTeamMember, TeamTaskWithKanban } from '@shared/types'; +import type { MemberSpawnStatusEntry, ResolvedTeamMember, TeamTaskWithKanban } from '@shared/types'; vi.mock('@renderer/components/ui/badge', () => ({ Badge: ({ @@ -56,6 +56,33 @@ const currentTask: TeamTaskWithKanban = { status: 'in_progress', } as unknown as TeamTaskWithKanban; +const failedSpawnEntry: MemberSpawnStatusEntry = { + 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', +}; + +const skippedSpawnEntry: MemberSpawnStatusEntry = { + status: 'skipped', + launchState: 'skipped_for_launch', + runtimeAlive: false, + bootstrapConfirmed: false, + hardFailure: false, + agentToolAccepted: false, + skippedForLaunch: true, + skipReason: 'Skipped by user after launch failure: spawn failed', + skippedAt: '2026-04-24T12:01:00.000Z', + updatedAt: '2026-04-24T12:01:00.000Z', +}; + describe('MemberCard starting-state visuals', () => { afterEach(() => { document.body.innerHTML = ''; @@ -228,7 +255,7 @@ describe('MemberCard starting-state visuals', () => { }); expect(host.textContent).not.toContain('online'); - expect(host.querySelector('[aria-label="connecting"]')).not.toBeNull(); + expect(host.querySelector('[aria-label="waiting for bootstrap"]')).not.toBeNull(); await act(async () => { root.unmount(); @@ -302,7 +329,7 @@ describe('MemberCard starting-state visuals', () => { }); }); - it('shows a connecting badge while runtime bootstrap is still pending after the process comes online', async () => { + it('shows a waiting-for-bootstrap badge while runtime bootstrap is still pending after the process comes online', async () => { vi.stubGlobal('IS_REACT_ACT_ENVIRONMENT', true); const host = document.createElement('div'); document.body.appendChild(host); @@ -324,9 +351,9 @@ describe('MemberCard starting-state visuals', () => { await Promise.resolve(); }); - expect(host.textContent).toContain('connecting'); + expect(host.textContent).toContain('waiting for bootstrap'); expect(host.textContent).not.toContain('ready'); - expect(host.querySelector('[aria-label="connecting"]')).not.toBeNull(); + expect(host.querySelector('[aria-label="waiting for bootstrap"]')).not.toBeNull(); await act(async () => { root.unmount(); @@ -430,4 +457,461 @@ describe('MemberCard starting-state visuals', () => { await Promise.resolve(); }); }); + + it('labels shared OpenCode host memory instead of member-owned runtime memory', async () => { + vi.stubGlobal('IS_REACT_ACT_ENVIRONMENT', true); + const host = document.createElement('div'); + document.body.appendChild(host); + const root = createRoot(host); + + await act(async () => { + root.render( + React.createElement(MemberCard, { + member, + memberColor: 'blue', + runtimeSummary: 'minimax · via OpenCode · 183.9 MB', + runtimeEntry: { + memberName: 'alice', + alive: true, + restartable: false, + providerId: 'opencode', + pid: 333, + pidSource: 'opencode_bridge', + rssBytes: 183.9 * 1024 * 1024, + updatedAt: '2026-04-24T12:00:00.000Z', + }, + isTeamAlive: true, + isTeamProvisioning: false, + }) + ); + await Promise.resolve(); + }); + + expect(host.querySelector('[title="RSS source: shared OpenCode host"]')).not.toBeNull(); + + await act(async () => { + root.unmount(); + await Promise.resolve(); + }); + }); + + it('copies bounded launch diagnostics only for launch errors', async () => { + vi.stubGlobal('IS_REACT_ACT_ENVIRONMENT', true); + const writeText = vi.fn().mockResolvedValue(undefined); + Object.defineProperty(navigator, 'clipboard', { + configurable: true, + value: { writeText }, + }); + const host = document.createElement('div'); + document.body.appendChild(host); + const root = createRoot(host); + + await act(async () => { + root.render( + React.createElement(MemberCard, { + member, + memberColor: 'blue', + runtimeRunId: 'run-42', + isTeamAlive: true, + isTeamProvisioning: false, + spawnStatus: 'waiting', + spawnLaunchState: 'runtime_pending_bootstrap', + spawnRuntimeAlive: false, + spawnEntry: { + status: 'waiting', + launchState: 'runtime_pending_bootstrap', + runtimeAlive: false, + bootstrapConfirmed: false, + hardFailure: false, + agentToolAccepted: true, + livenessKind: 'shell_only', + runtimeDiagnostic: 'tmux pane foreground command is zsh', + runtimeDiagnosticSeverity: 'warning', + updatedAt: '2026-04-24T12:00:00.000Z', + }, + runtimeEntry: { + memberName: 'alice', + alive: false, + restartable: true, + pid: 26676, + pidSource: 'tmux_pane', + paneCurrentCommand: 'zsh', + processCommand: 'node runtime --token super-secret', + updatedAt: '2026-04-24T12:00:01.000Z', + }, + }) + ); + await Promise.resolve(); + }); + + expect(host.querySelector('[aria-label="Copy diagnostics"]')).toBeNull(); + + await act(async () => { + root.render( + React.createElement(MemberCard, { + member, + memberColor: 'blue', + runtimeRunId: 'run-42', + isTeamAlive: true, + isTeamProvisioning: false, + spawnStatus: 'error', + spawnLaunchState: 'failed_to_start', + spawnRuntimeAlive: false, + spawnError: 'spawn failed', + spawnEntry: { + status: 'error', + launchState: 'failed_to_start', + runtimeAlive: false, + bootstrapConfirmed: false, + hardFailure: true, + hardFailureReason: 'spawn failed', + agentToolAccepted: false, + livenessKind: 'not_found', + runtimeDiagnostic: 'spawn failed', + runtimeDiagnosticSeverity: 'error', + updatedAt: '2026-04-24T12:00:00.000Z', + }, + runtimeEntry: { + memberName: 'alice', + alive: false, + restartable: true, + pid: 26676, + pidSource: 'tmux_pane', + paneCurrentCommand: 'zsh', + processCommand: 'node runtime --token super-secret', + updatedAt: '2026-04-24T12:00:01.000Z', + }, + }) + ); + await Promise.resolve(); + }); + + const button = host.querySelector('[aria-label="Copy diagnostics"]') as HTMLButtonElement; + expect(button).not.toBeNull(); + + await act(async () => { + button.click(); + await Promise.resolve(); + }); + + expect(writeText).toHaveBeenCalledTimes(1); + const payload = JSON.parse(writeText.mock.calls[0][0] as string) as { + runId?: string; + livenessKind?: string; + processCommand?: string; + }; + expect(payload.runId).toBe('run-42'); + expect(payload.livenessKind).toBe('not_found'); + expect(payload.processCommand).toContain('--token [redacted]'); + + await act(async () => { + root.unmount(); + await Promise.resolve(); + }); + }); + + it('renders retry for failed teammate launches', 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', + isTeamAlive: true, + isTeamProvisioning: false, + spawnStatus: 'error', + spawnLaunchState: 'failed_to_start', + spawnRuntimeAlive: false, + spawnError: 'spawn failed', + spawnEntry: failedSpawnEntry, + onRestartMember: vi.fn(), + }) + ); + await Promise.resolve(); + }); + + expect(host.querySelector('[aria-label="Retry teammate"]')).not.toBeNull(); + + await act(async () => { + root.unmount(); + await Promise.resolve(); + }); + }); + + it('renders skip for failed teammate launches', 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', + isTeamAlive: true, + isTeamProvisioning: false, + spawnStatus: 'error', + spawnLaunchState: 'failed_to_start', + spawnRuntimeAlive: false, + spawnError: 'spawn failed', + spawnEntry: failedSpawnEntry, + onSkipMemberForLaunch: vi.fn(), + }) + ); + await Promise.resolve(); + }); + + expect(host.querySelector('[aria-label="Skip for this launch"]')).not.toBeNull(); + + await act(async () => { + root.unmount(); + await Promise.resolve(); + }); + }); + + it('retries failed teammate launches without opening the member row', async () => { + vi.stubGlobal('IS_REACT_ACT_ENVIRONMENT', true); + const host = document.createElement('div'); + document.body.appendChild(host); + const root = createRoot(host); + const onClick = vi.fn(); + let resolveRetry!: () => void; + const retryPromise = new Promise((resolve) => { + resolveRetry = resolve; + }); + const onRestartMember = vi.fn(() => retryPromise); + + await act(async () => { + root.render( + React.createElement(MemberCard, { + member, + memberColor: 'blue', + isTeamAlive: true, + isTeamProvisioning: false, + spawnStatus: 'error', + spawnLaunchState: 'failed_to_start', + spawnRuntimeAlive: false, + spawnError: 'spawn failed', + spawnEntry: failedSpawnEntry, + onClick, + onRestartMember, + }) + ); + await Promise.resolve(); + }); + + const button = host.querySelector('[aria-label="Retry teammate"]') as HTMLButtonElement; + expect(button).not.toBeNull(); + + await act(async () => { + button.click(); + await Promise.resolve(); + }); + + expect(onRestartMember).toHaveBeenCalledWith('alice'); + expect(onClick).not.toHaveBeenCalled(); + expect(host.querySelector('[aria-label="Retrying teammate"]')).not.toBeNull(); + + await act(async () => { + resolveRetry(); + await retryPromise; + await Promise.resolve(); + }); + + expect(host.querySelector('[aria-label="Retry teammate"]')).not.toBeNull(); + + await act(async () => { + root.unmount(); + await Promise.resolve(); + }); + }); + + it('skips failed teammate launches without opening the member row', async () => { + vi.stubGlobal('IS_REACT_ACT_ENVIRONMENT', true); + const host = document.createElement('div'); + document.body.appendChild(host); + const root = createRoot(host); + const onClick = vi.fn(); + let resolveSkip!: () => void; + const skipPromise = new Promise((resolve) => { + resolveSkip = resolve; + }); + const onSkipMemberForLaunch = vi.fn(() => skipPromise); + + await act(async () => { + root.render( + React.createElement(MemberCard, { + member, + memberColor: 'blue', + isTeamAlive: true, + isTeamProvisioning: false, + spawnStatus: 'error', + spawnLaunchState: 'failed_to_start', + spawnRuntimeAlive: false, + spawnError: 'spawn failed', + spawnEntry: failedSpawnEntry, + onClick, + onSkipMemberForLaunch, + }) + ); + await Promise.resolve(); + }); + + const button = host.querySelector('[aria-label="Skip for this launch"]') as HTMLButtonElement; + expect(button).not.toBeNull(); + + await act(async () => { + button.click(); + await Promise.resolve(); + }); + + expect(onSkipMemberForLaunch).toHaveBeenCalledWith('alice'); + expect(onClick).not.toHaveBeenCalled(); + expect(host.querySelector('[aria-label="Skipping teammate"]')).not.toBeNull(); + + await act(async () => { + resolveSkip(); + await skipPromise; + await Promise.resolve(); + }); + + expect(host.querySelector('[aria-label="Skip for this launch"]')).not.toBeNull(); + + await act(async () => { + root.unmount(); + await Promise.resolve(); + }); + }); + + it('keeps retry available and exposes retry errors after rejection', async () => { + vi.stubGlobal('IS_REACT_ACT_ENVIRONMENT', true); + const host = document.createElement('div'); + document.body.appendChild(host); + const root = createRoot(host); + const onRestartMember = vi.fn(async () => { + throw new Error('restart failed'); + }); + + await act(async () => { + root.render( + React.createElement(MemberCard, { + member, + memberColor: 'blue', + isTeamAlive: true, + isTeamProvisioning: false, + spawnStatus: 'error', + spawnLaunchState: 'failed_to_start', + spawnRuntimeAlive: false, + spawnError: 'spawn failed', + spawnEntry: failedSpawnEntry, + onRestartMember, + }) + ); + await Promise.resolve(); + }); + + const button = host.querySelector('[aria-label="Retry teammate"]') as HTMLButtonElement; + expect(button).not.toBeNull(); + + await act(async () => { + button.click(); + await Promise.resolve(); + await Promise.resolve(); + }); + + expect(onRestartMember).toHaveBeenCalledWith('alice'); + expect(host.querySelector('[aria-label="Retry teammate"]')).not.toBeNull(); + expect(host.textContent).toContain('restart failed'); + + await act(async () => { + root.unmount(); + await Promise.resolve(); + }); + }); + + it('keeps skip available and exposes skip errors after rejection', async () => { + vi.stubGlobal('IS_REACT_ACT_ENVIRONMENT', true); + const host = document.createElement('div'); + document.body.appendChild(host); + const root = createRoot(host); + const onSkipMemberForLaunch = vi.fn(async () => { + throw new Error('skip failed'); + }); + + await act(async () => { + root.render( + React.createElement(MemberCard, { + member, + memberColor: 'blue', + isTeamAlive: true, + isTeamProvisioning: false, + spawnStatus: 'error', + spawnLaunchState: 'failed_to_start', + spawnRuntimeAlive: false, + spawnError: 'spawn failed', + spawnEntry: failedSpawnEntry, + onSkipMemberForLaunch, + }) + ); + await Promise.resolve(); + }); + + const button = host.querySelector('[aria-label="Skip for this launch"]') as HTMLButtonElement; + expect(button).not.toBeNull(); + + await act(async () => { + button.click(); + await Promise.resolve(); + await Promise.resolve(); + }); + + expect(onSkipMemberForLaunch).toHaveBeenCalledWith('alice'); + expect(host.querySelector('[aria-label="Skip for this launch"]')).not.toBeNull(); + expect(host.textContent).toContain('skip failed'); + + await act(async () => { + root.unmount(); + await Promise.resolve(); + }); + }); + + it('shows skipped teammates as skipped and keeps retry available', 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', + isTeamAlive: true, + isTeamProvisioning: false, + spawnStatus: 'skipped', + spawnLaunchState: 'skipped_for_launch', + spawnRuntimeAlive: false, + spawnEntry: skippedSpawnEntry, + onRestartMember: vi.fn(), + onSkipMemberForLaunch: vi.fn(), + }) + ); + await Promise.resolve(); + }); + + expect(host.textContent).toContain('skipped'); + expect(host.textContent).toContain('Skipped by user after launch failure'); + expect(host.querySelector('[aria-label="Retry teammate"]')).not.toBeNull(); + expect(host.querySelector('[aria-label="Skip for this launch"]')).toBeNull(); + + await act(async () => { + root.unmount(); + await Promise.resolve(); + }); + }); }); diff --git a/test/renderer/components/team/members/MemberDetailDialog.test.ts b/test/renderer/components/team/members/MemberDetailDialog.test.ts index cfc8d56b..21241edf 100644 --- a/test/renderer/components/team/members/MemberDetailDialog.test.ts +++ b/test/renderer/components/team/members/MemberDetailDialog.test.ts @@ -15,13 +15,7 @@ vi.mock('@renderer/hooks/useMemberStats', () => ({ })); vi.mock('@renderer/components/ui/button', () => ({ - Button: ({ - children, - onClick, - }: { - children: React.ReactNode; - onClick?: () => void; - }) => + Button: ({ children, onClick }: { children: React.ReactNode; onClick?: () => void }) => React.createElement( 'button', { @@ -33,7 +27,8 @@ vi.mock('@renderer/components/ui/button', () => ({ })); vi.mock('@renderer/components/ui/dialog', () => ({ - Dialog: ({ children }: { children: React.ReactNode }) => React.createElement('div', null, children), + Dialog: ({ children }: { children: React.ReactNode }) => + React.createElement('div', null, children), DialogContent: ({ children }: { children: React.ReactNode }) => React.createElement('div', null, children), DialogFooter: ({ children }: { children: React.ReactNode }) => @@ -42,6 +37,15 @@ vi.mock('@renderer/components/ui/dialog', () => ({ React.createElement('div', null, children), })); +vi.mock('@renderer/components/ui/tooltip', () => ({ + Tooltip: ({ children }: { children: React.ReactNode }) => + React.createElement(React.Fragment, null, children), + TooltipTrigger: ({ children }: { children: React.ReactNode }) => + React.createElement(React.Fragment, null, children), + TooltipContent: ({ children }: { children: React.ReactNode }) => + React.createElement('div', null, children), +})); + vi.mock('@renderer/components/ui/tabs', () => { let currentValue = ''; let currentOnValueChange: ((value: string) => void) | null = null; @@ -60,14 +64,9 @@ vi.mock('@renderer/components/ui/tabs', () => { currentOnValueChange = onValueChange ?? null; return React.createElement('div', { 'data-tabs-value': value }, children); }, - TabsList: ({ children }: { children: React.ReactNode }) => React.createElement('div', null, children), - TabsTrigger: ({ - children, - value, - }: { - children: React.ReactNode; - value: string; - }) => + TabsList: ({ children }: { children: React.ReactNode }) => + React.createElement('div', null, children), + TabsTrigger: ({ children, value }: { children: React.ReactNode; value: string }) => React.createElement( 'button', { @@ -77,13 +76,8 @@ vi.mock('@renderer/components/ui/tabs', () => { }, children ), - TabsContent: ({ - children, - value, - }: { - children: React.ReactNode; - value: string; - }) => (currentValue === value ? React.createElement('div', null, children) : null), + TabsContent: ({ children, value }: { children: React.ReactNode; value: string }) => + currentValue === value ? React.createElement('div', null, children) : null, }; }); @@ -93,7 +87,11 @@ vi.mock('@renderer/components/team/members/MemberDetailHeader', () => ({ vi.mock('@renderer/components/team/members/MemberDetailStats', () => ({ MemberDetailStats: ({ activityCount }: { activityCount: number }) => - React.createElement('div', { 'data-testid': 'member-detail-stats' }, `activity-count:${activityCount}`), + React.createElement( + 'div', + { 'data-testid': 'member-detail-stats' }, + `activity-count:${activityCount}` + ), })); vi.mock('@renderer/components/team/members/MemberTasksTab', () => ({ @@ -210,4 +208,132 @@ describe('MemberDetailDialog activity count', () => { await Promise.resolve(); }); }); + + it('copies launch diagnostics from the detail footer only for launch errors', async () => { + const writeText = vi.fn().mockResolvedValue(undefined); + Object.defineProperty(navigator, 'clipboard', { + configurable: true, + value: { writeText }, + }); + const member: ResolvedTeamMember = { + name: 'jack', + status: 'active', + currentTaskId: null, + taskCount: 0, + lastActiveAt: null, + messageCount: 0, + }; + const host = document.createElement('div'); + document.body.appendChild(host); + const root = createRoot(host); + + await act(async () => { + root.render( + React.createElement(MemberDetailDialog, { + open: true, + member, + teamName: 'demo-team', + runtimeRunId: 'run-42', + members: [member], + tasks: [], + spawnEntry: { + status: 'waiting', + launchState: 'runtime_pending_bootstrap', + runtimeAlive: false, + bootstrapConfirmed: false, + hardFailure: false, + agentToolAccepted: true, + livenessKind: 'runtime_process_candidate', + runtimeDiagnostic: 'runtime process candidate detected', + runtimeDiagnosticSeverity: 'warning', + updatedAt: '2026-04-24T12:00:00.000Z', + }, + runtimeEntry: { + memberName: 'jack', + alive: false, + restartable: true, + pid: 4242, + pidSource: 'tmux_child', + processCommand: 'node runtime --api-key abc123', + updatedAt: '2026-04-24T12:00:01.000Z', + }, + onClose: () => undefined, + onSendMessage: () => undefined, + onAssignTask: () => undefined, + onTaskClick: () => undefined, + }) + ); + await Promise.resolve(); + }); + + let copyButton = Array.from(host.querySelectorAll('button')).find((button) => + button.textContent?.includes('Copy diagnostics') + ); + expect(copyButton).toBeUndefined(); + + await act(async () => { + root.render( + React.createElement(MemberDetailDialog, { + open: true, + member, + teamName: 'demo-team', + runtimeRunId: 'run-42', + members: [member], + tasks: [], + spawnEntry: { + status: 'error', + launchState: 'failed_to_start', + runtimeAlive: false, + bootstrapConfirmed: false, + hardFailure: true, + hardFailureReason: 'runtime process failed', + agentToolAccepted: false, + livenessKind: 'not_found', + runtimeDiagnostic: 'runtime process failed', + runtimeDiagnosticSeverity: 'error', + updatedAt: '2026-04-24T12:00:00.000Z', + }, + runtimeEntry: { + memberName: 'jack', + alive: false, + restartable: true, + pid: 4242, + pidSource: 'tmux_child', + processCommand: 'node runtime --api-key abc123', + updatedAt: '2026-04-24T12:00:01.000Z', + }, + onClose: () => undefined, + onSendMessage: () => undefined, + onAssignTask: () => undefined, + onTaskClick: () => undefined, + }) + ); + await Promise.resolve(); + }); + + copyButton = Array.from(host.querySelectorAll('button')).find((button) => + button.textContent?.includes('Copy diagnostics') + ); + expect(copyButton).not.toBeUndefined(); + + await act(async () => { + copyButton?.click(); + await Promise.resolve(); + await Promise.resolve(); + }); + + const payload = JSON.parse(writeText.mock.calls[0][0] as string) as { + runId?: string; + livenessKind?: string; + processCommand?: string; + }; + expect(payload.runId).toBe('run-42'); + expect(payload.livenessKind).toBe('not_found'); + expect(payload.processCommand).toContain('--api-key [redacted]'); + + await act(async () => { + root.unmount(); + await Promise.resolve(); + }); + }); }); diff --git a/test/renderer/components/team/members/MemberDetailHeader.test.ts b/test/renderer/components/team/members/MemberDetailHeader.test.ts index 17c21bd5..157054ad 100644 --- a/test/renderer/components/team/members/MemberDetailHeader.test.ts +++ b/test/renderer/components/team/members/MemberDetailHeader.test.ts @@ -10,7 +10,8 @@ vi.mock('@renderer/components/ui/badge', () => ({ })); vi.mock('@renderer/components/ui/dialog', () => ({ - DialogTitle: ({ children }: { children: React.ReactNode }) => React.createElement('div', null, children), + DialogTitle: ({ children }: { children: React.ReactNode }) => + React.createElement('div', null, children), DialogDescription: ({ children }: { children: React.ReactNode }) => React.createElement('div', null, children), })); @@ -100,7 +101,7 @@ describe('MemberDetailHeader spawn-aware presence', () => { }); }); - it('shows connecting while the runtime is online but bootstrap is still pending', async () => { + it('shows waiting for bootstrap while the runtime is online but bootstrap is still pending', async () => { vi.stubGlobal('IS_REACT_ACT_ENVIRONMENT', true); const host = document.createElement('div'); document.body.appendChild(host); @@ -121,9 +122,9 @@ describe('MemberDetailHeader spawn-aware presence', () => { await Promise.resolve(); }); - expect(host.textContent).toContain('connecting'); + expect(host.textContent).toContain('waiting for bootstrap'); expect(host.textContent).not.toContain('online'); - expect(host.querySelector('[aria-label="connecting"]')).not.toBeNull(); + expect(host.querySelector('[aria-label="waiting for bootstrap"]')).not.toBeNull(); await act(async () => { root.unmount(); diff --git a/test/renderer/components/team/members/MemberHoverCard.test.ts b/test/renderer/components/team/members/MemberHoverCard.test.ts index fe06f72b..09153c73 100644 --- a/test/renderer/components/team/members/MemberHoverCard.test.ts +++ b/test/renderer/components/team/members/MemberHoverCard.test.ts @@ -45,6 +45,12 @@ const storeState = { updatedAt: string; runtimeAlive: boolean; livenessSource?: string; + livenessKind?: string; + runtimeDiagnostic?: string; + runtimeDiagnosticSeverity?: string; + error?: string; + hardFailure?: boolean; + hardFailureReason?: string; } > >, @@ -52,6 +58,13 @@ const storeState = { 'northstar-core': undefined, } as Record, leadActivityByTeam: {}, + teamAgentRuntimeByTeam: {} as Record< + string, + { + runId: string | null; + members: Record>; + } + >, openMemberProfile: vi.fn(), }; @@ -61,7 +74,11 @@ vi.mock('@renderer/store', () => ({ vi.mock('@renderer/store/slices/teamSlice', () => ({ getCurrentProvisioningProgressForTeam: () => storeState.progress, - selectResolvedMemberForTeamName: (state: typeof storeState, teamName: string, memberName: string) => + selectResolvedMemberForTeamName: ( + state: typeof storeState, + teamName: string, + memberName: string + ) => (state.selectedTeamName === teamName ? state.selectedTeamData : null)?.members.find( (candidate) => candidate.name === memberName ) ?? null, @@ -91,6 +108,15 @@ vi.mock('@renderer/components/ui/hover-card', () => ({ React.createElement('div', null, children), })); +vi.mock('@renderer/components/ui/tooltip', () => ({ + Tooltip: ({ children }: { children: React.ReactNode }) => + React.createElement(React.Fragment, null, children), + TooltipTrigger: ({ children }: { children: React.ReactNode }) => + React.createElement(React.Fragment, null, children), + TooltipContent: ({ children }: { children: React.ReactNode }) => + React.createElement('div', null, children), +})); + vi.mock('@renderer/components/team/members/CurrentTaskIndicator', () => ({ CurrentTaskIndicator: () => null, })); @@ -116,6 +142,7 @@ describe('MemberHoverCard spawn-aware presence', () => { runtimeAlive: false, }; storeState.memberSpawnSnapshotsByTeam['northstar-core'] = undefined; + storeState.teamAgentRuntimeByTeam = {}; storeState.openMemberProfile.mockReset(); }); @@ -144,7 +171,7 @@ describe('MemberHoverCard spawn-aware presence', () => { }); }); - it('shows connecting for runtime-pending members while launch is still settling', async () => { + it('shows starting for runtime-pending members while launch is still settling', async () => { vi.stubGlobal('IS_REACT_ACT_ENVIRONMENT', true); storeState.progress = { runId: 'run-1', @@ -188,9 +215,9 @@ describe('MemberHoverCard spawn-aware presence', () => { await Promise.resolve(); }); - expect(host.textContent).toContain('connecting'); + expect(host.textContent).toContain('starting'); expect(host.textContent).not.toContain('online'); - expect(host.querySelector('[aria-label="connecting"]')).not.toBeNull(); + expect(host.querySelector('[aria-label="starting"]')).not.toBeNull(); await act(async () => { root.unmount(); @@ -198,7 +225,7 @@ describe('MemberHoverCard spawn-aware presence', () => { }); }); - it('shows connecting while runtime is online but bootstrap is still pending outside launch settling', async () => { + it('shows waiting for bootstrap while runtime is online but bootstrap is still pending outside launch settling', async () => { vi.stubGlobal('IS_REACT_ACT_ENVIRONMENT', true); storeState.progress = null; storeState.memberSpawnStatusesByTeam['northstar-core'].alice = { @@ -224,9 +251,9 @@ describe('MemberHoverCard spawn-aware presence', () => { await Promise.resolve(); }); - expect(host.textContent).toContain('connecting'); + expect(host.textContent).toContain('waiting for bootstrap'); expect(host.textContent).not.toContain('online'); - expect(host.querySelector('[aria-label="connecting"]')).not.toBeNull(); + expect(host.querySelector('[aria-label="waiting for bootstrap"]')).not.toBeNull(); await act(async () => { root.unmount(); @@ -279,4 +306,96 @@ describe('MemberHoverCard spawn-aware presence', () => { await Promise.resolve(); }); }); + + it('copies launch diagnostics with the active runtime run id only for launch errors', async () => { + vi.stubGlobal('IS_REACT_ACT_ENVIRONMENT', true); + const writeText = vi.fn().mockResolvedValue(undefined); + Object.defineProperty(navigator, 'clipboard', { + configurable: true, + value: { writeText }, + }); + storeState.memberSpawnStatusesByTeam['northstar-core'].alice = { + status: 'waiting', + launchState: 'runtime_pending_bootstrap', + updatedAt: '2026-04-09T10:00:00.000Z', + runtimeAlive: false, + livenessKind: 'shell_only', + runtimeDiagnostic: 'tmux pane foreground command is zsh', + runtimeDiagnosticSeverity: 'warning', + }; + storeState.teamAgentRuntimeByTeam['northstar-core'] = { + runId: 'runtime-run-1', + members: { + alice: { + memberName: 'alice', + alive: false, + restartable: true, + livenessKind: 'shell_only', + pidSource: 'tmux_pane', + paneCurrentCommand: 'zsh', + processCommand: 'node runtime --token secret', + updatedAt: '2026-04-09T10:00:01.000Z', + }, + }, + }; + + const host = document.createElement('div'); + document.body.appendChild(host); + const root = createRoot(host); + + await act(async () => { + root.render( + React.createElement(MemberHoverCard, { + name: 'alice', + children: React.createElement('button', { type: 'button' }, 'alice'), + }) + ); + await Promise.resolve(); + }); + + expect(host.querySelector('[aria-label="Copy diagnostics"]')).toBeNull(); + + storeState.memberSpawnStatusesByTeam['northstar-core'].alice = { + status: 'error', + launchState: 'failed_to_start', + updatedAt: '2026-04-09T10:00:00.000Z', + runtimeAlive: false, + livenessKind: 'not_found', + runtimeDiagnostic: 'spawn failed', + runtimeDiagnosticSeverity: 'error', + error: 'spawn failed', + hardFailure: true, + hardFailureReason: 'spawn failed', + }; + + await act(async () => { + root.render( + React.createElement(MemberHoverCard, { + name: 'alice', + children: React.createElement('button', { type: 'button' }, 'alice'), + }) + ); + await Promise.resolve(); + }); + + const button = host.querySelector('[aria-label="Copy diagnostics"]') as HTMLButtonElement; + expect(button).not.toBeNull(); + + await act(async () => { + button.click(); + await Promise.resolve(); + }); + + const payload = JSON.parse(writeText.mock.calls[0][0] as string) as { + runId?: string; + processCommand?: string; + }; + expect(payload.runId).toBe('runtime-run-1'); + expect(payload.processCommand).toContain('--token [redacted]'); + + await act(async () => { + root.unmount(); + await Promise.resolve(); + }); + }); }); diff --git a/test/renderer/components/team/members/MemberList.test.ts b/test/renderer/components/team/members/MemberList.test.ts index 36ba0fdb..02516601 100644 --- a/test/renderer/components/team/members/MemberList.test.ts +++ b/test/renderer/components/team/members/MemberList.test.ts @@ -8,10 +8,45 @@ vi.mock('@renderer/components/team/members/MemberCard', () => ({ MemberCard: ({ member, spawnError, + spawnStatus, + spawnLaunchState, + onRestartMember, + onSkipMemberForLaunch, }: { member: ResolvedTeamMember; spawnError?: string; - }) => React.createElement('div', { 'data-testid': `member-${member.name}` }, spawnError ?? ''), + spawnStatus?: string; + spawnLaunchState?: string; + onRestartMember?: (memberName: string) => void; + onSkipMemberForLaunch?: (memberName: string) => void; + }) => + React.createElement( + 'div', + { 'data-testid': `member-${member.name}` }, + spawnError ?? '', + onRestartMember && (spawnStatus === 'error' || spawnLaunchState === 'failed_to_start') + ? React.createElement( + 'button', + { + 'data-testid': `retry-${member.name}`, + type: 'button', + onClick: () => onRestartMember(member.name), + }, + 'retry' + ) + : null, + onSkipMemberForLaunch && (spawnStatus === 'error' || spawnLaunchState === 'failed_to_start') + ? React.createElement( + 'button', + { + 'data-testid': `skip-${member.name}`, + type: 'button', + onClick: () => onSkipMemberForLaunch(member.name), + }, + 'skip' + ) + : null + ), })); import { MemberList } from '@renderer/components/team/members/MemberList'; @@ -98,4 +133,126 @@ describe('MemberList spawn-status memoization', () => { await Promise.resolve(); }); }); + + it('passes retry callbacks to failed member cards and rerenders when the callback changes', async () => { + vi.stubGlobal('IS_REACT_ACT_ENVIRONMENT', true); + const host = document.createElement('div'); + document.body.appendChild(host); + const root = createRoot(host); + const members = [member]; + const firstRestart = vi.fn(); + const secondRestart = vi.fn(); + const spawnStatuses = new Map([['bob', failedSpawnStatus('OpenCode failed')]]); + + await act(async () => { + root.render( + React.createElement(MemberList, { + members, + isTeamAlive: true, + memberSpawnStatuses: spawnStatuses, + onRestartMember: firstRestart, + }) + ); + await Promise.resolve(); + }); + + const firstRetry = host.querySelector('[data-testid="retry-bob"]') as HTMLButtonElement; + expect(firstRetry).not.toBeNull(); + + await act(async () => { + firstRetry.click(); + await Promise.resolve(); + }); + + expect(firstRestart).toHaveBeenCalledWith('bob'); + + await act(async () => { + root.render( + React.createElement(MemberList, { + members, + isTeamAlive: true, + memberSpawnStatuses: spawnStatuses, + onRestartMember: secondRestart, + }) + ); + await Promise.resolve(); + }); + + const secondRetry = host.querySelector('[data-testid="retry-bob"]') as HTMLButtonElement; + expect(secondRetry).not.toBeNull(); + + await act(async () => { + secondRetry.click(); + await Promise.resolve(); + }); + + expect(secondRestart).toHaveBeenCalledWith('bob'); + expect(firstRestart).toHaveBeenCalledTimes(1); + + await act(async () => { + root.unmount(); + await Promise.resolve(); + }); + }); + + it('passes skip callbacks to failed member cards and rerenders when the callback changes', async () => { + vi.stubGlobal('IS_REACT_ACT_ENVIRONMENT', true); + const host = document.createElement('div'); + document.body.appendChild(host); + const root = createRoot(host); + const members = [member]; + const firstSkip = vi.fn(); + const secondSkip = vi.fn(); + const spawnStatuses = new Map([['bob', failedSpawnStatus('OpenCode failed')]]); + + await act(async () => { + root.render( + React.createElement(MemberList, { + members, + isTeamAlive: true, + memberSpawnStatuses: spawnStatuses, + onSkipMemberForLaunch: firstSkip, + }) + ); + await Promise.resolve(); + }); + + const firstButton = host.querySelector('[data-testid="skip-bob"]') as HTMLButtonElement; + expect(firstButton).not.toBeNull(); + + await act(async () => { + firstButton.click(); + await Promise.resolve(); + }); + + expect(firstSkip).toHaveBeenCalledWith('bob'); + + await act(async () => { + root.render( + React.createElement(MemberList, { + members, + isTeamAlive: true, + memberSpawnStatuses: spawnStatuses, + onSkipMemberForLaunch: secondSkip, + }) + ); + await Promise.resolve(); + }); + + const secondButton = host.querySelector('[data-testid="skip-bob"]') as HTMLButtonElement; + expect(secondButton).not.toBeNull(); + + await act(async () => { + secondButton.click(); + await Promise.resolve(); + }); + + expect(secondSkip).toHaveBeenCalledWith('bob'); + expect(firstSkip).toHaveBeenCalledTimes(1); + + await act(async () => { + root.unmount(); + await Promise.resolve(); + }); + }); }); diff --git a/test/renderer/components/team/provisioningSteps.test.ts b/test/renderer/components/team/provisioningSteps.test.ts new file mode 100644 index 00000000..eae7686f --- /dev/null +++ b/test/renderer/components/team/provisioningSteps.test.ts @@ -0,0 +1,128 @@ +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); + }); + + it('counts skipped teammates separately from pending and failed launch members', () => { + const milestones = getLaunchJoinMilestonesFromMembers({ + members, + memberSpawnStatuses: { + alice: { + status: 'skipped', + launchState: 'skipped_for_launch', + skippedForLaunch: true, + runtimeAlive: false, + bootstrapConfirmed: false, + hardFailure: false, + updatedAt: '2026-04-24T12:00:00.000Z', + }, + }, + }); + + expect(milestones.skippedSpawnCount).toBe(1); + expect(milestones.failedSpawnCount).toBe(0); + expect(milestones.pendingSpawnCount).toBe(3); + }); +}); diff --git a/test/renderer/features/agent-graph/GraphNodePopover.test.ts b/test/renderer/features/agent-graph/GraphNodePopover.test.ts index e5d8ce6b..6932c9d6 100644 --- a/test/renderer/features/agent-graph/GraphNodePopover.test.ts +++ b/test/renderer/features/agent-graph/GraphNodePopover.test.ts @@ -60,13 +60,16 @@ function makeOverflowNode(): GraphNode { } describe('GraphNodePopover spawn badge labels', () => { - afterEach(() => { + afterEach(async () => { + await act(async () => { + useStore.setState({ + selectedTeamName: null, + selectedTeamData: null, + teamDataCacheByName: {}, + } as never); + await Promise.resolve(); + }); document.body.innerHTML = ''; - useStore.setState({ - selectedTeamName: null, - selectedTeamData: null, - teamDataCacheByName: {}, - } as never); vi.unstubAllGlobals(); }); @@ -138,45 +141,48 @@ describe('GraphNodePopover spawn badge labels', () => { it('reuses launch-aware presence semantics from cached team data', async () => { vi.stubGlobal('IS_REACT_ACT_ENVIRONMENT', true); - useStore.setState({ - teamDataCacheByName: { - 'northstar-core': { - teamName: 'northstar-core', - config: { name: 'Northstar', members: [], projectPath: '/repo' }, - members: [ - { - name: 'alice', - status: 'active', - currentTaskId: null, - taskCount: 0, - lastActiveAt: null, - messageCount: 0, - agentType: 'reviewer', - providerId: 'codex', - }, - ], - tasks: [], - messages: [], - kanbanState: { teamName: 'northstar-core', reviewers: [], tasks: {} }, - processes: [], - isAlive: true, - }, - }, - memberSpawnStatusesByTeam: { - 'northstar-core': { - alice: { - status: 'online', - launchState: 'runtime_pending_bootstrap', - livenessSource: 'process', - runtimeAlive: true, + await act(async () => { + useStore.setState({ + teamDataCacheByName: { + 'northstar-core': { + teamName: 'northstar-core', + config: { name: 'Northstar', members: [], projectPath: '/repo' }, + members: [ + { + name: 'alice', + status: 'active', + currentTaskId: null, + taskCount: 0, + lastActiveAt: null, + messageCount: 0, + agentType: 'reviewer', + providerId: 'codex', + }, + ], + tasks: [], + messages: [], + kanbanState: { teamName: 'northstar-core', reviewers: [], tasks: {} }, + processes: [], + isAlive: true, }, }, - }, - memberSpawnSnapshotsByTeam: {}, - currentProvisioningRunIdByTeam: {}, - provisioningRuns: {}, - leadActivityByTeam: {}, - } as never); + memberSpawnStatusesByTeam: { + 'northstar-core': { + alice: { + status: 'online', + launchState: 'runtime_pending_bootstrap', + livenessSource: 'process', + runtimeAlive: true, + }, + }, + }, + memberSpawnSnapshotsByTeam: {}, + currentProvisioningRunIdByTeam: {}, + provisioningRuns: {}, + leadActivityByTeam: {}, + } as never); + await Promise.resolve(); + }); const host = document.createElement('div'); document.body.appendChild(host); @@ -193,7 +199,7 @@ describe('GraphNodePopover spawn badge labels', () => { await Promise.resolve(); }); - expect(host.textContent).toContain('connecting'); + expect(host.textContent).toContain('waiting for bootstrap'); expect(host.textContent).not.toContain('Idle'); await act(async () => { @@ -204,48 +210,10 @@ describe('GraphNodePopover spawn badge labels', () => { it('renders overflow stack contents instead of the task card and opens task detail from the list', async () => { vi.stubGlobal('IS_REACT_ACT_ENVIRONMENT', true); - useStore.setState({ - selectedTeamName: 'northstar-core', - selectedTeamData: { - teamName: 'northstar-core', - config: { name: 'Northstar', members: [], projectPath: '/repo' }, - tasks: [ - { - id: 'task-1', - displayId: '#1', - subject: 'Tighten rollout checklist', - owner: 'alice', - reviewer: 'bob', - status: 'in_progress', - reviewState: 'review', - kanbanColumn: 'review', - }, - { - id: 'task-2', - displayId: '#2', - subject: 'Patch release notes', - owner: 'alice', - status: 'pending', - reviewState: 'none', - }, - ], - members: [], - messages: [], - kanbanState: { - teamName: 'northstar-core', - reviewers: [], - tasks: { - 'task-1': { - column: 'review', - reviewer: 'bob', - movedAt: '2026-04-12T18:00:00.000Z', - }, - }, - }, - processes: [], - }, - teamDataCacheByName: { - 'northstar-core': { + await act(async () => { + useStore.setState({ + selectedTeamName: 'northstar-core', + selectedTeamData: { teamName: 'northstar-core', config: { name: 'Northstar', members: [], projectPath: '/repo' }, tasks: [ @@ -283,8 +251,49 @@ describe('GraphNodePopover spawn badge labels', () => { }, processes: [], }, - }, - } as never); + teamDataCacheByName: { + 'northstar-core': { + teamName: 'northstar-core', + config: { name: 'Northstar', members: [], projectPath: '/repo' }, + tasks: [ + { + id: 'task-1', + displayId: '#1', + subject: 'Tighten rollout checklist', + owner: 'alice', + reviewer: 'bob', + status: 'in_progress', + reviewState: 'review', + kanbanColumn: 'review', + }, + { + id: 'task-2', + displayId: '#2', + subject: 'Patch release notes', + owner: 'alice', + status: 'pending', + reviewState: 'none', + }, + ], + members: [], + messages: [], + kanbanState: { + teamName: 'northstar-core', + reviewers: [], + tasks: { + 'task-1': { + column: 'review', + reviewer: 'bob', + movedAt: '2026-04-12T18:00:00.000Z', + }, + }, + }, + processes: [], + }, + }, + } as never); + await Promise.resolve(); + }); const onOpenTaskDetail = vi.fn(); const host = document.createElement('div'); diff --git a/test/renderer/features/agent-graph/TeamGraphAdapter.test.ts b/test/renderer/features/agent-graph/TeamGraphAdapter.test.ts index 7439b827..0422ae18 100644 --- a/test/renderer/features/agent-graph/TeamGraphAdapter.test.ts +++ b/test/renderer/features/agent-graph/TeamGraphAdapter.test.ts @@ -1136,8 +1136,8 @@ describe('TeamGraphAdapter particles', () => { ); expect(findNode(graph, 'member:my-team:alice')).toMatchObject({ - launchVisualState: 'runtime_pending', - launchStatusLabel: 'connecting', + launchVisualState: 'waiting', + launchStatusLabel: 'waiting to start', }); }); diff --git a/test/renderer/store/teamSlice.test.ts b/test/renderer/store/teamSlice.test.ts index 2d1b4d38..a4443e3d 100644 --- a/test/renderer/store/teamSlice.test.ts +++ b/test/renderer/store/teamSlice.test.ts @@ -29,6 +29,7 @@ const hoisted = vi.hoisted(() => ({ permanentlyDeleteTeam: vi.fn(), sendMessage: vi.fn(), restartMember: vi.fn(), + skipMemberForLaunch: vi.fn(), requestReview: vi.fn(), updateKanban: vi.fn(), invalidateTaskChangeSummaries: vi.fn(), @@ -53,6 +54,7 @@ vi.mock('@renderer/api', () => ({ permanentlyDeleteTeam: hoisted.permanentlyDeleteTeam, sendMessage: hoisted.sendMessage, restartMember: hoisted.restartMember, + skipMemberForLaunch: hoisted.skipMemberForLaunch, requestReview: hoisted.requestReview, updateKanban: hoisted.updateKanban, onProvisioningProgress: hoisted.onProvisioningProgress, @@ -103,9 +105,7 @@ function createSliceStore() { })); } -function createTeamSnapshot( - overrides: Record = {} -): { +function createTeamSnapshot(overrides: Record = {}): { teamName: string; config: { name: string; members?: unknown[]; projectPath?: string }; tasks: unknown[]; @@ -227,12 +227,15 @@ describe('teamSlice actions', () => { updatedAt: new Date().toISOString(), }); hoisted.getMemberSpawnStatuses.mockResolvedValue({ statuses: {}, runId: null }); - hoisted.getTeamAgentRuntime.mockResolvedValue(createRuntimeSnapshot({ runId: null, members: {} })); + hoisted.getTeamAgentRuntime.mockResolvedValue( + createRuntimeSnapshot({ runId: null, members: {} }) + ); hoisted.cancelProvisioning.mockResolvedValue(undefined); hoisted.deleteTeam.mockResolvedValue(undefined); hoisted.restoreTeam.mockResolvedValue(undefined); hoisted.permanentlyDeleteTeam.mockResolvedValue(undefined); hoisted.restartMember.mockResolvedValue(undefined); + hoisted.skipMemberForLaunch.mockResolvedValue(undefined); }); it('maps inbox verify failure to user-friendly text', async () => { @@ -326,13 +329,15 @@ describe('teamSlice actions', () => { it('commits owner slot drops in the current session while persistence is disabled', () => { const store = createSliceStore(); - store.getState().commitTeamGraphOwnerSlotDrop( - 'my-team', - 'agent-alice', - { ringIndex: 0, sectorIndex: 2 }, - 'agent-bob', - { ringIndex: 0, sectorIndex: 1 } - ); + store + .getState() + .commitTeamGraphOwnerSlotDrop( + 'my-team', + 'agent-alice', + { ringIndex: 0, sectorIndex: 2 }, + 'agent-bob', + { ringIndex: 0, sectorIndex: 1 } + ); expect(store.getState().slotAssignmentsByTeam['my-team']).toEqual({ 'agent-alice': { ringIndex: 0, sectorIndex: 2 }, @@ -467,9 +472,9 @@ describe('teamSlice actions', () => { }, }); - store.getState().ensureTeamGraphSlotAssignments('my-team', [ - { name: 'alice', agentId: 'agent-alice' }, - ]); + store + .getState() + .ensureTeamGraphSlotAssignments('my-team', [{ name: 'alice', agentId: 'agent-alice' }]); expect(store.getState().slotLayoutVersion).toBe('stable-slots-v1'); expect(store.getState().slotAssignmentsByTeam).toEqual({ @@ -491,9 +496,9 @@ describe('teamSlice actions', () => { }, }); - store.getState().ensureTeamGraphSlotAssignments('my-team', [ - { name: 'visible', agentId: 'agent-visible' }, - ]); + store + .getState() + .ensureTeamGraphSlotAssignments('my-team', [{ name: 'visible', agentId: 'agent-visible' }]); expect(store.getState().slotAssignmentsByTeam['my-team']).toEqual({ 'agent-visible': { ringIndex: 0, sectorIndex: 0 }, @@ -585,13 +590,15 @@ describe('teamSlice actions', () => { { name: 'jack', agentId: 'agent-jack' }, ]); - store.getState().commitTeamGraphOwnerSlotDrop( - 'my-team', - 'agent-alice', - { ringIndex: 0, sectorIndex: 2 }, - 'agent-jack', - { ringIndex: 0, sectorIndex: 0 } - ); + store + .getState() + .commitTeamGraphOwnerSlotDrop( + 'my-team', + 'agent-alice', + { ringIndex: 0, sectorIndex: 2 }, + 'agent-jack', + { ringIndex: 0, sectorIndex: 0 } + ); store.getState().resetTeamGraphSlotAssignmentsToDefaults('my-team'); @@ -830,13 +837,7 @@ describe('teamSlice actions', () => { }); expect( nextEntry?.canonicalMessages.map((message: { messageId?: string }) => message.messageId) - ).toEqual([ - 'msg-5', - 'msg-4', - 'msg-3', - 'msg-2', - 'msg-1', - ]); + ).toEqual(['msg-5', 'msg-4', 'msg-3', 'msg-2', 'msg-1']); expect(nextEntry?.nextCursor).toBe('cursor-tail'); expect(nextEntry?.hasMore).toBe(true); }); @@ -1038,9 +1039,9 @@ describe('teamSlice actions', () => { expect( store .getState() - .teamMessagesByName['my-team']?.canonicalMessages.map( - (message: { messageId?: string }) => message.messageId - ) + .teamMessagesByName[ + 'my-team' + ]?.canonicalMessages.map((message: { messageId?: string }) => message.messageId) ).toEqual(['msg-4', 'msg-3', 'msg-2', 'msg-1']); }); @@ -2428,6 +2429,106 @@ describe('teamSlice actions', () => { expect(store.getState().teamAgentRuntimeByTeam['my-team']).toBe(firstSnapshot); }); + it('updates runtime snapshots when liveness diagnostics change', async () => { + const store = createSliceStore(); + const snapshot = createRuntimeSnapshot(); + hoisted.getTeamAgentRuntime.mockResolvedValue(snapshot); + + await store.getState().fetchTeamAgentRuntime('my-team'); + const firstSnapshot = store.getState().teamAgentRuntimeByTeam['my-team']; + + const nextSnapshot = createRuntimeSnapshot({ + members: { + alice: { + ...snapshot.members.alice, + alive: false, + livenessKind: 'shell_only', + pidSource: 'tmux_pane', + runtimeDiagnostic: 'tmux pane foreground command is zsh', + runtimeDiagnosticSeverity: 'warning', + }, + }, + }); + hoisted.getTeamAgentRuntime.mockResolvedValue(nextSnapshot); + + await store.getState().fetchTeamAgentRuntime('my-team'); + + expect(store.getState().teamAgentRuntimeByTeam['my-team']).not.toBe(firstSnapshot); + expect(store.getState().teamAgentRuntimeByTeam['my-team']).toEqual(nextSnapshot); + }); + + it('updates runtime snapshots when copy-diagnostics details change', async () => { + const store = createSliceStore(); + const snapshot = createRuntimeSnapshot({ + members: { + alice: { + memberName: 'alice', + alive: false, + restartable: true, + backendType: 'tmux', + pid: 42, + livenessKind: 'shell_only', + pidSource: 'tmux_pane', + paneId: '%42', + panePid: 42, + paneCurrentCommand: 'zsh', + runtimeDiagnostic: 'tmux pane foreground command is zsh', + diagnostics: ['tmux pane foreground command is zsh'], + updatedAt: '2026-03-12T10:00:00.000Z', + }, + }, + }); + hoisted.getTeamAgentRuntime.mockResolvedValue(snapshot); + + await store.getState().fetchTeamAgentRuntime('my-team'); + const firstSnapshot = store.getState().teamAgentRuntimeByTeam['my-team']; + + const nextSnapshot = createRuntimeSnapshot({ + members: { + alice: { + ...snapshot.members.alice, + processCommand: 'node runtime --token [redacted]', + runtimeSessionId: 'session-alice', + diagnostics: [ + 'tmux pane foreground command is zsh', + 'no verified runtime descendant process was found', + ], + }, + }, + }); + hoisted.getTeamAgentRuntime.mockResolvedValue(nextSnapshot); + + await store.getState().fetchTeamAgentRuntime('my-team'); + + expect(store.getState().teamAgentRuntimeByTeam['my-team']).not.toBe(firstSnapshot); + expect(store.getState().teamAgentRuntimeByTeam['my-team']).toEqual(nextSnapshot); + }); + + it('updates runtime snapshots when historical bootstrap state changes', async () => { + const store = createSliceStore(); + const snapshot = createRuntimeSnapshot(); + hoisted.getTeamAgentRuntime.mockResolvedValue(snapshot); + + await store.getState().fetchTeamAgentRuntime('my-team'); + const firstSnapshot = store.getState().teamAgentRuntimeByTeam['my-team']; + + const nextSnapshot = createRuntimeSnapshot({ + members: { + alice: { + ...snapshot.members.alice, + alive: false, + historicalBootstrapConfirmed: true, + }, + }, + }); + hoisted.getTeamAgentRuntime.mockResolvedValue(nextSnapshot); + + await store.getState().fetchTeamAgentRuntime('my-team'); + + expect(store.getState().teamAgentRuntimeByTeam['my-team']).not.toBe(firstSnapshot); + expect(store.getState().teamAgentRuntimeByTeam['my-team']).toEqual(nextSnapshot); + }); + it('restartMember refreshes spawn statuses and runtime snapshot', async () => { const store = createSliceStore(); hoisted.getMemberSpawnStatuses.mockResolvedValue({ @@ -2457,12 +2558,65 @@ describe('teamSlice actions', () => { }); hoisted.restartMember.mockRejectedValueOnce(new Error('restart failed')); - await expect(store.getState().restartMember('my-team', 'alice')).rejects.toThrow('restart failed'); + await expect(store.getState().restartMember('my-team', 'alice')).rejects.toThrow( + 'restart failed' + ); expect(refreshSpawnStatuses).toHaveBeenCalledWith('my-team'); expect(refreshRuntimeSnapshot).toHaveBeenCalledWith('my-team'); }); + it('skipMemberForLaunch refreshes spawn statuses, runtime snapshot, and team list', async () => { + const store = createSliceStore(); + const refreshTeams = vi.fn(async () => undefined); + store.setState({ fetchTeams: refreshTeams }); + hoisted.getMemberSpawnStatuses.mockResolvedValue({ + statuses: { + alice: createMemberSpawnStatus({ + status: 'skipped', + launchState: 'skipped_for_launch', + skippedForLaunch: true, + }), + }, + runId: 'runtime-run', + }); + hoisted.getTeamAgentRuntime.mockResolvedValue(createRuntimeSnapshot()); + + await store.getState().skipMemberForLaunch('my-team', 'alice'); + + expect(hoisted.skipMemberForLaunch).toHaveBeenCalledWith('my-team', 'alice'); + expect(store.getState().memberSpawnStatusesByTeam['my-team']).toEqual({ + alice: expect.objectContaining({ + status: 'skipped', + launchState: 'skipped_for_launch', + skippedForLaunch: true, + }), + }); + expect(store.getState().teamAgentRuntimeByTeam['my-team']).toEqual(createRuntimeSnapshot()); + expect(refreshTeams).toHaveBeenCalled(); + }); + + it('skipMemberForLaunch refreshes launch data even when skip fails', async () => { + const store = createSliceStore(); + const refreshSpawnStatuses = vi.fn(async (_teamName: string) => undefined); + const refreshRuntimeSnapshot = vi.fn(async (_teamName: string) => undefined); + const refreshTeams = vi.fn(async () => undefined); + store.setState({ + fetchMemberSpawnStatuses: refreshSpawnStatuses, + fetchTeamAgentRuntime: refreshRuntimeSnapshot, + fetchTeams: refreshTeams, + }); + hoisted.skipMemberForLaunch.mockRejectedValueOnce(new Error('skip failed')); + + await expect(store.getState().skipMemberForLaunch('my-team', 'alice')).rejects.toThrow( + 'skip failed' + ); + + expect(refreshSpawnStatuses).toHaveBeenCalledWith('my-team'); + expect(refreshRuntimeSnapshot).toHaveBeenCalledWith('my-team'); + expect(refreshTeams).toHaveBeenCalled(); + }); + it('clears stale runtime snapshots on delete', async () => { const store = createSliceStore(); store.setState({ @@ -2528,7 +2682,7 @@ describe('teamSlice actions', () => { expect(store.getState().selectedTeamData).toEqual(existingData); }); - it('reuses the existing selectedTeamData ref on a semantic no-op refresh', async () => { + it('reuses the existing selectedTeamData ref on a semantic no-op refresh', async () => { const store = createSliceStore(); const existingData = createTeamSnapshot({ tasks: [ @@ -2575,78 +2729,78 @@ describe('teamSlice actions', () => { expect(store.getState().selectedTeamData).toBe(existingData); expect(store.getState().teamDataCacheByName['my-team']).toBe(existingData); expect(store.getState().selectedTeamError).toBeNull(); - }); + }); - it('memoizes focused resolved member selection against unrelated member activity churn', () => { - const aliceSnapshot = { - name: 'alice', - currentTaskId: null, - taskCount: 0, - role: 'Reviewer', - }; - const bobSnapshot = { - name: 'bob', - currentTaskId: null, - taskCount: 0, - role: 'Builder', - }; - const baseState = { - selectedTeamName: 'my-team', - selectedTeamData: null, - teamDataCacheByName: { - 'my-team': createTeamSnapshot({ - members: [aliceSnapshot, bobSnapshot], - }), - }, - memberActivityMetaByTeam: { - 'my-team': { - teamName: 'my-team', - computedAt: '2026-03-12T10:00:00.000Z', - feedRevision: 'rev-1', - members: { - alice: { - memberName: 'alice', - lastAuthoredMessageAt: '2026-03-12T10:00:00.000Z', - messageCountExact: 3, - latestAuthoredMessageSignalsTermination: false, - }, - bob: { - memberName: 'bob', - lastAuthoredMessageAt: '2026-03-12T10:01:00.000Z', - messageCountExact: 1, - latestAuthoredMessageSignalsTermination: false, + it('memoizes focused resolved member selection against unrelated member activity churn', () => { + const aliceSnapshot = { + name: 'alice', + currentTaskId: null, + taskCount: 0, + role: 'Reviewer', + }; + const bobSnapshot = { + name: 'bob', + currentTaskId: null, + taskCount: 0, + role: 'Builder', + }; + const baseState = { + selectedTeamName: 'my-team', + selectedTeamData: null, + teamDataCacheByName: { + 'my-team': createTeamSnapshot({ + members: [aliceSnapshot, bobSnapshot], + }), + }, + memberActivityMetaByTeam: { + 'my-team': { + teamName: 'my-team', + computedAt: '2026-03-12T10:00:00.000Z', + feedRevision: 'rev-1', + members: { + alice: { + memberName: 'alice', + lastAuthoredMessageAt: '2026-03-12T10:00:00.000Z', + messageCountExact: 3, + latestAuthoredMessageSignalsTermination: false, + }, + bob: { + memberName: 'bob', + lastAuthoredMessageAt: '2026-03-12T10:01:00.000Z', + messageCountExact: 1, + latestAuthoredMessageSignalsTermination: false, + }, }, }, }, - }, - }; + }; - const firstAlice = selectResolvedMemberForTeamName(baseState as never, 'my-team', 'alice'); - const nextState = { - ...baseState, - memberActivityMetaByTeam: { - 'my-team': { - ...baseState.memberActivityMetaByTeam['my-team'], - computedAt: '2026-03-12T10:02:00.000Z', - feedRevision: 'rev-2', - members: { - ...baseState.memberActivityMetaByTeam['my-team'].members, - bob: { - ...baseState.memberActivityMetaByTeam['my-team'].members.bob, - messageCountExact: 2, + const firstAlice = selectResolvedMemberForTeamName(baseState as never, 'my-team', 'alice'); + const nextState = { + ...baseState, + memberActivityMetaByTeam: { + 'my-team': { + ...baseState.memberActivityMetaByTeam['my-team'], + computedAt: '2026-03-12T10:02:00.000Z', + feedRevision: 'rev-2', + members: { + ...baseState.memberActivityMetaByTeam['my-team'].members, + bob: { + ...baseState.memberActivityMetaByTeam['my-team'].members.bob, + messageCountExact: 2, + }, }, }, }, - }, - }; + }; - const secondAlice = selectResolvedMemberForTeamName(nextState as never, 'my-team', 'alice'); + const secondAlice = selectResolvedMemberForTeamName(nextState as never, 'my-team', 'alice'); - expect(firstAlice).not.toBeNull(); - expect(secondAlice).toBe(firstAlice); - }); + expect(firstAlice).not.toBeNull(); + expect(secondAlice).toBe(firstAlice); + }); - it('re-canonicalizes selectedTeamData into the cache on a no-op refresh', async () => { + it('re-canonicalizes selectedTeamData into the cache on a no-op refresh', async () => { const store = createSliceStore(); const existingData = createTeamSnapshot({ tasks: [ diff --git a/test/renderer/utils/memberHelpers.test.ts b/test/renderer/utils/memberHelpers.test.ts index 2f562510..d0506347 100644 --- a/test/renderer/utils/memberHelpers.test.ts +++ b/test/renderer/utils/memberHelpers.test.ts @@ -191,7 +191,7 @@ describe('memberHelpers spawn-aware presence', () => { }); expect(runtimePending.launchVisualState).toBe('runtime_pending'); - expect(runtimePending.launchStatusLabel).toBe('connecting'); + expect(runtimePending.launchStatusLabel).toBe('waiting for bootstrap'); expect(settling.launchVisualState).toBe('settling'); expect(settling.launchStatusLabel).toBe('joining team'); }); @@ -209,13 +209,42 @@ describe('memberHelpers spawn-aware presence', () => { isTeamProvisioning: false, }); - expect(permissionPending.presenceLabel).toBe('connecting'); + expect(permissionPending.presenceLabel).toBe('awaiting permission'); expect(permissionPending.launchVisualState).toBe('permission_pending'); expect(permissionPending.launchStatusLabel).toBe('awaiting permission'); expect(permissionPending.dotClass).toContain('bg-amber-400'); expect(permissionPending.cardClass).toContain('member-waiting-shimmer'); }); + it('surfaces strict runtime liveness diagnostics as launch labels', () => { + expect( + buildMemberLaunchPresentation({ + member, + spawnStatus: 'waiting', + spawnLaunchState: 'runtime_pending_bootstrap', + spawnLivenessSource: undefined, + spawnRuntimeAlive: false, + runtimeEntry: { + memberName: 'alice', + alive: false, + restartable: true, + livenessKind: 'shell_only', + pidSource: 'tmux_pane', + runtimeDiagnostic: 'tmux pane foreground command is zsh', + updatedAt: '2026-04-24T12:00:00.000Z', + }, + runtimeAdvisory: undefined, + isLaunchSettling: false, + isTeamAlive: true, + isTeamProvisioning: false, + }) + ).toMatchObject({ + presenceLabel: 'shell only', + launchVisualState: 'shell_only', + launchStatusLabel: 'shell only', + }); + }); + it('returns shared launch status labels without changing generic presence labels', () => { expect( buildMemberLaunchPresentation({ diff --git a/test/renderer/utils/memberLaunchDiagnostics.test.ts b/test/renderer/utils/memberLaunchDiagnostics.test.ts new file mode 100644 index 00000000..9c8e63c0 --- /dev/null +++ b/test/renderer/utils/memberLaunchDiagnostics.test.ts @@ -0,0 +1,65 @@ +import { describe, expect, it } from 'vitest'; + +import { + buildMemberLaunchDiagnosticsPayload, + formatMemberLaunchDiagnosticsPayload, + hasMemberLaunchDiagnosticsDetails, +} from '@renderer/utils/memberLaunchDiagnostics'; + +describe('member launch diagnostics', () => { + it('builds a bounded copy payload from spawn and runtime evidence', () => { + const payload = buildMemberLaunchDiagnosticsPayload({ + teamName: 'demo-team', + runId: 'run-42', + memberName: 'bob', + spawnEntry: { + status: 'waiting', + launchState: 'runtime_pending_bootstrap', + runtimeAlive: false, + bootstrapConfirmed: false, + hardFailure: false, + agentToolAccepted: true, + livenessKind: 'shell_only', + livenessSource: 'process', + runtimeDiagnostic: 'tmux pane foreground command is zsh', + runtimeDiagnosticSeverity: 'warning', + updatedAt: '2026-04-24T12:00:00.000Z', + }, + runtimeEntry: { + memberName: 'bob', + alive: false, + restartable: true, + pid: 26676, + pidSource: 'tmux_pane', + paneId: '%42', + panePid: 26676, + paneCurrentCommand: 'zsh', + processCommand: 'node runtime --token super-secret --team-name demo-team', + diagnostics: ['tmux pane foreground command is zsh', 'no runtime child found'], + updatedAt: '2026-04-24T12:00:01.000Z', + }, + }); + + expect(payload).toMatchObject({ + teamName: 'demo-team', + runId: 'run-42', + memberName: 'bob', + launchState: 'runtime_pending_bootstrap', + spawnStatus: 'waiting', + livenessKind: 'shell_only', + pid: 26676, + pidSource: 'tmux_pane', + paneCurrentCommand: 'zsh', + runtimeDiagnostic: 'tmux pane foreground command is zsh', + runtimeDiagnosticSeverity: 'warning', + }); + expect(payload.processCommand).toContain('--token [redacted]'); + expect(payload.processCommand).not.toContain('super-secret'); + expect(payload.diagnostics).toEqual([ + 'tmux pane foreground command is zsh', + 'no runtime child found', + ]); + expect(hasMemberLaunchDiagnosticsDetails(payload)).toBe(true); + expect(formatMemberLaunchDiagnosticsPayload(payload)).toContain('"livenessKind": "shell_only"'); + }); +}); diff --git a/test/renderer/utils/memberRuntimeSummary.test.ts b/test/renderer/utils/memberRuntimeSummary.test.ts index 20dad8ab..80e10ec1 100644 --- a/test/renderer/utils/memberRuntimeSummary.test.ts +++ b/test/renderer/utils/memberRuntimeSummary.test.ts @@ -1,6 +1,9 @@ import { describe, expect, it } from 'vitest'; -import { resolveMemberRuntimeSummary } from '@renderer/utils/memberRuntimeSummary'; +import { + getRuntimeMemorySourceLabel, + resolveMemberRuntimeSummary, +} from '@renderer/utils/memberRuntimeSummary'; import type { MemberSpawnStatusEntry, ResolvedTeamMember } from '@shared/types'; @@ -224,3 +227,48 @@ describe('resolveMemberRuntimeSummary', () => { ).toBe('minimax-m2.5-free · via OpenCode · 183.9 MB'); }); }); + +describe('getRuntimeMemorySourceLabel', () => { + it('explains when RSS comes from a tmux pane shell', () => { + expect( + getRuntimeMemorySourceLabel({ + memberName: 'alice', + alive: false, + restartable: true, + pid: 26676, + pidSource: 'tmux_pane', + rssBytes: 2 * 1024 * 1024, + updatedAt: '2026-04-24T12:00:00.000Z', + }) + ).toBe('RSS source: tmux pane shell'); + }); + + it('explains shared OpenCode host memory separately from member-owned runtime memory', () => { + expect( + getRuntimeMemorySourceLabel({ + 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', + }) + ).toBe('RSS source: shared OpenCode host'); + }); + + it('labels verified runtime child memory as runtime process memory', () => { + expect( + getRuntimeMemorySourceLabel({ + memberName: 'alice', + alive: true, + restartable: true, + pid: 4242, + pidSource: 'tmux_child', + rssBytes: 256 * 1024 * 1024, + updatedAt: '2026-04-24T12:00:00.000Z', + }) + ).toBe('RSS source: runtime process'); + }); +}); diff --git a/test/renderer/utils/teamLaunchSummaryCopy.test.ts b/test/renderer/utils/teamLaunchSummaryCopy.test.ts index d712425e..31b1493f 100644 --- a/test/renderer/utils/teamLaunchSummaryCopy.test.ts +++ b/test/renderer/utils/teamLaunchSummaryCopy.test.ts @@ -8,7 +8,7 @@ describe('buildPendingRuntimeSummaryCopy', () => { buildPendingRuntimeSummaryCopy({ confirmedCount: 2, expectedMemberCount: 4, - runtimeAlivePendingCount: 2, + runtimeProcessPendingCount: 2, }) ).toBe( 'Last launch is still reconciling - 2/4 teammates confirmed alive, 2 runtimes still awaiting confirmation' @@ -20,11 +20,23 @@ describe('buildPendingRuntimeSummaryCopy', () => { buildPendingRuntimeSummaryCopy({ confirmedCount: 1, expectedMemberCount: 3, - runtimeAlivePendingCount: 1, + runtimeProcessPendingCount: 1, includePeriod: true, }) ).toBe( 'Last launch is still reconciling - 1/3 teammates confirmed alive, 1 runtime still awaiting confirmation.' ); }); + + it('does not trust legacy runtimeAlivePendingCount as process evidence', () => { + expect( + buildPendingRuntimeSummaryCopy({ + confirmedCount: 0, + expectedMemberCount: 3, + runtimeAlivePendingCount: 3, + } as Parameters[0] & { + runtimeAlivePendingCount: number; + }) + ).toBe('Last launch is still reconciling'); + }); }); diff --git a/test/renderer/utils/teamProvisioningPresentation.test.ts b/test/renderer/utils/teamProvisioningPresentation.test.ts index 3d4ea4e3..0bd0c2f8 100644 --- a/test/renderer/utils/teamProvisioningPresentation.test.ts +++ b/test/renderer/utils/teamProvisioningPresentation.test.ts @@ -293,6 +293,75 @@ describe('buildTeamProvisioningPresentation', () => { expect(presentation?.compactTone).toBe('warning'); }); + it('shows skipped teammates as a continued launch instead of still joining', () => { + const presentation = buildTeamProvisioningPresentation({ + progress: { + runId: 'run-3d', + teamName: 'mixed-team', + state: 'ready', + startedAt: '2026-04-13T10:00:00.000Z', + updatedAt: '2026-04-13T10:00:08.000Z', + message: 'Launch completed', + messageSeverity: undefined, + pid: 4321, + configReady: true, + cliLogsTail: '', + assistantOutput: '', + }, + members: [ + { + name: 'team-lead', + agentType: 'team-lead', + status: 'active', + currentTaskId: null, + taskCount: 0, + lastActiveAt: null, + messageCount: 0, + }, + { + name: 'bob', + agentType: 'developer', + status: 'unknown', + currentTaskId: null, + taskCount: 0, + lastActiveAt: null, + messageCount: 0, + }, + ], + memberSpawnStatuses: { + bob: { + status: 'skipped', + launchState: 'skipped_for_launch', + updatedAt: '2026-04-13T10:00:07.000Z', + runtimeAlive: false, + bootstrapConfirmed: false, + hardFailure: false, + agentToolAccepted: false, + skippedForLaunch: true, + skipReason: 'Skipped by user after launch failure: OpenCode lane failed', + }, + }, + memberSpawnSnapshot: { + expectedMembers: ['bob'], + summary: { + confirmedCount: 0, + pendingCount: 0, + failedCount: 0, + skippedCount: 1, + runtimeAlivePendingCount: 0, + }, + }, + }); + + expect(presentation?.successMessage).toBe('Launch continued - 1/1 teammates skipped'); + expect(presentation?.panelMessage).toContain('bob skipped for this launch'); + expect(presentation?.compactTitle).toBe('Launch continued with skipped teammates'); + expect(presentation?.compactDetail).toBe('bob skipped'); + expect(presentation?.compactTone).toBe('warning'); + expect(presentation?.currentStepIndex).toBe(2); + expect(presentation?.hasMembersStillJoining).toBe(false); + }); + it('prefers live member spawn statuses over a stale persisted launch summary', () => { const presentation = buildTeamProvisioningPresentation({ progress: { @@ -687,6 +756,166 @@ describe('buildTeamProvisioningPresentation', () => { expect(presentation?.panelMessage).toBe('1 teammate awaiting permission approval'); }); + it('names teammates in pending runtime diagnostic summaries', () => { + const presentation = buildTeamProvisioningPresentation({ + progress: { + runId: 'run-named-diagnostics', + teamName: 'runtime-team', + state: 'ready', + startedAt: '2026-04-13T10:00:00.000Z', + updatedAt: '2026-04-13T10:00:08.000Z', + message: 'Launch completed', + messageSeverity: undefined, + pid: 4321, + cliLogsTail: '', + assistantOutput: '', + }, + members: [ + { + name: 'team-lead', + agentType: 'team-lead', + status: 'active', + currentTaskId: null, + taskCount: 0, + lastActiveAt: null, + messageCount: 0, + }, + { + name: 'alice', + agentType: 'developer', + status: 'unknown', + currentTaskId: null, + taskCount: 0, + lastActiveAt: null, + messageCount: 0, + }, + { + name: 'bob', + agentType: 'developer', + status: 'unknown', + currentTaskId: null, + taskCount: 0, + lastActiveAt: null, + messageCount: 0, + }, + ], + memberSpawnStatuses: {}, + memberSpawnSnapshot: { + expectedMembers: ['alice', 'bob'], + statuses: { + alice: { + status: 'waiting', + launchState: 'runtime_pending_bootstrap', + updatedAt: '2026-04-13T10:00:07.000Z', + runtimeAlive: false, + bootstrapConfirmed: false, + hardFailure: false, + agentToolAccepted: true, + livenessKind: 'not_found', + runtimeDiagnostic: 'runtime process not found', + }, + bob: { + status: 'waiting', + launchState: 'runtime_pending_bootstrap', + updatedAt: '2026-04-13T10:00:07.000Z', + runtimeAlive: false, + bootstrapConfirmed: false, + hardFailure: false, + agentToolAccepted: true, + livenessKind: 'not_found', + runtimeDiagnostic: 'runtime process not found', + }, + }, + summary: { + confirmedCount: 0, + pendingCount: 2, + failedCount: 0, + runtimeAlivePendingCount: 0, + noRuntimePendingCount: 2, + }, + }, + }); + + expect(presentation?.compactTitle).toBe('Finishing launch'); + expect(presentation?.compactDetail).toBe('No runtime found: alice, bob'); + expect(presentation?.panelMessage).toBe('No runtime found: alice, bob'); + }); + + it('names live pending diagnostics without duplicating permission-blocked teammates', () => { + const presentation = buildTeamProvisioningPresentation({ + progress: { + runId: 'run-live-diagnostics', + teamName: 'runtime-team', + state: 'ready', + startedAt: '2026-04-13T10:00:00.000Z', + updatedAt: '2026-04-13T10:00:08.000Z', + message: 'Launch completed', + messageSeverity: undefined, + pid: 4321, + cliLogsTail: '', + assistantOutput: '', + }, + members: [ + { + name: 'team-lead', + agentType: 'team-lead', + status: 'active', + currentTaskId: null, + taskCount: 0, + lastActiveAt: null, + messageCount: 0, + }, + { + name: 'alice', + agentType: 'developer', + status: 'unknown', + currentTaskId: null, + taskCount: 0, + lastActiveAt: null, + messageCount: 0, + }, + { + name: 'bob', + agentType: 'developer', + status: 'unknown', + currentTaskId: null, + taskCount: 0, + lastActiveAt: null, + messageCount: 0, + }, + ], + memberSpawnStatuses: { + alice: { + status: 'online', + launchState: 'runtime_pending_bootstrap', + updatedAt: '2026-04-13T10:00:07.000Z', + runtimeAlive: true, + bootstrapConfirmed: false, + hardFailure: false, + agentToolAccepted: true, + livenessKind: 'runtime_process', + }, + bob: { + status: 'online', + launchState: 'runtime_pending_permission', + updatedAt: '2026-04-13T10:00:07.000Z', + runtimeAlive: true, + bootstrapConfirmed: false, + hardFailure: false, + agentToolAccepted: true, + livenessKind: 'runtime_process', + pendingPermissionRequestIds: ['perm_1'], + }, + }, + memberSpawnSnapshot: undefined, + }); + + expect(presentation?.panelMessage).toBe( + 'Waiting for bootstrap: alice, Awaiting permission: bob' + ); + expect(presentation?.panelMessage).not.toContain('Waiting for bootstrap: alice, bob'); + }); + it('keeps a generic failed teammate message while launch is still active if only persisted failure counts remain', () => { const presentation = buildTeamProvisioningPresentation({ progress: { diff --git a/test/shared/utils/reviewState.test.ts b/test/shared/utils/reviewState.test.ts index e000da37..d9b5d453 100644 --- a/test/shared/utils/reviewState.test.ts +++ b/test/shared/utils/reviewState.test.ts @@ -115,6 +115,7 @@ describe('reviewState utils', () => { expect( getReviewStateFromTask({ reviewState: 'approved', + status: 'completed', historyEvents: [ { id: '1', @@ -126,4 +127,16 @@ describe('reviewState utils', () => { }) ).toBe('approved'); }); + + it('ignores stale terminal review fallback on active or deleted statuses', () => { + expect(getReviewStateFromTask({ reviewState: 'approved', status: 'pending' })).toBe('none'); + expect(getReviewStateFromTask({ reviewState: 'review', status: 'in_progress' })).toBe('none'); + expect(getReviewStateFromTask({ kanbanColumn: 'approved', status: 'deleted' })).toBe('none'); + }); + + it('keeps legacy pending needsFix as actionable fallback', () => { + expect(getReviewStateFromTask({ reviewState: 'needsFix', status: 'pending' })).toBe( + 'needsFix' + ); + }); }); diff --git a/test/shared/utils/taskChangeState.test.ts b/test/shared/utils/taskChangeState.test.ts new file mode 100644 index 00000000..18705758 --- /dev/null +++ b/test/shared/utils/taskChangeState.test.ts @@ -0,0 +1,70 @@ +import { describe, expect, it } from 'vitest'; + +import { + getTaskChangeStateBucket, + isTaskChangeSummaryCacheable, +} from '../../../src/shared/utils/taskChangeState'; + +describe('taskChangeState utils', () => { + it('falls back to persisted legacy reviewState when history has no review signal', () => { + const bucket = getTaskChangeStateBucket({ + status: 'completed', + reviewState: 'approved', + historyEvents: [ + { + id: '1', + timestamp: '2026-01-01T00:00:00Z', + type: 'task_created', + status: 'completed', + }, + ], + }); + + expect(bucket).toBe('approved'); + expect(isTaskChangeSummaryCacheable(bucket)).toBe(true); + }); + + it('falls back to the kanban overlay when history has no review signal', () => { + expect( + getTaskChangeStateBucket({ + status: 'completed', + kanbanColumn: 'review', + historyEvents: [ + { + id: '1', + timestamp: '2026-01-01T00:00:00Z', + type: 'task_created', + status: 'completed', + }, + ], + }) + ).toBe('review'); + }); + + it('keeps explicit pending reopen as active after approval', () => { + expect( + getTaskChangeStateBucket({ + status: 'pending', + reviewState: 'approved', + historyEvents: [ + { + id: '1', + timestamp: '2026-01-01T00:00:00Z', + type: 'review_approved', + from: 'review', + to: 'approved', + actor: 'alice', + }, + { + id: '2', + timestamp: '2026-01-01T00:01:00Z', + type: 'status_changed', + from: 'completed', + to: 'pending', + actor: 'alice', + }, + ], + }) + ).toBe('active'); + }); +}); diff --git a/vitest.config.ts b/vitest.config.ts index deedf623..839eefd6 100644 --- a/vitest.config.ts +++ b/vitest.config.ts @@ -7,7 +7,7 @@ export default defineConfig({ environment: 'happy-dom', testTimeout: 15000, setupFiles: ['./test/setup.ts'], - include: ['test/**/*.test.ts', 'src/**/*.test.ts', 'src/**/*.test.tsx'], + include: ['test/**/*.test.ts', 'test/**/*.test.tsx', 'src/**/*.test.ts', 'src/**/*.test.tsx'], coverage: { provider: 'v8', reporter: ['text', 'json', 'html'],