diff --git a/agent-teams-controller/src/internal/crossTeam.js b/agent-teams-controller/src/internal/crossTeam.js index e2fb3a12..1dafc09b 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(); } @@ -141,7 +130,7 @@ function findRecentDuplicate(outboxList, dedupeKey) { function sendCrossTeamMessage(context, flags) { const fromTeam = context.teamName; const toTeam = typeof flags.toTeam === 'string' ? flags.toTeam.trim() : ''; - const fromMember = typeof flags.fromMember === 'string' ? flags.fromMember.trim() : 'team-lead'; + const rawFromMember = typeof flags.fromMember === 'string' ? flags.fromMember.trim() : ''; const replyToConversationId = typeof flags.replyToConversationId === 'string' ? flags.replyToConversationId.trim() : ''; const conversationId = @@ -156,6 +145,10 @@ function sendCrossTeamMessage(context, flags) { if (!TEAM_NAME_PATTERN.test(fromTeam)) { throw new Error(`Invalid fromTeam: ${fromTeam}`); } + const sourceConfig = runtimeHelpers.readTeamConfig(context.paths); + if (!sourceConfig || sourceConfig.deletedAt) { + throw new Error(`Source team not found: ${fromTeam}`); + } if (!TEAM_NAME_PATTERN.test(toTeam)) { throw new Error(`Invalid toTeam: ${toTeam}`); } @@ -165,6 +158,11 @@ function sendCrossTeamMessage(context, flags) { if (!text || text.trim().length === 0) { throw new Error('Message text is required'); } + const fromMember = rawFromMember + ? runtimeHelpers.assertExplicitTeamMemberName(context.paths, rawFromMember, 'cross-team sender', { + allowLeadAliases: true, + }) + : runtimeHelpers.inferLeadName(context.paths); // Target context + config const targetContext = createTargetContext(context, toTeam); 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 e7ee7ca4..cec15a0e 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', @@ -130,7 +131,7 @@ function isCanonicalLeadMember(member) { const role = typeof member.role === 'string' ? member.role.trim().toLowerCase() : ''; const name = typeof member.name === 'string' ? member.name.trim().toLowerCase() : ''; return ( - agentType === 'team-lead' || + LEAD_AGENT_TYPES.has(agentType) || name === 'team-lead' || role === 'team-lead' || role === 'team lead' || @@ -175,12 +176,10 @@ function inferLeadName(paths) { const resolved = resolveTeamMembers(paths); const members = resolved.members || []; const lead = - members.find( - (member) => - member && - typeof member.agentType === 'string' && - member.agentType.trim().toLowerCase() === 'team-lead' - ) || + members.find((member) => { + const agentType = typeof member?.agentType === 'string' ? member.agentType.trim().toLowerCase() : ''; + return LEAD_AGENT_TYPES.has(agentType); + }) || members.find((member) => String((member && member.name) || '').trim().toLowerCase() === 'team-lead') || members.find( (member) => { diff --git a/agent-teams-controller/src/internal/tasks.js b/agent-teams-controller/src/internal/tasks.js index feece6e0..968f5944 100644 --- a/agent-teams-controller/src/internal/tasks.js +++ b/agent-teams-controller/src/internal/tasks.js @@ -187,19 +187,23 @@ function maybeNotifyTaskOwnerOnComment(context, task, comment, options = {}) { } function createTask(context, input) { + let taskInput = input; if (input && typeof input.owner === 'string' && input.owner.trim()) { - assertKnownTaskActor(context, input.owner, 'task owner'); + taskInput = { + ...input, + owner: assertKnownTaskActor(context, input.owner, 'task owner'), + }; } - const task = withTeamBoardLock(context.paths, () => taskStore.createTask(context.paths, input)); - if (input && input.notifyOwner !== false) { + const task = withTeamBoardLock(context.paths, () => taskStore.createTask(context.paths, taskInput)); + if (taskInput && taskInput.notifyOwner !== false) { maybeNotifyAssignedOwner(context, task, { - description: input.description, - prompt: input.prompt, + description: taskInput.description, + prompt: taskInput.prompt, taskRefs: [ - ...(Array.isArray(input.descriptionTaskRefs) ? input.descriptionTaskRefs : []), - ...(Array.isArray(input.promptTaskRefs) ? input.promptTaskRefs : []), + ...(Array.isArray(taskInput.descriptionTaskRefs) ? taskInput.descriptionTaskRefs : []), + ...(Array.isArray(taskInput.promptTaskRefs) ? taskInput.promptTaskRefs : []), ], - from: input.from, + from: taskInput.from, }); } return task; @@ -382,6 +386,10 @@ function softDeleteTask(context, taskId, actor) { function restoreTask(context, taskId, actor) { return withTeamBoardLock(context.paths, () => { + const before = taskStore.readTask(context.paths, taskId, { includeDeleted: true }); + if (before.status !== 'deleted') { + throw new Error(`Task #${before.displayId || before.id} is not deleted; task_restore only restores deleted tasks`); + } let task = taskStore.setTaskStatus(context.paths, taskId, 'pending', actor || 'user'); const state = kanbanStore.readKanbanState(context.paths, context.teamName); if (hasKanbanReference(state, task.id)) { @@ -403,7 +411,7 @@ function setTaskOwner(context, taskId, owner) { const before = taskStore.readTask(context.paths, taskId, { includeDeleted: true }); const nextOwner = isClearOwnerValue(owner) ? owner - : (assertKnownTaskActor(context, owner, 'task owner'), owner); + : assertKnownTaskActor(context, owner, 'task owner'); const after = taskStore.setTaskOwner(context.paths, taskId, nextOwner); return { previousTask: before, 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 71fde494..b36d086c 100644 --- a/agent-teams-controller/test/controller.test.js +++ b/agent-teams-controller/test/controller.test.js @@ -385,6 +385,31 @@ describe('agent-teams-controller API', () => { expect(briefing).toContain('Counters: actionable=4, awareness=3'); }); + it('treats stale legacy terminal reviewState on pending tasks as owner-ready work', async () => { + const claudeDir = makeClaudeDir(); + const controller = createController({ teamName: 'my-team', claudeDir }); + + const staleTask = controller.tasks.createTask({ + subject: 'Legacy stale approved task', + owner: 'bob', + status: 'pending', + reviewState: 'approved', + notifyOwner: false, + }); + + const briefing = await controller.tasks.taskBriefing('bob'); + const staleLine = briefing.split('\n').find((line) => line.includes(`#${staleTask.displayId}`)); + expect(staleLine).toContain('[status=pending]'); + expect(staleLine).not.toContain('review='); + expect(staleLine).toContain('reason=owner_ready'); + + const rows = controller.tasks.listTaskInventory({ owner: 'bob' }); + expect(rows.find((row) => row.id === staleTask.id)).toMatchObject({ + status: 'pending', + reviewState: 'none', + }); + }); + it('reconciles stale kanban rows and linked inbox comments idempotently', () => { const claudeDir = makeClaudeDir(); const controller = createController({ teamName: 'my-team', claudeDir }); @@ -887,7 +912,7 @@ describe('agent-teams-controller API', () => { text: 'Need your decision here.', }); - const inboxPath = path.join(claudeDir, 'teams', 'my-team', 'inboxes', 'team-lead.json'); + const inboxPath = path.join(claudeDir, 'teams', 'my-team', 'inboxes', 'alice.json'); const rows = JSON.parse(fs.readFileSync(inboxPath, 'utf8')); expect(rows).toHaveLength(1); expect(rows[0].from).toBe('bob'); @@ -1114,6 +1139,82 @@ describe('agent-teams-controller API', () => { expect(leadBriefing).not.toContain(`#${task.displayId}`); }); + it('recognizes lead and orchestrator agent types as canonical team leads', async () => { + const claudeDir = makeClaudeDir(); + const configPath = path.join(claudeDir, 'teams', 'my-team', 'config.json'); + fs.writeFileSync( + configPath, + JSON.stringify( + { + name: 'my-team', + leadSessionId: 'lead-session-1', + members: [ + { name: 'alice', role: 'developer' }, + { name: 'leadbot', agentType: 'lead' }, + { name: 'opsbot', agentType: 'orchestrator' }, + ], + }, + null, + 2 + ) + ); + + const controller = createController({ teamName: 'my-team', claudeDir }); + const aliceTask = controller.tasks.createTask({ subject: 'Alice owns this', owner: 'alice' }); + const leadTask = controller.tasks.createTask({ subject: 'Lead owns this', owner: 'leadbot' }); + const aliceBriefing = await controller.tasks.taskBriefing('alice'); + const leadBriefing = await controller.tasks.leadBriefing(); + + expect(aliceBriefing).toContain(`#${aliceTask.displayId}`); + expect(aliceBriefing).toContain('actionOwner=@alice'); + expect(aliceBriefing).not.toContain(`#${leadTask.displayId}`); + expect(leadBriefing).toContain(`#${leadTask.displayId}`); + expect(leadBriefing).not.toContain(`#${aliceTask.displayId}`); + }); + + it('stores canonical member names for lead aliases in owners, reviewers, and reviewer config', () => { + const claudeDir = makeClaudeDir(); + const configPath = path.join(claudeDir, 'teams', 'my-team', 'config.json'); + fs.writeFileSync( + configPath, + JSON.stringify( + { + name: 'my-team', + members: [ + { name: 'leadbot', agentType: 'lead' }, + { name: 'alice', role: 'reviewer' }, + { name: 'bob', role: 'developer' }, + ], + }, + null, + 2 + ) + ); + + const controller = createController({ teamName: 'my-team', claudeDir }); + const leadOwnedTask = controller.tasks.createTask({ subject: 'Lead alias owner', owner: 'lead' }); + expect(leadOwnedTask.owner).toBe('leadbot'); + expect(fs.existsSync(path.join(claudeDir, 'teams', 'my-team', 'inboxes', 'lead.json'))).toBe(false); + + const reassignedTask = controller.tasks.createTask({ subject: 'Reassign alias owner', owner: 'bob' }); + expect(controller.tasks.setTaskOwner(reassignedTask.id, 'team-lead').owner).toBe('leadbot'); + + controller.kanban.addReviewer('lead'); + expect(controller.kanban.listReviewers()).toEqual(['leadbot']); + + const reviewTask = controller.tasks.createTask({ subject: 'Review alias', owner: 'bob' }); + controller.tasks.completeTask(reviewTask.id, 'bob'); + controller.review.requestReview(reviewTask.id, { from: 'alice', reviewer: 'lead' }); + + const requested = controller.tasks + .getTask(reviewTask.id) + .historyEvents.filter((event) => event.type === 'review_requested') + .at(-1); + expect(requested.reviewer).toBe('leadbot'); + expect(fs.existsSync(path.join(claudeDir, 'teams', 'my-team', 'inboxes', 'leadbot.json'))).toBe(true); + expect(fs.existsSync(path.join(claudeDir, 'teams', 'my-team', 'inboxes', 'lead.json'))).toBe(false); + }); + it('rejects task_briefing for unknown members', async () => { const claudeDir = makeClaudeDir(); const controller = createController({ teamName: 'my-team', claudeDir }); @@ -1305,6 +1406,22 @@ describe('agent-teams-controller API', () => { expect(restored.reviewState).toBe('none'); }); + it('rejects task_restore for non-deleted tasks', () => { + const claudeDir = makeClaudeDir(); + const controller = createController({ teamName: 'my-team', claudeDir }); + const task = controller.tasks.createTask({ subject: 'Approved task must stay approved', owner: 'bob' }); + + controller.tasks.completeTask(task.id, 'bob'); + controller.review.requestReview(task.id, { from: 'alice', reviewer: 'alice' }); + controller.review.approveReview(task.id, { from: 'alice' }); + + expect(() => controller.tasks.restoreTask(task.id, 'alice')).toThrow( + 'task_restore only restores deleted tasks' + ); + expect(controller.tasks.getTask(task.id).status).toBe('completed'); + expect(controller.tasks.getTask(task.id).reviewState).toBe('approved'); + }); + it('uses actual kanban overlay for kanbanColumn inventory filters', () => { const claudeDir = makeClaudeDir(); const controller = createController({ teamName: 'my-team', claudeDir }); diff --git a/agent-teams-controller/test/crossTeam.test.js b/agent-teams-controller/test/crossTeam.test.js index b4b67f43..5b2371d3 100644 --- a/agent-teams-controller/test/crossTeam.test.js +++ b/agent-teams-controller/test/crossTeam.test.js @@ -59,8 +59,8 @@ describe('crossTeam module', () => { const inbox = JSON.parse(fs.readFileSync(inboxPath, 'utf8')); expect(inbox).toHaveLength(1); expect(inbox[0].source).toBe(CROSS_TEAM_SOURCE); - expect(inbox[0].from).toBe('team-a.lead'); - expect(inbox[0].text).toContain(`<${CROSS_TEAM_TAG_NAME} from="team-a.lead" depth="0"`); + expect(inbox[0].from).toBe('team-a.team-lead'); + expect(inbox[0].text).toContain(`<${CROSS_TEAM_TAG_NAME} from="team-a.team-lead" depth="0"`); expect(inbox[0].conversationId).toBeTruthy(); expect(inbox[0].text).toContain(`conversationId="${inbox[0].conversationId}"`); }); @@ -314,6 +314,108 @@ describe('crossTeam module', () => { expect(fs.existsSync(inboxPath)).toBe(true); }); + it('resolves supported lead agent types before tech-lead role text', () => { + const claudeDir = makeClaudeDir({ + 'team-a': { + name: 'team-a', + members: [{ name: 'team-lead', agentType: 'team-lead' }], + }, + 'team-b': { + name: 'team-b', + members: [ + { name: 'alice', role: 'tech lead' }, + { name: 'olivia', agentType: 'lead' }, + ], + }, + }); + + const controller = createController({ teamName: 'team-a', claudeDir }); + controller.crossTeam.sendCrossTeamMessage({ + toTeam: 'team-b', + text: 'Hello', + }); + + expect(fs.existsSync(path.join(claudeDir, 'teams', 'team-b', 'inboxes', 'olivia.json'))).toBe(true); + expect(fs.existsSync(path.join(claudeDir, 'teams', 'team-b', 'inboxes', 'alice.json'))).toBe(false); + }); + + it('resolves orchestrator lead from members.meta.json', () => { + const claudeDir = makeClaudeDir({ + 'team-a': { + name: 'team-a', + members: [{ name: 'team-lead', agentType: 'team-lead' }], + }, + 'team-b': { + name: 'team-b', + members: [], + }, + }); + + const metaPath = path.join(claudeDir, 'teams', 'team-b', 'members.meta.json'); + fs.writeFileSync( + metaPath, + JSON.stringify({ + members: [ + { name: 'alice', role: 'tech lead' }, + { name: 'orla', agentType: 'orchestrator' }, + ], + }) + ); + + const controller = createController({ teamName: 'team-a', claudeDir }); + controller.crossTeam.sendCrossTeamMessage({ + toTeam: 'team-b', + text: 'Hello', + }); + + expect(fs.existsSync(path.join(claudeDir, 'teams', 'team-b', 'inboxes', 'orla.json'))).toBe(true); + expect(fs.existsSync(path.join(claudeDir, 'teams', 'team-b', 'inboxes', 'alice.json'))).toBe(false); + }); + + it('rejects phantom source teams before delivery or outbox writes', () => { + const claudeDir = makeClaudeDir({ + 'team-b': { + name: 'team-b', + members: [{ name: 'team-lead', agentType: 'team-lead' }], + }, + }); + + const controller = createController({ teamName: 'team-a', claudeDir }); + + expect(() => + controller.crossTeam.sendCrossTeamMessage({ + toTeam: 'team-b', + text: 'Hello from nowhere', + }) + ).toThrow('Source team not found: team-a'); + expect(fs.existsSync(path.join(claudeDir, 'teams', 'team-a'))).toBe(false); + expect(fs.existsSync(path.join(claudeDir, 'teams', 'team-b', 'inboxes', 'team-lead.json'))).toBe(false); + }); + + it('rejects unknown cross-team senders', () => { + const claudeDir = makeClaudeDir({ + 'team-a': { + name: 'team-a', + members: [{ name: 'team-lead', agentType: 'team-lead' }], + }, + 'team-b': { + name: 'team-b', + members: [{ name: 'team-lead', agentType: 'team-lead' }], + }, + }); + + const controller = createController({ teamName: 'team-a', claudeDir }); + + expect(() => + controller.crossTeam.sendCrossTeamMessage({ + toTeam: 'team-b', + fromMember: 'alicce', + text: 'Hello', + }) + ).toThrow('Unknown cross-team sender: alicce'); + expect(fs.existsSync(path.join(claudeDir, 'teams', 'team-b', 'inboxes', 'team-lead.json'))).toBe(false); + }); + it('resolves lead by name fallback', () => { const claudeDir = makeClaudeDir({ 'team-a': { diff --git a/docs/team-management/member-liveness-hardening-plan.md b/docs/team-management/member-liveness-hardening-plan.md index d78a8d97..bba76a0d 100644 --- a/docs/team-management/member-liveness-hardening-plan.md +++ b/docs/team-management/member-liveness-hardening-plan.md @@ -32,23 +32,23 @@ - `mcp-server/src/tools/runtimeTools.ts` уже содержит `runtime_bootstrap_checkin` и `runtime_heartbeat`. Это сильный сигнал, его надо сделать главным источником подтверждения. - `agent-teams-controller/src/internal/runtime.js` уже прокидывает `runtimeBootstrapCheckin()` в desktop runtime. - `src/main/services/team/TeamBootstrapStateReader.ts` уже читает `bootstrap-state.json`, `bootstrap-journal.jsonl` и классифицирует stuck bootstrap. Там уже есть важные тайминги: `ACTIVE_BOOTSTRAP_STUCK_CLASSIFICATION_MS = 3 min` и `TERMINAL_BOOTSTRAP_ONLY_PENDING_GRACE_MS = 5 min`. -- `TeamProvisioningService.getLiveTeamAgentRuntimeMetadata()` сейчас собирает evidence из config/meta/persisted runtime/tmux/process table. -- Для tmux сейчас читается только `#{pane_id}\t#{pane_pid}` через `listTmuxPanePidsForCurrentPlatform()`. `pane_pid` часто является shell (`zsh`, `bash`, `sh`), поэтому `2 MB` выглядит логично. -- `attachLiveRuntimeMetadataToStatuses()` превращает `metadata.alive` в `runtimeAlive: true` и `livenessSource: "process"`. -- `reevaluateMemberLaunchStatus()` не fail-ит member после grace timeout, если `runtimeAlive === true`. -- `OpenCodeTeamRuntimeAdapter.mapBridgeMemberToRuntimeEvidence()` сейчас может выставить `runtimeAlive: true`, если bridge просто вернул member в состоянии `created` или `permission_blocked`. Это полезный материализационный сигнал, но он слабее реального `runtimePid` и слабее bootstrap. +- `TeamProvisioningService.getLiveTeamAgentRuntimeMetadata()` собирает evidence из config/meta/persisted runtime/tmux/process table и прогоняет его через strict resolver. +- Для tmux раньше читался только `#{pane_id}\t#{pane_pid}` через `listTmuxPanePidsForCurrentPlatform()`. `pane_pid` часто является shell (`zsh`, `bash`, `sh`), поэтому `2 MB` выглядело логично. +- `attachLiveRuntimeMetadataToStatuses()` теперь повышает member до `runtimeAlive: true` только через strong evidence: `confirmed_bootstrap` или `runtime_process`. +- `reevaluateMemberLaunchStatus()` больше не доверяет старому `runtimeAlive === true` без live metadata. +- `OpenCodeTeamRuntimeAdapter.mapBridgeMemberToRuntimeEvidence()` теперь не выставляет `runtimeAlive: true` для bridge-only `created` или `permission_blocked`. Такие сигналы остаются candidate/pending до bootstrap или OS verification. - `recordOpenCodeRuntimeBootstrapCheckin()` и `recordOpenCodeRuntimeHeartbeat()` уже пишут `confirmed_alive`, `runtimeAlive: true`, `bootstrapConfirmed: true`, `nativeHeartbeat: true` через `updateOpenCodeRuntimeMemberLiveness()`. Значит confirmed state уже есть, надо не дать слабым сигналам выглядеть как он. - `OpenCodeLaunchTransactionStore.canMarkOpenCodeRunReady()` уже требует `member_session_recorded`, `required_tools_proven` и `bootstrap_confirmed`. Это strict readiness precedent, который надо сохранить. - Renderer уже получает оба источника: `memberSpawnStatuses` и `teamAgentRuntimeByTeam`. Но `MemberCard` сейчас получает только `runtimeSummary` строкой, а не сам `TeamAgentRuntimeEntry`. -- `teamSlice.areTeamAgentRuntimeEntriesEqual()` сейчас сравнивает только `memberName`, `alive`, `restartable`, `backendType`, `pid`, `runtimeModel`, `rssBytes`. Если добавить `livenessKind`, `pidSource`, `diagnostics`, но не обновить comparator, UI может не перерендериться. -- `teamSlice.areMemberSpawnStatusEntriesEqual()` сейчас намеренно игнорирует timing fields и сравнивает только visible spawn fields. Если добавить `livenessKind/runtimeDiagnostic`, comparator тоже надо обновить. -- `areLaunchSummaryCountsEqual()` сейчас знает только `confirmedCount`, `pendingCount`, `failedCount`, `runtimeAlivePendingCount`. Новые aggregate diagnostic counts не будут обновлять UI без расширения comparator. +- `teamSlice.areTeamAgentRuntimeEntriesEqual()` должен сравнивать `livenessKind`, `pidSource` и diagnostic fields, иначе UI может не перерендериться при смене strict evidence. +- `teamSlice.areMemberSpawnStatusEntriesEqual()` должен сравнивать visible liveness fields (`livenessKind/runtimeDiagnostic`) и продолжать игнорировать timing-only fields. +- `areLaunchSummaryCountsEqual()` должен сравнивать aggregate diagnostic counts (`shellOnlyPendingCount`, `runtimeProcessPendingCount`, `runtimeCandidatePendingCount`, `noRuntimePendingCount`, `permissionPendingCount`). UI не должен использовать legacy `runtimeAlivePendingCount` как process evidence. - `TeamAgentRuntimeWatcher` обновляет runtime snapshot раз в 5 секунд, а spawn statuses раз в 2.5 секунды. Диагностические поля должны попадать либо в оба snapshot слоя, либо UX должен быть устойчив к задержке runtime snapshot. - Renderer `member-spawn` event сейчас вызывает refresh spawn statuses, но не runtime snapshot. Если tooltip/detail зависят от `TeamAgentRuntimeSnapshot`, event handler тоже должен запланировать runtime refresh. - Runtime tools принимают `metadata`, но `recordOpenCodeRuntimeBootstrapCheckin()` и `recordOpenCodeRuntimeHeartbeat()` сейчас используют только `diagnostics`. Если runtime присылает PID/version/command в `metadata`, эта информация теряется. -- `handleMemberSpawnToolResult()` при reason `already_running` сейчас делает `setMemberSpawnStatus(..., "online", ..., "process")`. В strict model это нельзя оставлять как strong liveness без проверки актуального runtime identity. +- `handleMemberSpawnToolResult()` раньше при reason `already_running` делал `setMemberSpawnStatus(..., "online", ..., "process")`. В strict model это заменено на `waiting` + runtime re-evaluation. - `waitForTmuxPanesToExit()` использует `listTmuxPanePidsForCurrentPlatform()` только как "pane exists" check. Поэтому старый `listPanePids()` wrapper должен остаться ровно pane-existence helper, а не получить новую liveness-семантику. -- В проекте уже есть env-mode precedent: `CLAUDE_TEAM_OPENCODE_LAUNCH_MODE` с `dogfood`/`production`/`disabled`. Для liveness rollout лучше использовать такой же явный режим, а не скрытый boolean. +- В проекте есть env-mode precedent: `CLAUDE_TEAM_OPENCODE_LAUNCH_MODE` с `dogfood`/`production`/`disabled`. Для member liveness финальное решение другое: strict model включена по умолчанию без отдельного env-флага. - `src/shared/types/api.ts`, `src/preload/index.ts` и `src/renderer/api/httpClient.ts` уже прокидывают `getMemberSpawnStatuses()` и `getTeamAgentRuntime()` через shared snapshot types. Новый контракт можно добавить optional fields без нового IPC channel, но browser HTTP fallback должен возвращать валидный старый shape. - `TeamProvisioningService.readUnixProcessTableRows()` сейчас приватный, sync и читает только `pid,command`. Для надежного liveness нужен `ppid`, WSL-aware execution и unit-test seam. Это не должно оставаться приватным ad hoc helper внутри огромного service. - `getLiveTeamAgentRuntimeMetadata()` сейчас читает tmux panes и process table внутри одного метода. После strict model там станет слишком много правил, поэтому план должен вынести pure resolution в отдельный helper/module. @@ -142,58 +142,21 @@ const MEMBER_BOOTSTRAP_STALL_MS = 5 * 60_000; ## Rollout mode -Строгая модель меняет поведение launch timeout, поэтому ее надо включать контролируемо. +Строгая модель меняет поведение launch timeout, поэтому изначальный план рассматривал rollout через отдельный флаг. +Текущая реализация после hardening включает strict liveness по умолчанию и не содержит старый переключатель режима. -Топ 3 rollout вариантов: +Актуальное поведение: -1. Diagnostics-only default, strict behind env flag - 🎯 9 🛡️ 9 🧠 5 Примерно 80-140 строк. - По умолчанию UI получает новые diagnostics, но `runtimeAlive` behavior остается старым. Strict включается через env для dogfood. Это самый безопасный путь для первого PR. +| Area | Strict-only behavior | +| ------------------------------ | ---------------------------------------- | +| `livenessKind` | always filled when evidence exists | +| UI labels | enabled | +| `runtimeAlive` from shell-only | always false | +| `already_running` shortcut | waits for strong runtime verification | +| timeout self-heal | strong evidence only | +| launchDiagnostics | enabled for warning/error states | -2. Strict default сразу - 🎯 6 🛡️ 6 🧠 4 Примерно 40-80 строк. - Быстрее исправляет проблему, но риск false negative выше, если реальные teammate processes не содержат ожидаемые identity args. - -3. Полный app setting + env override - 🎯 8 🛡️ 8 🧠 7 Примерно 180-260 строк. - Удобно для пользователей, но это больше surface area: settings UI, persistence, migration, tests. Лучше после dogfood данных. - -Рекомендация: вариант 1. - -Добавить mode resolver рядом с team runtime кодом: - -```ts -export type TeamMemberLivenessMode = 'diagnostics' | 'strict'; - -export const CLAUDE_TEAM_MEMBER_LIVENESS_MODE_ENV = 'CLAUDE_TEAM_MEMBER_LIVENESS_MODE'; - -export function resolveTeamMemberLivenessModeFromEnv( - env: NodeJS.ProcessEnv = process.env -): TeamMemberLivenessMode { - const raw = env[CLAUDE_TEAM_MEMBER_LIVENESS_MODE_ENV]?.trim().toLowerCase(); - if (raw === 'strict') return 'strict'; - return 'diagnostics'; -} -``` - -Behavior by mode: - -| Area | `diagnostics` | `strict` | -| ------------------------------ | ---------------------------------------- | --------------------------- | -| `livenessKind` | filled | filled | -| UI labels | enabled | enabled | -| `runtimeAlive` from shell-only | old behavior may remain temporarily | always false | -| `already_running` shortcut | warning diagnostic, old fallback allowed | must verify strong evidence | -| timeout self-heal | old behavior | strong evidence only | -| launchDiagnostics | enabled | enabled | - -Important default: - -- In local dogfood, run with `CLAUDE_TEAM_MEMBER_LIVENESS_MODE=strict`. -- Production default can stay `diagnostics` for one release if Phase 0 data is unknown. -- After manual scenarios pass, flip default to `strict` and keep env as rollback: `CLAUDE_TEAM_MEMBER_LIVENESS_MODE=diagnostics`. - -This gives an emergency fallback without reverting the UI diagnostics work. +Operational rollback должен быть отдельным code revert или follow-up setting, а не скрытым env-флагом. ## Structured launch diagnostics @@ -1251,7 +1214,7 @@ if ( Файл: `src/main/services/team/TeamProvisioningService.ts` -`handleMemberSpawnToolResult()` сейчас содержит shortcut: +`handleMemberSpawnToolResult()` раньше содержал shortcut: ```ts if (parsedStatus.reason === 'already_running') { @@ -1261,23 +1224,19 @@ if (parsedStatus.reason === 'already_running') { В strict liveness модели это опасно: `already_running` доказывает, что runtime/CLI отказался дублировать spawn, но не доказывает, что нужный teammate сейчас прошел bootstrap или что текущий pane PID является runtime процессом. -Новая логика: +Итоговая логика: ```ts if (parsedStatus.reason === 'already_running') { this.agentRuntimeSnapshotCache.delete(run.teamName); this.liveTeamAgentRuntimeMetadataCache.delete(run.teamName); - const runtime = await this.findStrongRuntimeEvidenceForMember(run.teamName, spawnedMemberName); - if (isStrongRuntimeEvidence(runtime)) { - this.setMemberSpawnStatus(run, spawnedMemberName, 'online', undefined, 'process'); - } else { - this.setMemberSpawnStatus(run, spawnedMemberName, 'waiting'); - this.setMemberRuntimeDiagnostic(run, spawnedMemberName, { - livenessKind: runtime?.livenessKind ?? 'registered_only', - message: 'Runtime reported already running, but no verified member process was found yet.', - severity: 'warning', - }); - } + this.setMemberSpawnStatus(run, spawnedMemberName, 'waiting'); + this.appendMemberBootstrapDiagnostic( + run, + spawnedMemberName, + 'already_running requires strong runtime verification' + ); + void this.reevaluateMemberLaunchStatus(run, spawnedMemberName); } ``` @@ -1825,6 +1784,7 @@ export interface PersistedTeamLaunchSummary { confirmedCount: number; pendingCount: number; failedCount: number; + // Compatibility aggregate only. Do not use as process evidence in UI. runtimeAlivePendingCount: number; shellOnlyPendingCount?: number; runtimeProcessPendingCount?: number; @@ -1905,11 +1865,6 @@ Backend/shared: - добавить компактные diagnostic fields в `MemberSpawnStatusEntry`. - добавить bounded `TeamLaunchDiagnosticItem` и `TeamProvisioningProgress.launchDiagnostics`. -- `src/main/services/team/TeamMemberLivenessMode.ts` - - добавить `CLAUDE_TEAM_MEMBER_LIVENESS_MODE`; - - добавить resolver `diagnostics`/`strict`; - - использовать как dogfood/rollback lever. - - `src/main/services/team/TeamRuntimeLivenessResolver.ts` - вынести pure liveness classification; - принимать tmux/process/OpenCode/persisted facts; @@ -1928,7 +1883,6 @@ Backend/shared: - расширить `LiveTeamAgentRuntimeMetadata`; - parse sanitized runtime tool `metadata`; - добавить strict evidence helpers; - - подключить `TeamMemberLivenessMode`; - использовать `TeamRuntimeLivenessResolver`; - обновить `updateProgress()` extras для `launchDiagnostics`; - переписать tmux/process resolution; @@ -2005,11 +1959,6 @@ Renderer: Backend: -- `TeamMemberLivenessMode.test.ts` - - default mode is `diagnostics`; - - `CLAUDE_TEAM_MEMBER_LIVENESS_MODE=strict` enables strict; - - unknown values fall back to `diagnostics`. - - `TeamRuntimeLivenessResolver.test.ts` - tmux foreground shell + no child -> `shell_only`; - verified process row by `--team-name` + `--agent-id` -> `runtime_process`; @@ -2088,9 +2037,9 @@ Renderer: Add: -- `TeamMemberLivenessMode` with default `diagnostics`; - `TeamRuntimeLivenessResolver` pure tests; -- process table/tmux providers, but strict behavior disabled by default. +- process table/tmux providers; +- strict-only runtime evidence flow without a runtime-mode switch. Verification: @@ -2103,14 +2052,11 @@ pnpm exec vitest run test/main/features/tmux-installer test/main/services/team/T 🎯 9 🛡️ 9 🧠 7 Примерно 220-320 строк. -Переключить `attachLiveRuntimeMetadataToStatuses()` на strong evidence only when `CLAUDE_TEAM_MEMBER_LIVENESS_MODE=strict`. Shell/pane/candidate больше не выставляют `runtimeAlive` в strict mode. - -Keep diagnostics mode as rollback until manual launch scenarios pass. +Переключить `attachLiveRuntimeMetadataToStatuses()` на strong evidence only. Shell/pane/candidate больше не выставляют `runtimeAlive`. Verification: ```bash -CLAUDE_TEAM_MEMBER_LIVENESS_MODE=strict pnpm exec vitest run test/main/services/team/TeamProvisioningService.test.ts pnpm exec vitest run test/main/services/team/TeamProvisioningService.test.ts ``` @@ -2204,9 +2150,9 @@ Scenarios: - `member-spawn` event refreshes runtime snapshot. 9. Rollout безопасен: - - default `diagnostics` mode не меняет hard timeout behavior до включения strict; - - `CLAUDE_TEAM_MEMBER_LIVENESS_MODE=strict` включает strong-only behavior; - - `CLAUDE_TEAM_MEMBER_LIVENESS_MODE=diagnostics` работает как rollback без удаления UI diagnostics. + - strict behavior включен по умолчанию; + - diagnostics UI остается доступным без отдельного mode flag; + - rollback требует явного code revert или отдельного follow-up setting. 10. Provider failures не создают ложный ready: @@ -2282,22 +2228,21 @@ Mitigation: ## Minimal safe patch order 1. Добавить типы и optional fields. -2. Добавить `TeamMemberLivenessMode` default `diagnostics`. -3. Добавить sanitized runtime tool metadata parser. -4. Добавить tmux `listPaneRuntimeInfo()` и сохранить wrapper `listPanePids()`. -5. Добавить process table provider/parser с `ppid`. -6. Вынести `TeamRuntimeLivenessResolver`. -7. Заполнить `livenessKind` без behavior change. -8. Написать backend tests на shell-only, verified runtime, stale event, metadata PID. -9. Переключить `attachLiveRuntimeMetadataToStatuses()` на strong evidence behind strict mode. -10. Исправить `already_running` shortcut behind strict mode. -11. Переключить timeout/self-heal logic behind strict mode. -12. Исправить OpenCode bridge mapping. -13. Обновить persisted summary diagnostics и store equality. -14. Добавить `launchDiagnostics` в progress payload и UI disclosure. -15. Добавить renderer labels/tooltips/banner. -16. Добавить copy diagnostics. -17. После manual validation включить strict default или оставить env rollback на один release. +2. Добавить sanitized runtime tool metadata parser. +3. Добавить tmux `listPaneRuntimeInfo()` и сохранить wrapper `listPanePids()`. +4. Добавить process table provider/parser с `ppid`. +5. Вынести `TeamRuntimeLivenessResolver`. +6. Заполнить `livenessKind`. +7. Написать backend tests на shell-only, verified runtime, stale event, metadata PID. +8. Переключить `attachLiveRuntimeMetadataToStatuses()` на strong evidence. +9. Исправить `already_running` shortcut. +10. Переключить timeout/self-heal logic на strong evidence. +11. Исправить OpenCode bridge mapping. +12. Обновить persisted summary diagnostics и store equality. +13. Добавить `launchDiagnostics` в progress payload и UI disclosure. +14. Добавить renderer labels/tooltips/banner. +15. Добавить copy diagnostics. +16. Manual validation: создать команду, проверить pending names, runtime diagnostics и отсутствие false-ready shell-only процесса. ## Expected UX diff --git a/mcp-server/src/tools/crossTeamTools.ts b/mcp-server/src/tools/crossTeamTools.ts index 486eb014..7649936d 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), @@ -34,8 +35,9 @@ export function registerCrossTeamTools(server: Pick) { conversationId, replyToConversationId, chainDepth, - }) => - await Promise.resolve( + }) => { + assertConfiguredTeam(teamName, claudeDir); + return await Promise.resolve( jsonTextContent( getController(teamName, claudeDir).crossTeam.sendCrossTeamMessage({ toTeam, @@ -47,7 +49,8 @@ export function registerCrossTeamTools(server: Pick) { ...(chainDepth !== undefined ? { chainDepth } : {}), }) ) - ), + ); + }, }); server.addTool({ @@ -57,14 +60,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({ @@ -73,9 +78,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 59030abf..7ca6b607 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 = { @@ -42,8 +43,9 @@ export function registerMessageTools(server: Pick) { source, leadSessionId, attachments, - }) => - await Promise.resolve( + }) => { + assertConfiguredTeam(teamName, claudeDir); + return await Promise.resolve( jsonTextContent( getController(teamName, claudeDir).messages.sendMessage({ to, @@ -55,6 +57,7 @@ export function registerMessageTools(server: Pick) { ...(attachments?.length ? { attachments } : {}), }) ) - ), + ); + }, }); } 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 6dd065e9..2968b0c8 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({ @@ -156,8 +163,9 @@ export function registerRuntimeTools(server: Pick) { createdAt, summary, taskRefs, - }) => - jsonTextContent( + }) => { + assertConfiguredTeam(teamName, claudeDir); + return jsonTextContent( await getController(teamName, claudeDir).runtime.runtimeDeliverMessage({ idempotencyKey, runId, @@ -171,7 +179,8 @@ export function registerRuntimeTools(server: Pick) { ...(controlUrl ? { controlUrl } : {}), ...(waitTimeoutMs ? { waitTimeoutMs } : {}), }) - ), + ); + }, }); server.addTool({ @@ -203,8 +212,9 @@ export function registerRuntimeTools(server: Pick) { createdAt, summary, metadata, - }) => - jsonTextContent( + }) => { + assertConfiguredTeam(teamName, claudeDir); + return jsonTextContent( await getController(teamName, claudeDir).runtime.runtimeTaskEvent({ idempotencyKey, runId, @@ -218,7 +228,8 @@ export function registerRuntimeTools(server: Pick) { ...(controlUrl ? { controlUrl } : {}), ...(waitTimeoutMs ? { waitTimeoutMs } : {}), }) - ), + ); + }, }); server.addTool({ @@ -241,8 +252,9 @@ export function registerRuntimeTools(server: Pick) { observedAt, status, metadata, - }) => - jsonTextContent( + }) => { + assertConfiguredTeam(teamName, claudeDir); + return jsonTextContent( await getController(teamName, claudeDir).runtime.runtimeHeartbeat({ runId, memberName, @@ -253,6 +265,7 @@ export function registerRuntimeTools(server: Pick) { ...(controlUrl ? { controlUrl } : {}), ...(waitTimeoutMs ? { waitTimeoutMs } : {}), }) - ), + ); + }, }); } diff --git a/mcp-server/src/tools/taskTools.ts b/mcp-server/src/tools/taskTools.ts index f4fc62a2..92e1c256 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({ @@ -565,14 +621,17 @@ 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.memberBriefing(memberName), - }, - ], - }), + execute: async ({ teamName, claudeDir, memberName }) => { + assertConfiguredTeam(teamName, claudeDir); + return { + content: [ + { + type: 'text' as const, + text: await getController(teamName, claudeDir).tasks.memberBriefing(memberName), + }, + ], + }; + }, }); server.addTool({ @@ -582,13 +641,16 @@ export function registerTaskTools(server: Pick) { ...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 0fc075e5..b4705ce7 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) }) + ); } }); }); @@ -125,6 +127,10 @@ describe('agent-teams-mcp tools', () => { }); it('launches and stops teams through the runtime MCP tools', async () => { + const claudeDir = makeClaudeDir(); + writeTeamConfig(claudeDir, 'alpha', { + members: [{ name: 'lead', role: 'team-lead' }], + }); const calls: Array<{ method?: string; url?: string; body?: unknown }> = []; const server = await startControlServer(async ({ method, url, body }) => { calls.push({ method, url, body }); @@ -171,6 +177,7 @@ describe('agent-teams-mcp tools', () => { try { const launched = parseJsonToolResult( await getTool('team_launch').execute({ + claudeDir, teamName: 'alpha', cwd: '/tmp/project', controlUrl: server.baseUrl, @@ -182,6 +189,7 @@ describe('agent-teams-mcp tools', () => { const stopped = parseJsonToolResult( await getTool('team_stop').execute({ + claudeDir, teamName: 'alpha', controlUrl: server.baseUrl, }) @@ -216,6 +224,13 @@ describe('agent-teams-mcp tools', () => { }); it('forwards OpenCode runtime MCP tools through the runtime control bridge', async () => { + const claudeDir = makeClaudeDir(); + writeTeamConfig(claudeDir, 'alpha', { + members: [ + { name: 'lead', role: 'team-lead' }, + { name: 'alice', role: 'developer' }, + ], + }); const calls: Array<{ method?: string; url?: string; body?: unknown }> = []; const server = await startControlServer(async ({ method, url, body }) => { calls.push({ method, url, body }); @@ -224,6 +239,7 @@ describe('agent-teams-mcp tools', () => { try { await getTool('runtime_bootstrap_checkin').execute({ + claudeDir, teamName: 'alpha', controlUrl: server.baseUrl, runId: 'run-oc', @@ -231,6 +247,7 @@ describe('agent-teams-mcp tools', () => { runtimeSessionId: 'ses-1', }); await getTool('runtime_deliver_message').execute({ + claudeDir, teamName: 'alpha', controlUrl: server.baseUrl, idempotencyKey: 'idem-1', @@ -241,6 +258,7 @@ describe('agent-teams-mcp tools', () => { text: 'hello', }); await getTool('runtime_task_event').execute({ + claudeDir, teamName: 'alpha', controlUrl: server.baseUrl, idempotencyKey: 'idem-task-1', @@ -251,6 +269,7 @@ describe('agent-teams-mcp tools', () => { event: 'started', }); await getTool('runtime_heartbeat').execute({ + claudeDir, teamName: 'alpha', controlUrl: server.baseUrl, runId: 'run-oc', @@ -280,6 +299,9 @@ describe('agent-teams-mcp tools', () => { it('discovers the control endpoint from the published state file', async () => { const claudeDir = makeClaudeDir(); + writeTeamConfig(claudeDir, 'alpha', { + members: [{ name: 'lead', role: 'team-lead' }], + }); const statePath = path.join(claudeDir, 'team-control-api.json'); const server = await startControlServer(async ({ method, url }) => { @@ -648,12 +670,16 @@ describe('agent-teams-mcp tools', () => { expect(ownerInbox[0].text).toContain('task_start'); expect(ownerInbox[0].text).toContain('task_add_comment'); expect(ownerInbox[0].text).toContain('Read the plan before starting.'); - expect(ownerInbox[0].text).toContain('If you are idle and this task is ready to start, start it now.'); + expect(ownerInbox[0].text).toContain( + 'If you are idle and this task is ready to start, start it now.' + ); expect(ownerInbox[0].text).toContain( 'If you are busy, blocked, or still need more context, immediately add a short task comment' ); expect(ownerInbox[3].summary).toContain(`#${unassignedTask.displayId}`); - expect(ownerInbox[3].text).toContain('If you are idle and this task is ready to start, start it now.'); + expect(ownerInbox[3].text).toContain( + 'If you are idle and this task is ready to start, start it now.' + ); expect(ownerInbox[3].text).toContain('task_add_comment'); const briefing = (await getTool('task_briefing').execute({ @@ -695,14 +721,22 @@ describe('agent-teams-mcp tools', () => { expect(memberBriefingText).toContain( 'Awareness items are watch-only context and do not authorize you to start work unless the lead reroutes the task or you become the actionOwner.' ); - expect(memberBriefingText).toContain('TURN ACTION MODE PROTOCOL (HIGHEST PRIORITY FOR EACH USER TURN):'); + expect(memberBriefingText).toContain( + 'TURN ACTION MODE PROTOCOL (HIGHEST PRIORITY FOR EACH USER TURN):' + ); expect(memberBriefingText).toContain('Task briefing for alice:'); expect(memberBriefingText).toContain(`#${activeTask.displayId}`); fs.mkdirSync(path.join(claudeDir, 'teams', teamName, 'inboxes'), { recursive: true }); fs.writeFileSync(path.join(claudeDir, 'teams', teamName, 'inboxes', 'carol.json'), '[]'); - fs.writeFileSync(path.join(claudeDir, 'teams', teamName, 'inboxes', 'cross_team_send.json'), '[]'); - fs.writeFileSync(path.join(claudeDir, 'teams', teamName, 'inboxes', 'other-team.alice.json'), '[]'); + fs.writeFileSync( + path.join(claudeDir, 'teams', teamName, 'inboxes', 'cross_team_send.json'), + '[]' + ); + fs.writeFileSync( + path.join(claudeDir, 'teams', teamName, 'inboxes', 'other-team.alice.json'), + '[]' + ); const inboxResolvedBriefing = (await getTool('member_briefing').execute({ claudeDir, @@ -710,7 +744,9 @@ describe('agent-teams-mcp tools', () => { memberName: 'carol', })) as { content: Array<{ text: string }> }; const inboxResolvedBriefingText = inboxResolvedBriefing.content[0]?.text ?? ''; - expect(inboxResolvedBriefingText).toContain('Member briefing for carol on team "gamma" (gamma).'); + expect(inboxResolvedBriefingText).toContain( + 'Member briefing for carol on team "gamma" (gamma).' + ); expect(inboxResolvedBriefingText).toContain('Role: team member.'); await expect( @@ -897,9 +933,9 @@ describe('agent-teams-mcp tools', () => { teamName, }) ); - expect(listedTasks.find((task: { id: string }) => task.id === createdTask.id)?.reviewState).toBe( - 'needsFix' - ); + expect( + listedTasks.find((task: { id: string }) => task.id === createdTask.id)?.reviewState + ).toBe('needsFix'); const kanbanCleared = parseJsonToolResult( await getTool('kanban_clear').execute({ @@ -1044,6 +1080,26 @@ describe('agent-teams-mcp tools', () => { ); expect(kanbanState.tasks[reviewTask.id]).toBeUndefined(); expect(JSON.stringify(kanbanState.columnOrder ?? {})).not.toContain(reviewTask.id); + + const restored = parseJsonToolResult( + await getTool('task_restore').execute({ + claudeDir, + teamName, + taskId: reviewTask.id, + actor: 'lead', + }) + ); + expect(restored.status).toBe('pending'); + expect(restored.reviewState).toBe('none'); + + await expect( + getTool('task_restore').execute({ + claudeDir, + teamName, + taskId: reviewTask.id, + actor: 'lead', + }) + ).rejects.toThrow('task_restore only restores deleted tasks'); }); it('only notifies the owner on review_approve when notifyOwner is explicit', async () => { @@ -1132,6 +1188,12 @@ describe('agent-teams-mcp tools', () => { it('persists full message metadata through message_send', async () => { const claudeDir = makeClaudeDir(); const teamName = 'gamma'; + writeTeamConfig(claudeDir, teamName, { + members: [ + { name: 'lead', role: 'team-lead' }, + { name: 'alice', role: 'developer' }, + ], + }); const sent = parseJsonToolResult( await getTool('message_send').execute({ @@ -1155,6 +1217,41 @@ describe('agent-teams-mcp tools', () => { expect(rows[0].attachments[0].filename).toBe('note.txt'); }); + it('rejects non-configured teams before MCP side-effect writes', async () => { + const claudeDir = makeClaudeDir(); + writeTeamConfig(claudeDir, 'real-team', { + members: [{ name: 'lead', role: 'team-lead' }], + }); + + await expect( + getTool('message_send').execute({ + claudeDir, + teamName: 'typo-team', + to: 'alice', + text: 'Should not create inbox', + }) + ).rejects.toThrow('Unknown team "typo-team"'); + await expect( + getTool('process_register').execute({ + claudeDir, + teamName: 'typo-team', + pid: process.pid, + label: 'watcher', + }) + ).rejects.toThrow('Unknown team "typo-team"'); + await expect( + getTool('cross_team_send').execute({ + claudeDir, + teamName: 'typo-team', + toTeam: 'real-team', + text: 'Should not deliver', + }) + ).rejects.toThrow('Unknown team "typo-team"'); + + expect(fs.existsSync(path.join(claudeDir, 'teams', 'typo-team'))).toBe(false); + expect(fs.existsSync(path.join(claudeDir, 'teams', 'real-team', 'inboxes', 'lead.json'))).toBe(false); + }); + it('exposes zod schemas that reject obviously invalid payloads', () => { expect( getTool('task_create').parameters?.safeParse({ @@ -1303,9 +1400,7 @@ describe('agent-teams-mcp tools', () => { expect(completed.comments).toBeUndefined(); // task_list: explicit inventory shape only - const listed = parseJsonToolResult( - await getTool('task_list').execute({ claudeDir, teamName }) - ); + const listed = parseJsonToolResult(await getTool('task_list').execute({ claudeDir, teamName })); const listedTask = listed.find((t: { id: string }) => t.id === task.id); expect(listedTask).toBeDefined(); expect(listedTask).toEqual({ @@ -1345,9 +1440,7 @@ describe('agent-teams-mcp tools', () => { const sentPath = path.join(claudeDir, 'teams', teamName, 'sentMessages.json'); const teamDir = path.join(claudeDir, 'teams', teamName); fs.mkdirSync(teamDir, { recursive: true }); - const existing = fs.existsSync(sentPath) - ? JSON.parse(fs.readFileSync(sentPath, 'utf8')) - : []; + const existing = fs.existsSync(sentPath) ? JSON.parse(fs.readFileSync(sentPath, 'utf8')) : []; existing.push(message); fs.writeFileSync(sentPath, JSON.stringify(existing, null, 2)); } @@ -1693,9 +1786,7 @@ describe('agent-teams-mcp tools', () => { text: 'Roundtrip test message', timestamp: '2026-03-15T16:00:00.000Z', source: 'user_sent', - attachments: [ - { id: 'att-rt', filename: 'data.csv', mimeType: 'text/csv', size: 1024 }, - ], + attachments: [{ id: 'att-rt', filename: 'data.csv', mimeType: 'text/csv', size: 1024 }], }); const created = parseJsonToolResult( @@ -1813,4 +1904,20 @@ describe('agent-teams-mcp tools', () => { 'Unknown team "team-lead". Board tools require an existing configured team with config.json.' ); }); + + it('fails closed for primary queue and inventory tools when team config does not exist', async () => { + const claudeDir = makeClaudeDir(); + const params = { claudeDir, teamName: 'team-lead' }; + const expected = + 'Unknown team "team-lead". Board tools require an existing configured team with config.json.'; + + await expect(getTool('lead_briefing').execute(params)).rejects.toThrow(expected); + await expect( + getTool('task_briefing').execute({ + ...params, + memberName: 'alice', + }) + ).rejects.toThrow(expected); + await expect(getTool('task_list').execute(params)).rejects.toThrow(expected); + }); }); 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 7300608b..0ae0c94e 100644 --- a/src/features/tmux-installer/main/composition/runtimeSupport.ts +++ b/src/features/tmux-installer/main/composition/runtimeSupport.ts @@ -1,8 +1,8 @@ import { TmuxStatusSourceAdapter } from '../adapters/output/sources/TmuxStatusSourceAdapter'; import { - TmuxPlatformCommandExecutor, type RuntimeProcessTableRow, type TmuxPaneRuntimeInfo, + TmuxPlatformCommandExecutor, } from '../infrastructure/runtime/TmuxPlatformCommandExecutor'; const runtimeStatusSource = new TmuxStatusSourceAdapter(); diff --git a/src/features/tmux-installer/main/index.ts b/src/features/tmux-installer/main/index.ts index 3d133b99..d18d99ea 100644 --- a/src/features/tmux-installer/main/index.ts +++ b/src/features/tmux-installer/main/index.ts @@ -17,3 +17,4 @@ export type { RuntimeProcessTableRow, TmuxPaneRuntimeInfo, } from './infrastructure/runtime/TmuxPlatformCommandExecutor'; +export { parseRuntimeProcessTable } from './infrastructure/runtime/TmuxPlatformCommandExecutor'; 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 9514d8ba..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 @@ -96,4 +96,26 @@ describe('TmuxPlatformCommandExecutor', () => { 3_000 ); }); + + it('lists runtime processes inside WSL on Windows instead of using host ps', async () => { + setPlatform('win32'); + const execInPreferredDistro = vi.fn(async () => ({ + exitCode: 0, + stdout: ' 42 1 opencode runtime --team-name demo\n', + stderr: '', + })); + const executor = new TmuxPlatformCommandExecutor( + { + execInPreferredDistro, + getPersistedPreferredDistroSync: () => 'Ubuntu', + } as never, + {} as never + ); + + await expect(executor.listRuntimeProcesses()).resolves.toEqual([ + { pid: 42, ppid: 1, command: 'opencode runtime --team-name demo' }, + ]); + expect(execInPreferredDistro).toHaveBeenCalledWith(['ps', '-ax', '-o', 'pid=,ppid=,command=']); + expect(childProcess.execFile).not.toHaveBeenCalled(); + }); }); diff --git a/src/main/index.ts b/src/main/index.ts index fd91a899..f0df99ac 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'; @@ -142,6 +140,7 @@ import { markRendererUnavailable, safeSendToRenderer, } from './utils/safeWebContentsSend'; +import { earlyElectronUserDataMigrationResult } from './bootstrapUserDataMigration'; import { syncTelemetryFlag } from './sentry'; import { ActiveTeamRegistry, 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/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 402e1308..2e0b50e6 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). @@ -2156,7 +2178,11 @@ export class TeamDataService { private resolveLeadNameFromConfig(config: TeamConfig | null): string { if (!config) return 'team-lead'; - const lead = config.members?.find((m) => m.role?.toLowerCase().includes('lead')); + const members = config.members ?? []; + const lead = + members.find((member) => isLeadMember(member)) ?? + members.find((member) => member.name?.trim().toLowerCase() === 'lead') ?? + members.find((member) => isExplicitLeadRole(member.role)); return lead?.name ?? config.members?.[0]?.name ?? 'team-lead'; } @@ -2729,9 +2755,9 @@ export class TeamDataService { } async requestReview(teamName: string, taskId: string): Promise { - const { leadSessionId } = await this.resolveLeadRuntimeContext(teamName); + const { leadName, leadSessionId } = await this.resolveLeadRuntimeContext(teamName); this.getController(teamName).review.requestReview(taskId, { - from: 'user', + from: leadName, ...(leadSessionId ? { leadSessionId } : {}), }); } @@ -3194,15 +3220,15 @@ export class TeamDataService { if (patch.op === 'set_column') { if (patch.column === 'review') { - const { leadSessionId } = await this.resolveLeadRuntimeContext(teamName); + const { leadName, leadSessionId } = await this.resolveLeadRuntimeContext(teamName); controller.review.requestReview(taskId, { - from: 'user', + from: leadName, ...(leadSessionId ? { leadSessionId } : {}), }); } else { - const { leadSessionId } = await this.resolveLeadRuntimeContext(teamName); + const { leadName, leadSessionId } = await this.resolveLeadRuntimeContext(teamName); controller.review.approveReview(taskId, { - from: 'user', + from: leadName, suppressTaskComment: true, 'notify-owner': true, ...(leadSessionId ? { leadSessionId } : {}), @@ -3211,9 +3237,9 @@ export class TeamDataService { return; } - const { leadSessionId } = await this.resolveLeadRuntimeContext(teamName); + const { leadName, leadSessionId } = await this.resolveLeadRuntimeContext(teamName); controller.review.requestChanges(taskId, { - from: 'user', + from: leadName, comment: patch.comment?.trim() || 'Reviewer requested changes.', ...(patch.op === 'request_changes' && patch.taskRefs?.length ? { taskRefs: patch.taskRefs } diff --git a/src/main/services/team/TeamLaunchStateEvaluator.ts b/src/main/services/team/TeamLaunchStateEvaluator.ts index 9a74353c..e010533b 100644 --- a/src/main/services/team/TeamLaunchStateEvaluator.ts +++ b/src/main/services/team/TeamLaunchStateEvaluator.ts @@ -80,6 +80,23 @@ function normalizeLivenessKind(value: unknown): TeamAgentRuntimeLivenessKind | u : undefined; } +function preservesStrongRuntimeAlive( + value: + | { + runtimeAlive?: boolean; + bootstrapConfirmed?: boolean; + livenessKind?: TeamAgentRuntimeLivenessKind; + } + | undefined +): boolean { + return ( + value?.runtimeAlive === true && + (value.bootstrapConfirmed === true || + value.livenessKind === 'confirmed_bootstrap' || + value.livenessKind === 'runtime_process') + ); +} + function normalizePidSource(value: unknown): TeamAgentRuntimePidSource | undefined { return value === 'lead_process' || value === 'tmux_pane' || @@ -181,7 +198,7 @@ export function summarizePersistedLaunchMembers( continue; } pendingCount += 1; - if (entry.runtimeAlive) { + if (preservesStrongRuntimeAlive(entry)) { runtimeAlivePendingCount += 1; } if (entry.launchState === 'runtime_pending_permission') { @@ -193,7 +210,11 @@ export function summarizePersistedLaunchMembers( runtimeProcessPendingCount += 1; } else if (entry.livenessKind === 'runtime_process_candidate') { runtimeCandidatePendingCount += 1; - } else if (entry.livenessKind === 'not_found' || entry.livenessKind === 'stale_metadata') { + } else if ( + entry.livenessKind === 'not_found' || + entry.livenessKind === 'stale_metadata' || + entry.livenessKind === 'registered_only' + ) { noRuntimePendingCount += 1; } } @@ -369,6 +390,18 @@ function normalizePersistedMemberState( return null; } const providerId = normalizeOptionalTeamProviderId(parsed.providerId); + const bootstrapConfirmed = + toBoolean(parsed.bootstrapConfirmed) || parsed.launchState === 'confirmed_alive'; + const livenessKind = normalizeLivenessKind(parsed.livenessKind); + const runtimeAlive = preservesStrongRuntimeAlive({ + runtimeAlive: toBoolean(parsed.runtimeAlive), + bootstrapConfirmed, + livenessKind, + }); + const sources = normalizeSources(parsed.sources) ?? {}; + if (!runtimeAlive) { + sources.processAlive = undefined; + } const next: PersistedTeamLaunchMemberState = { name: normalizedName, providerId, @@ -399,8 +432,8 @@ function normalizePersistedMemberState( launchIdentity: normalizeLaunchIdentity(parsed.launchIdentity, providerId), launchState: 'starting', agentToolAccepted: toBoolean(parsed.agentToolAccepted), - runtimeAlive: toBoolean(parsed.runtimeAlive), - bootstrapConfirmed: toBoolean(parsed.bootstrapConfirmed), + runtimeAlive, + bootstrapConfirmed, hardFailure: toBoolean(parsed.hardFailure), hardFailureReason: typeof parsed.hardFailureReason === 'string' && parsed.hardFailureReason.trim().length > 0 @@ -411,7 +444,7 @@ function normalizePersistedMemberState( ), runtimePid: normalizeRuntimePid(parsed.runtimePid), runtimeSessionId: normalizeOptionalString(parsed.runtimeSessionId), - livenessKind: normalizeLivenessKind(parsed.livenessKind), + livenessKind, pidSource: normalizePidSource(parsed.pidSource), runtimeDiagnostic: normalizeOptionalString(parsed.runtimeDiagnostic), runtimeDiagnosticSeverity: normalizeDiagnosticSeverity(parsed.runtimeDiagnosticSeverity), @@ -424,7 +457,7 @@ function normalizePersistedMemberState( typeof parsed.lastRuntimeAliveAt === 'string' ? parsed.lastRuntimeAliveAt : undefined, lastEvaluatedAt: typeof parsed.lastEvaluatedAt === 'string' ? parsed.lastEvaluatedAt : updatedAtFallback, - sources: normalizeSources(parsed.sources), + sources: Object.values(sources).some(Boolean) ? sources : undefined, diagnostics: Array.isArray(parsed.diagnostics) ? parsed.diagnostics.filter( (item): item is string => typeof item === 'string' && item.trim().length > 0 @@ -554,14 +587,15 @@ export function snapshotFromRuntimeMemberStatuses(params: { sources.nativeHeartbeat = true; sources.inboxHeartbeat = true; } - if (runtime?.livenessSource === 'process' || runtime?.runtimeAlive) { + const runtimeAlive = preservesStrongRuntimeAlive(runtime); + if (runtime?.livenessSource === 'process' && runtimeAlive) { sources.processAlive = true; } const entry: PersistedTeamLaunchMemberState = { name, launchState: runtime?.launchState ?? 'starting', agentToolAccepted: runtime?.agentToolAccepted === true, - runtimeAlive: runtime?.runtimeAlive === true, + runtimeAlive, bootstrapConfirmed: runtime?.bootstrapConfirmed === true, hardFailure: runtime?.hardFailure === true || runtime?.launchState === 'failed_to_start', hardFailureReason: runtime?.hardFailureReason ?? runtime?.error, @@ -574,7 +608,7 @@ export function snapshotFromRuntimeMemberStatuses(params: { runtimeLastSeenAt: runtime?.livenessLastCheckedAt, firstSpawnAcceptedAt: runtime?.firstSpawnAcceptedAt, lastHeartbeatAt: runtime?.lastHeartbeatAt, - lastRuntimeAliveAt: runtime?.runtimeAlive ? updatedAt : undefined, + lastRuntimeAliveAt: runtimeAlive ? updatedAt : undefined, lastEvaluatedAt: runtime?.updatedAt ?? updatedAt, sources: Object.values(sources).some(Boolean) ? sources : undefined, diagnostics: undefined, @@ -610,6 +644,7 @@ export function snapshotToMemberSpawnStatuses( if (!entry) continue; let status: MemberSpawnStatusEntry['status'] = 'offline'; let livenessSource: MemberSpawnLivenessSource | undefined; + const runtimeAlive = preservesStrongRuntimeAlive(entry); if (entry.launchState === 'failed_to_start') { status = 'error'; } else if (entry.launchState === 'confirmed_alive') { @@ -619,8 +654,8 @@ export function snapshotToMemberSpawnStatuses( entry.launchState === 'runtime_pending_permission' || entry.launchState === 'runtime_pending_bootstrap' ) { - status = entry.runtimeAlive ? 'online' : 'waiting'; - livenessSource = entry.runtimeAlive ? 'process' : undefined; + status = runtimeAlive ? 'online' : 'waiting'; + livenessSource = runtimeAlive ? 'process' : undefined; } else { status = entry.agentToolAccepted ? 'waiting' : 'spawning'; } @@ -631,7 +666,7 @@ export function snapshotToMemberSpawnStatuses( hardFailureReason: entry.hardFailureReason, livenessSource, agentToolAccepted: entry.agentToolAccepted, - runtimeAlive: entry.runtimeAlive, + runtimeAlive, bootstrapConfirmed: entry.bootstrapConfirmed, hardFailure: entry.hardFailure, pendingPermissionRequestIds: entry.pendingPermissionRequestIds, diff --git a/src/main/services/team/TeamMemberLivenessMode.ts b/src/main/services/team/TeamMemberLivenessMode.ts deleted file mode 100644 index 6817d7f7..00000000 --- a/src/main/services/team/TeamMemberLivenessMode.ts +++ /dev/null @@ -1,14 +0,0 @@ -export type TeamMemberLivenessMode = 'diagnostics' | 'strict'; - -export const CLAUDE_TEAM_MEMBER_LIVENESS_MODE_ENV = 'CLAUDE_TEAM_MEMBER_LIVENESS_MODE'; - -export function resolveTeamMemberLivenessModeFromEnv( - env: NodeJS.ProcessEnv = process.env -): TeamMemberLivenessMode { - const raw = env[CLAUDE_TEAM_MEMBER_LIVENESS_MODE_ENV]?.trim().toLowerCase(); - return raw === 'strict' ? 'strict' : 'diagnostics'; -} - -export function isStrictTeamMemberLivenessMode(env: NodeJS.ProcessEnv = process.env): boolean { - return resolveTeamMemberLivenessModeFromEnv(env) === 'strict'; -} diff --git a/src/main/services/team/TeamProvisioningService.ts b/src/main/services/team/TeamProvisioningService.ts index a285af1c..4bf2b3f1 100644 --- a/src/main/services/team/TeamProvisioningService.ts +++ b/src/main/services/team/TeamProvisioningService.ts @@ -177,14 +177,14 @@ import { } from './TeamLaunchStateEvaluator'; import { TeamLaunchStateStore } from './TeamLaunchStateStore'; import { TeamMcpConfigBuilder } from './TeamMcpConfigBuilder'; -import { - isStrongRuntimeEvidence, - resolveTeamMemberRuntimeLiveness, -} from './TeamRuntimeLivenessResolver'; -import { isStrictTeamMemberLivenessMode } from './TeamMemberLivenessMode'; import { TeamMemberLogsFinder } from './TeamMemberLogsFinder'; import { TeamMembersMetaStore } from './TeamMembersMetaStore'; import { TeamMetaStore } from './TeamMetaStore'; +import { + isStrongRuntimeEvidence, + resolveTeamMemberRuntimeLiveness, + sanitizeProcessCommandForDiagnostics, +} from './TeamRuntimeLivenessResolver'; import { TeamSentMessagesStore } from './TeamSentMessagesStore'; import { TeamTaskReader } from './TeamTaskReader'; import { TeamTranscriptProjectResolver } from './TeamTranscriptProjectResolver'; @@ -425,6 +425,54 @@ function parseRuntimeToolMetadata(value: unknown): RuntimeToolMetadata { }; } +function mentionsProcessTableUnavailable(value: string | undefined): boolean { + return /\bprocess table\b.*\bunavailable\b/i.test(value ?? ''); +} + +function buildRuntimeToolMetadataDiagnostics(metadata: RuntimeToolMetadata | undefined): string[] { + if (!metadata) { + return []; + } + const diagnostics: string[] = []; + if (metadata.runtimePid != null) { + diagnostics.push(`runtime pid: ${metadata.runtimePid}`); + } + if (metadata.processCommand) { + const processCommand = sanitizeProcessCommandForDiagnostics(metadata.processCommand); + if (processCommand) { + diagnostics.push(`runtime process command: ${processCommand}`); + } + } + if (metadata.runtimeVersion) { + diagnostics.push(`runtime version: ${metadata.runtimeVersion}`); + } + if (metadata.hostPid != null) { + diagnostics.push(`runtime host pid: ${metadata.hostPid}`); + } + if (metadata.cwd) { + diagnostics.push(`runtime cwd: ${metadata.cwd}`); + } + return diagnostics; +} + +function buildRuntimeDiagnosticForSpawn( + metadata: LiveTeamAgentRuntimeMetadata +): string | undefined { + const baseDiagnostic = metadata.runtimeDiagnostic; + const processTableUnavailable = + mentionsProcessTableUnavailable(baseDiagnostic) || + metadata.diagnostics?.some((diagnostic) => mentionsProcessTableUnavailable(diagnostic)); + if (!processTableUnavailable) { + return baseDiagnostic; + } + if (mentionsProcessTableUnavailable(baseDiagnostic)) { + return baseDiagnostic; + } + return baseDiagnostic + ? `${baseDiagnostic}; process table unavailable` + : 'process table unavailable'; +} + function runtimeTaskRefs(teamName: string, value: unknown): InboxMessage['taskRefs'] | undefined { const refs = normalizeRuntimeStringArray(value); return refs.length > 0 @@ -515,6 +563,32 @@ const PREFLIGHT_BINARY_TIMEOUT_MS = 8000; const PREFLIGHT_AUTH_RETRY_DELAY_MS = 2000; const PREFLIGHT_AUTH_MAX_RETRIES = 2; const OPENCODE_PREFLIGHT_MODEL_PROBE_CONCURRENCY = 2; +const OPENCODE_PROJECT_EVIDENCE_MISSING_DIAGNOSTIC = + 'OpenCode production E2E evidence artifact has no entry for the current working directory'; +const OPENCODE_PROJECT_EVIDENCE_NOTE = + 'OpenCode has not been verified on this project yet. This does not mean the selected models are broken.'; + +function pushUniqueProvisioningWarning(warnings: string[], warning: string): void { + if (!warnings.includes(warning)) { + warnings.push(warning); + } +} + +function isOpenCodeProjectEvidenceMissingDiagnostic(value: string): boolean { + return value.trim() === OPENCODE_PROJECT_EVIDENCE_MISSING_DIAGNOSTIC; +} + +function isOpenCodeProjectEvidenceMissingPrepareFailure( + prepare: TeamRuntimePrepareResult +): prepare is TeamRuntimePrepareResult & { ok: false } { + if (prepare.ok || prepare.reason !== 'e2e_missing') { + return false; + } + const diagnostics = prepare.diagnostics + .map((diagnostic) => diagnostic.trim()) + .filter((diagnostic) => diagnostic.length > 0); + return diagnostics.length > 0 && diagnostics.every(isOpenCodeProjectEvidenceMissingDiagnostic); +} function applyDistinctProvisioningMemberColors< T extends { name: string; color?: string; removedAt?: number }, @@ -1537,7 +1611,11 @@ function summarizeMemberSpawnStatusRecord( runtimeProcessPendingCount += 1; } else if (entry.livenessKind === 'runtime_process_candidate') { runtimeCandidatePendingCount += 1; - } else if (entry.livenessKind === 'not_found' || entry.livenessKind === 'stale_metadata') { + } else if ( + entry.livenessKind === 'not_found' || + entry.livenessKind === 'stale_metadata' || + entry.livenessKind === 'registered_only' + ) { noRuntimePendingCount += 1; } } @@ -3245,12 +3323,13 @@ function updateProgress( function buildLaunchDiagnosticsFromRun( run: ProvisioningRun ): TeamLaunchDiagnosticItem[] | undefined { - if (!run.isLaunch || run.memberSpawnStatuses.size === 0) { + const memberSpawnStatuses = run.memberSpawnStatuses; + if (!run.isLaunch || !memberSpawnStatuses || memberSpawnStatuses.size === 0) { return undefined; } const observedAt = nowIso(); const items: TeamLaunchDiagnosticItem[] = []; - for (const [memberName, entry] of run.memberSpawnStatuses.entries()) { + for (const [memberName, entry] of memberSpawnStatuses.entries()) { if (entry.launchState === 'confirmed_alive') { items.push({ id: `${memberName}:bootstrap_confirmed`, @@ -3286,6 +3365,18 @@ function buildLaunchDiagnosticsFromRun( }); continue; } + if (mentionsProcessTableUnavailable(entry.runtimeDiagnostic)) { + items.push({ + id: `${memberName}:process_table_unavailable`, + memberName, + severity: 'warning', + code: 'process_table_unavailable', + label: `${memberName} - process table unavailable`, + detail: entry.runtimeDiagnostic, + observedAt, + }); + continue; + } if (entry.livenessKind === 'shell_only') { items.push({ id: `${memberName}:tmux_shell_only`, @@ -3322,6 +3413,22 @@ function buildLaunchDiagnosticsFromRun( }); continue; } + if ( + entry.livenessKind === 'registered_only' || + entry.livenessKind === 'stale_metadata' || + entry.livenessKind === 'not_found' + ) { + items.push({ + id: `${memberName}:runtime_not_found`, + memberName, + severity: 'warning', + code: 'runtime_not_found', + label: `${memberName} - no runtime found`, + detail: entry.runtimeDiagnostic, + observedAt, + }); + continue; + } if (entry.agentToolAccepted) { items.push({ id: `${memberName}:spawn_accepted`, @@ -3679,7 +3786,12 @@ export class TeamProvisioningService { private readonly runtimeAdapterProgressByRunId = new Map(); 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; @@ -5882,7 +5994,10 @@ export class TeamProvisioningService { }, diagnostics: mergeRuntimeDiagnostics( previousMember?.diagnostics, - input.diagnostics, + [ + ...normalizeRuntimeStringArray(input.diagnostics), + ...buildRuntimeToolMetadataDiagnostics(input.metadata), + ], input.reason ), }; @@ -6498,17 +6613,13 @@ export class TeamProvisioningService { } this.agentRuntimeSnapshotCache.delete(run.teamName); this.liveTeamAgentRuntimeMetadataCache.delete(run.teamName); - if (isStrictTeamMemberLivenessMode()) { - this.setMemberSpawnStatus(run, spawnedMemberName, 'waiting'); - this.appendMemberBootstrapDiagnostic( - run, - spawnedMemberName, - 'already_running requires strong runtime verification' - ); - void this.reevaluateMemberLaunchStatus(run, spawnedMemberName); - return; - } - this.setMemberSpawnStatus(run, spawnedMemberName, 'online', undefined, 'process'); + this.setMemberSpawnStatus(run, spawnedMemberName, 'waiting'); + this.appendMemberBootstrapDiagnostic( + run, + spawnedMemberName, + 'already_running requires strong runtime verification' + ); + void this.reevaluateMemberLaunchStatus(run, spawnedMemberName); } else { this.setMemberSpawnStatus(run, spawnedMemberName, 'waiting'); } @@ -6924,7 +7035,6 @@ export class TeamProvisioningService { } const updatedAt = nowIso(); - const strictLiveness = isStrictTeamMemberLivenessMode(); const run = runId ? (this.runs.get(runId) ?? null) : null; const persistedTeamMeta = await this.teamMetaStore.getMeta(teamName).catch(() => null); @@ -7066,14 +7176,9 @@ export class TeamProvisioningService { : isSharedOpenCodeHost ? false : backendType !== 'in-process'; - const launchSnapshotAlive = - this.isTeamAlive(teamName) && - (strictLiveness - ? launchMember?.bootstrapConfirmed === true || - launchMember?.launchState === 'confirmed_alive' - : launchMember?.runtimeAlive === true || - launchMember?.bootstrapConfirmed === true || - launchMember?.launchState === 'confirmed_alive'); + const historicalBootstrapConfirmed = + launchMember?.bootstrapConfirmed === true || + launchMember?.launchState === 'confirmed_alive'; let rssBytes = rssPid ? rssBytesByPid.get(rssPid) : undefined; if (rssBytes == null && isSharedOpenCodeHost && typeof rssPid === 'number' && rssPid > 0) { try { @@ -7089,7 +7194,7 @@ export class TeamProvisioningService { snapshotMembers[memberName] = { memberName, - alive: liveRuntimeMember?.alive === true || launchSnapshotAlive, + alive: liveRuntimeMember?.alive === true, restartable, ...(backendType ? { backendType } : {}), ...(memberProviderId ? { providerId: memberProviderId } : {}), @@ -7120,6 +7225,7 @@ export class TeamProvisioningService { ...(liveRuntimeMember?.runtimeLastSeenAt ? { runtimeLastSeenAt: liveRuntimeMember.runtimeLastSeenAt } : {}), + ...(historicalBootstrapConfirmed ? { historicalBootstrapConfirmed: true } : {}), ...(liveRuntimeMember?.runtimeDiagnostic ? { runtimeDiagnostic: liveRuntimeMember.runtimeDiagnostic } : {}), @@ -7593,8 +7699,7 @@ export class TeamProvisioningService { if (!refreshed) return; if ( refreshed.launchState === 'failed_to_start' || - refreshed.launchState === 'confirmed_alive' || - refreshed.runtimeAlive + refreshed.launchState === 'confirmed_alive' ) { return; } @@ -7602,81 +7707,92 @@ export class TeamProvisioningService { if (!refreshedFirstSpawnAcceptedAt) { return; } - if (isStrictTeamMemberLivenessMode()) { - const runtimeByMember = await this.getLiveTeamAgentRuntimeMetadata(run.teamName); - const metadata = - runtimeByMember.get(memberName) ?? - [...runtimeByMember.entries()].find(([candidateName]) => - matchesObservedMemberNameForExpected(candidateName, memberName) - )?.[1]; - const acceptedAtMs = Date.parse(refreshedFirstSpawnAcceptedAt); - const elapsedMs = Number.isFinite(acceptedAtMs) ? Date.now() - acceptedAtMs : Infinity; - const runtimeDiagnostic = metadata?.runtimeDiagnostic; - if (metadata?.livenessKind === 'runtime_process') { - this.setMemberSpawnStatus(run, memberName, 'online', undefined, 'process'); - return; - } - if (metadata?.livenessKind === 'permission_blocked') { - const next = { + const restartPending = run.pendingMemberRestarts.has(memberName); + const runtimeByMember = await this.getLiveTeamAgentRuntimeMetadata(run.teamName); + const metadata = + runtimeByMember.get(memberName) ?? + [...runtimeByMember.entries()].find(([candidateName]) => + matchesObservedMemberNameForExpected(candidateName, memberName) + )?.[1]; + const acceptedAtMs = Date.parse(refreshedFirstSpawnAcceptedAt); + const elapsedMs = Number.isFinite(acceptedAtMs) ? Date.now() - acceptedAtMs : Infinity; + const runtimeDiagnostic = metadata?.runtimeDiagnostic; + if (metadata?.livenessKind === 'runtime_process') { + if (elapsedMs >= MEMBER_BOOTSTRAP_STALL_MS) { + run.memberSpawnStatuses.set(memberName, { ...refreshed, livenessKind: metadata.livenessKind, - runtimeDiagnostic: runtimeDiagnostic ?? 'waiting for permission approval', - runtimeDiagnosticSeverity: metadata.runtimeDiagnosticSeverity ?? 'warning', + runtimeDiagnostic: 'Runtime process is alive, but no bootstrap check-in after 5 min.', + runtimeDiagnosticSeverity: 'warning', livenessLastCheckedAt: nowIso(), - launchState: 'runtime_pending_permission' as const, - }; - run.memberSpawnStatuses.set(memberName, next); - this.emitMemberSpawnChange(run, memberName); - return; + }); } - if ( - metadata?.livenessKind === 'runtime_process_candidate' && - elapsedMs < MEMBER_BOOTSTRAP_STALL_MS - ) { - const next = { - ...refreshed, - livenessKind: metadata.livenessKind, - runtimeDiagnostic: runtimeDiagnostic ?? 'runtime process candidate detected', - runtimeDiagnosticSeverity: metadata.runtimeDiagnosticSeverity ?? 'warning', - livenessLastCheckedAt: nowIso(), - }; - run.memberSpawnStatuses.set(memberName, next); - this.emitMemberSpawnChange(run, memberName); - const stallDelayMs = Math.max( - 1_000, - Date.parse(refreshedFirstSpawnAcceptedAt) + MEMBER_BOOTSTRAP_STALL_MS - Date.now() - ); - const stallKey = `${this.getMemberLaunchGraceKey(run, memberName)}:bootstrap-stall`; - if (!this.pendingTimeouts.has(stallKey)) { - const timer = setTimeout(() => { - this.pendingTimeouts.delete(stallKey); - void this.reevaluateMemberLaunchStatus(run, memberName); - }, stallDelayMs); - timer.unref?.(); - this.pendingTimeouts.set(stallKey, timer); - } - return; - } - const strictReason = - runtimeDiagnostic ?? - (metadata?.livenessKind === 'shell_only' - ? 'Tmux pane is alive, but no teammate runtime process was found.' - : 'Teammate did not join within the launch grace window.'); - this.setMemberSpawnStatus(run, memberName, 'error', strictReason); + this.setMemberSpawnStatus(run, memberName, 'online', undefined, 'process'); return; } - const restartPending = run.pendingMemberRestarts.has(memberName); + if (metadata?.livenessKind === 'permission_blocked') { + const next = { + ...refreshed, + livenessKind: metadata.livenessKind, + runtimeDiagnostic: runtimeDiagnostic ?? 'waiting for permission approval', + runtimeDiagnosticSeverity: metadata.runtimeDiagnosticSeverity ?? 'warning', + livenessLastCheckedAt: nowIso(), + launchState: 'runtime_pending_permission' as const, + }; + run.memberSpawnStatuses.set(memberName, next); + this.emitMemberSpawnChange(run, memberName); + return; + } + if ( + metadata?.livenessKind === 'runtime_process_candidate' && + elapsedMs < MEMBER_BOOTSTRAP_STALL_MS + ) { + const next = { + ...refreshed, + livenessKind: metadata.livenessKind, + runtimeDiagnostic: runtimeDiagnostic ?? 'runtime process candidate detected', + runtimeDiagnosticSeverity: metadata.runtimeDiagnosticSeverity ?? 'warning', + livenessLastCheckedAt: nowIso(), + }; + run.memberSpawnStatuses.set(memberName, next); + this.emitMemberSpawnChange(run, memberName); + const stallDelayMs = Math.max( + 1_000, + Date.parse(refreshedFirstSpawnAcceptedAt) + MEMBER_BOOTSTRAP_STALL_MS - Date.now() + ); + const stallKey = `${this.getMemberLaunchGraceKey(run, memberName)}:bootstrap-stall`; + if (!this.pendingTimeouts.has(stallKey)) { + const timer = setTimeout(() => { + this.pendingTimeouts.delete(stallKey); + void this.reevaluateMemberLaunchStatus(run, memberName); + }, stallDelayMs); + timer.unref?.(); + this.pendingTimeouts.set(stallKey, timer); + } + return; + } + const strictReason = restartPending + ? buildRestartGraceTimeoutReason(memberName) + : (runtimeDiagnostic ?? + (metadata?.livenessKind === 'shell_only' + ? 'Tmux pane is alive, but no teammate runtime process was found.' + : 'Teammate did not join within the launch grace window.')); if (restartPending) { run.pendingMemberRestarts.delete(memberName); } - this.setMemberSpawnStatus( - run, - memberName, - 'error', - restartPending - ? buildRestartGraceTimeoutReason(memberName) - : 'Teammate did not join within the launch grace window.' - ); + run.memberSpawnStatuses.set(memberName, { + ...refreshed, + runtimeAlive: false, + livenessSource: undefined, + bootstrapConfirmed: false, + ...(metadata?.livenessKind ? { livenessKind: metadata.livenessKind } : {}), + ...(runtimeDiagnostic ? { runtimeDiagnostic } : {}), + ...(metadata?.runtimeDiagnosticSeverity + ? { runtimeDiagnosticSeverity: metadata.runtimeDiagnosticSeverity } + : {}), + livenessLastCheckedAt: nowIso(), + }); + this.setMemberSpawnStatus(run, memberName, 'error', strictReason); } private shouldSkipMemberSpawnAudit(run: ProvisioningRun): boolean { @@ -10152,6 +10268,7 @@ export class TeamProvisioningService { runId, providerId: 'opencode', cwd: input.request.cwd, + members: result.members, }); this.aliveRunByTeam.set(input.request.teamName, runId); } @@ -11978,7 +12095,6 @@ export class TeamProvisioningService { async getRuntimeState(teamName: string): Promise { const runId = this.getTrackedRunId(teamName); const run = runId ? (this.runs.get(runId) ?? null) : null; - const strictLiveness = isStrictTeamMemberLivenessMode(); if (!run) { const recovered = await readBootstrapRuntimeState(teamName); @@ -12221,6 +12337,11 @@ export class TeamProvisioningService { // Read config.json to get the actual registered members const registeredNames = await this.getRegisteredTeamMemberNames(run.teamName); if (!registeredNames) { + try { + await fs.promises.access(path.join(getTeamsBasePath(), run.teamName)); + } catch { + return; + } const now = Date.now(); if ( shouldWarnOnUnreadableMemberAuditConfig({ @@ -12351,7 +12472,6 @@ export class TeamProvisioningService { statuses: Record ): Promise> { const runtimeByMember = await this.getLiveTeamAgentRuntimeMetadata(teamName); - const strictLiveness = isStrictTeamMemberLivenessMode(); const nextStatuses = { ...statuses }; for (const [memberName, metadata] of runtimeByMember.entries()) { const resolvedStatusKey = @@ -12370,20 +12490,23 @@ export class TeamProvisioningService { if (!current) { continue; } + const runtimeDiagnostic = buildRuntimeDiagnosticForSpawn(metadata); const nextEntry: MemberSpawnStatusEntry = { ...current, ...(metadata.model ? { runtimeModel: metadata.model } : {}), ...(metadata.livenessKind ? { livenessKind: metadata.livenessKind } : {}), - ...(metadata.runtimeDiagnostic ? { runtimeDiagnostic: metadata.runtimeDiagnostic } : {}), + ...(runtimeDiagnostic ? { runtimeDiagnostic } : {}), ...(metadata.runtimeDiagnosticSeverity ? { runtimeDiagnosticSeverity: metadata.runtimeDiagnosticSeverity } : {}), livenessLastCheckedAt: nowIso(), }; const failureReason = current.hardFailureReason ?? current.error; - const hasStrongEvidence = strictLiveness - ? isStrongRuntimeEvidence(metadata) - : metadata.alive === true; + const hasStrongEvidence = isStrongRuntimeEvidence(metadata); + const hasWeakEvidence = + metadata.livenessKind != null && + !isStrongRuntimeEvidence(metadata) && + current.bootstrapConfirmed !== true; if ( hasStrongEvidence && current.hardFailure !== true && @@ -12412,6 +12535,26 @@ export class TeamProvisioningService { nextEntry.livenessSource = current.bootstrapConfirmed ? current.livenessSource : 'process'; nextEntry.launchState = deriveMemberLaunchState(nextEntry); } + if (hasWeakEvidence) { + nextEntry.runtimeAlive = false; + if (nextEntry.livenessSource === 'process') { + nextEntry.livenessSource = undefined; + } + if ( + current.launchState === 'runtime_pending_bootstrap' || + current.launchState === 'runtime_pending_permission' + ) { + nextEntry.agentToolAccepted = true; + } + if ( + current.status === 'online' && + current.hardFailure !== true && + current.launchState !== 'failed_to_start' + ) { + nextEntry.status = nextEntry.agentToolAccepted ? 'waiting' : 'spawning'; + } + nextEntry.launchState = deriveMemberLaunchState(nextEntry); + } nextStatuses[resolvedStatusKey] = nextEntry; } return nextStatuses; @@ -12669,7 +12812,6 @@ export class TeamProvisioningService { if (cached && cached.expiresAtMs > Date.now()) { return cached.metadata; } - const strictLiveness = isStrictTeamMemberLivenessMode(); const runId = this.getTrackedRunId(teamName); const run = runId ? (this.runs.get(runId) ?? null) : null; @@ -12817,7 +12959,7 @@ export class TeamProvisioningService { upsertMetadata(memberName, { backendType: 'process', providerId: 'opencode', - alive: evidence?.runtimeAlive === true, + alive: false, livenessKind: evidence?.livenessKind, pidSource: evidence?.pidSource, runtimeDiagnostic: evidence?.runtimeDiagnostic, @@ -12829,34 +12971,42 @@ export class TeamProvisioningService { }); } + const currentRuntimeAdapterRun = this.runtimeAdapterRunByTeam.get(teamName); const persistedLaunchSnapshot = await this.launchStateStore.read(teamName).catch(() => null); for (const persistedMember of Object.values(persistedLaunchSnapshot?.members ?? {})) { const memberName = persistedMember.name?.trim() ?? ''; if (!memberName || this.isMemberRemovedInMeta(metaMembers, memberName)) { continue; } + const currentRuntimeAdapterEvidence = currentRuntimeAdapterRun?.members?.[memberName]; upsertMetadata(memberName, { backendType: persistedMember.providerId === 'opencode' ? 'process' : metadataByMember.get(memberName)?.backendType, providerId: persistedMember.providerId, - alive: persistedMember.runtimeAlive === true || persistedMember.bootstrapConfirmed === true, - livenessKind: persistedMember.livenessKind, - pidSource: persistedMember.pidSource, - runtimeDiagnostic: persistedMember.runtimeDiagnostic, + alive: false, + livenessKind: currentRuntimeAdapterEvidence?.livenessKind ?? persistedMember.livenessKind, + pidSource: currentRuntimeAdapterEvidence?.pidSource ?? persistedMember.pidSource, + runtimeDiagnostic: + currentRuntimeAdapterEvidence?.runtimeDiagnostic ?? persistedMember.runtimeDiagnostic, runtimeDiagnosticSeverity: persistedMember.runtimeDiagnosticSeverity, runtimeLastSeenAt: persistedMember.runtimeLastSeenAt ?? persistedMember.lastHeartbeatAt ?? persistedMember.lastRuntimeAliveAt, ...(persistedMember.model?.trim() ? { model: persistedMember.model.trim() } : {}), - ...(typeof persistedMember.runtimePid === 'number' && persistedMember.runtimePid > 0 - ? { metricsPid: persistedMember.runtimePid } - : {}), - ...(persistedMember.runtimeSessionId - ? { runtimeSessionId: persistedMember.runtimeSessionId } - : {}), + ...(typeof currentRuntimeAdapterEvidence?.runtimePid === 'number' && + currentRuntimeAdapterEvidence.runtimePid > 0 + ? { metricsPid: currentRuntimeAdapterEvidence.runtimePid } + : typeof persistedMember.runtimePid === 'number' && persistedMember.runtimePid > 0 + ? { metricsPid: persistedMember.runtimePid } + : {}), + ...(currentRuntimeAdapterEvidence?.sessionId + ? { runtimeSessionId: currentRuntimeAdapterEvidence.sessionId } + : persistedMember.runtimeSessionId + ? { runtimeSessionId: persistedMember.runtimeSessionId } + : {}), }); } @@ -12891,8 +13041,37 @@ export class TeamProvisioningService { for (const [memberName, metadata] of metadataByMember.entries()) { const paneId = metadata.tmuxPaneId?.trim() ?? ''; - const status = this.findTrackedMemberSpawnStatus(run, memberName); const launchMember = persistedLaunchSnapshot?.members[memberName]; + const adapterEvidence = currentRuntimeAdapterRun?.members?.[memberName]; + const adapterStatus: MemberSpawnStatusEntry | undefined = adapterEvidence + ? { + status: adapterEvidence.hardFailure + ? 'error' + : adapterEvidence.bootstrapConfirmed + ? 'online' + : adapterEvidence.agentToolAccepted + ? 'waiting' + : 'spawning', + launchState: adapterEvidence.launchState, + ...(adapterEvidence.hardFailureReason + ? { hardFailureReason: adapterEvidence.hardFailureReason } + : {}), + ...(adapterEvidence.pendingPermissionRequestIds?.length + ? { pendingPermissionRequestIds: adapterEvidence.pendingPermissionRequestIds } + : {}), + agentToolAccepted: adapterEvidence.agentToolAccepted, + runtimeAlive: adapterEvidence.runtimeAlive, + bootstrapConfirmed: adapterEvidence.bootstrapConfirmed, + hardFailure: adapterEvidence.hardFailure, + ...(metadata.model ? { runtimeModel: metadata.model } : {}), + ...(adapterEvidence.livenessKind ? { livenessKind: adapterEvidence.livenessKind } : {}), + ...(adapterEvidence.runtimeDiagnostic + ? { runtimeDiagnostic: adapterEvidence.runtimeDiagnostic } + : {}), + updatedAt: persistedLaunchSnapshot?.updatedAt ?? nowIso(), + } + : undefined; + const status = this.findTrackedMemberSpawnStatus(run, memberName) ?? adapterStatus; const resolved = resolveTeamMemberRuntimeLiveness({ teamName, memberName, @@ -12910,15 +13089,9 @@ export class TeamProvisioningService { processTableAvailable, nowIso: nowIso(), }); - const legacyWeakAlive = - resolved.alive || - (resolved.pidSource === 'tmux_pane' && typeof resolved.pid === 'number') || - (metadata.backendType === 'process' && - typeof metadata.metricsPid === 'number' && - metadata.metricsPid > 0); metadataByMember.set(memberName, { ...metadata, - alive: strictLiveness ? resolved.alive : legacyWeakAlive, + alive: resolved.alive, ...(typeof resolved.pid === 'number' && resolved.pid > 0 ? { pid: resolved.pid } : {}), ...(typeof (resolved.metricsPid ?? metadata.metricsPid) === 'number' && Number.isFinite(resolved.metricsPid ?? metadata.metricsPid) && @@ -13050,7 +13223,11 @@ export class TeamProvisioningService { runtimeProcessPendingCount += 1; } else if (entry.livenessKind === 'runtime_process_candidate') { runtimeCandidatePendingCount += 1; - } else if (entry.livenessKind === 'not_found' || entry.livenessKind === 'stale_metadata') { + } else if ( + entry.livenessKind === 'not_found' || + entry.livenessKind === 'stale_metadata' || + entry.livenessKind === 'registered_only' + ) { noRuntimePendingCount += 1; } } @@ -13100,10 +13277,8 @@ export class TeamProvisioningService { }`; } - const stillStartingCount = Math.max( - 0, - launchSummary.pendingCount - launchSummary.runtimeAlivePendingCount - ); + const runtimeProcessPendingCount = launchSummary.runtimeProcessPendingCount ?? 0; + const stillStartingCount = Math.max(0, launchSummary.pendingCount - runtimeProcessPendingCount); const diagnosticParts = [ launchSummary.shellOnlyPendingCount ? `${launchSummary.shellOnlyPendingCount} shell-only` @@ -13121,16 +13296,15 @@ export class TeamProvisioningService { const diagnosticSuffix = diagnosticParts.length > 0 ? ` - ${diagnosticParts.join(', ')}` : ''; if (launchSummary.confirmedCount === 0) { const allRuntimeAlive = - launchSummary.runtimeAlivePendingCount > 0 && - launchSummary.runtimeAlivePendingCount === expectedTeammateCount; + runtimeProcessPendingCount > 0 && runtimeProcessPendingCount === expectedTeammateCount; return allRuntimeAlive ? `${prefix} — teammates online` - : launchSummary.runtimeAlivePendingCount > 0 - ? `${prefix} — ${launchSummary.runtimeAlivePendingCount}/${expectedTeammateCount} teammate${launchSummary.runtimeAlivePendingCount === 1 ? '' : 's'} online${stillStartingCount > 0 ? `, ${stillStartingCount} still starting` : ''}` + : runtimeProcessPendingCount > 0 + ? `${prefix} — ${runtimeProcessPendingCount}/${expectedTeammateCount} teammate${runtimeProcessPendingCount === 1 ? '' : 's'} online${stillStartingCount > 0 ? `, ${stillStartingCount} still starting` : ''}` : `${prefix} — teammates are still starting${diagnosticSuffix}`; } - return `${prefix} — ${launchSummary.confirmedCount}/${expectedTeammateCount} teammates made contact${launchSummary.runtimeAlivePendingCount > 0 ? `, ${launchSummary.runtimeAlivePendingCount} teammate${launchSummary.runtimeAlivePendingCount === 1 ? '' : 's'} online` : ''}${stillStartingCount > 0 ? `${launchSummary.runtimeAlivePendingCount > 0 ? ', ' : ', '}${stillStartingCount} still joining${diagnosticSuffix}` : ''}`; + return `${prefix} — ${launchSummary.confirmedCount}/${expectedTeammateCount} teammates made contact${runtimeProcessPendingCount > 0 ? `, ${runtimeProcessPendingCount} teammate${runtimeProcessPendingCount === 1 ? '' : 's'} online` : ''}${stillStartingCount > 0 ? `${runtimeProcessPendingCount > 0 ? ', ' : ', '}${stillStartingCount} still joining${diagnosticSuffix}` : ''}`; } private buildAggregatePendingLaunchMessage( @@ -13141,6 +13315,7 @@ export class TeamProvisioningService { pendingCount: number; failedCount: number; runtimeAlivePendingCount: number; + runtimeProcessPendingCount?: number; }, snapshot?: PersistedTeamLaunchSnapshot | null ): string { @@ -13554,10 +13729,14 @@ export class TeamProvisioningService { if (filteredSnapshot.teamLaunchState === 'clean_success' && launchPhase !== 'active') { await this.clearPersistedLaunchState(run.teamName); + this.agentRuntimeSnapshotCache.delete(run.teamName); + this.liveTeamAgentRuntimeMetadataCache.delete(run.teamName); return null; } await this.launchStateStore.write(run.teamName, filteredSnapshot); + this.agentRuntimeSnapshotCache.delete(run.teamName); + this.liveTeamAgentRuntimeMetadataCache.delete(run.teamName); return filteredSnapshot; } @@ -14242,7 +14421,7 @@ export class TeamProvisioningService { }; } - const liveAgentNames = await this.getLiveTeamAgentNames(teamName); + const liveRuntimeByMember = await this.getLiveTeamAgentRuntimeMetadata(teamName); const nextMembers = { ...filteredPersisted.members }; const persistedMemberNames = this.getPersistedLaunchMemberNames(filteredPersisted); const now = nowIso(); @@ -14262,11 +14441,6 @@ export class TeamProvisioningService { current.firstSpawnAcceptedAt = current.firstSpawnAcceptedAt ?? bootstrapMember.firstSpawnAcceptedAt; } - if (bootstrapMember?.runtimeAlive && !current.runtimeAlive) { - current.runtimeAlive = true; - current.lastRuntimeAliveAt = - current.lastRuntimeAliveAt ?? bootstrapMember.lastRuntimeAliveAt; - } if (bootstrapMember?.bootstrapConfirmed && !current.bootstrapConfirmed) { current.bootstrapConfirmed = true; current.lastHeartbeatAt = current.lastHeartbeatAt ?? bootstrapMember.lastHeartbeatAt; @@ -14274,10 +14448,13 @@ export class TeamProvisioningService { const matchedConfigNames = [...configMembers].filter((name) => matchesObservedMemberNameForExpected(name, expected) ); - const observedRuntimeAlive = [...liveAgentNames].some((name) => + const runtimeMetadataCandidates = [...liveRuntimeByMember.entries()].filter(([name]) => matchesObservedMemberNameForExpected(name, expected) ); - const runtimeAlive = current.runtimeAlive === true || observedRuntimeAlive; + const runtimeMetadata = + runtimeMetadataCandidates.find(([, metadata]) => metadata.alive) ?? + runtimeMetadataCandidates[0]; + const observedRuntimeAlive = runtimeMetadata?.[1].alive === true; const heartbeatMessage = this.selectLatestLeadInboxLaunchReconcileMessage( leadInboxMessages, persistedMemberNames, @@ -14289,11 +14466,15 @@ export class TeamProvisioningService { : null; const acceptedAtMs = current.firstSpawnAcceptedAt != null ? Date.parse(current.firstSpawnAcceptedAt) : NaN; - current.runtimeAlive = runtimeAlive; + current.runtimeAlive = observedRuntimeAlive; current.lastRuntimeAliveAt = observedRuntimeAlive ? now : current.lastRuntimeAliveAt; + current.livenessKind = runtimeMetadata?.[1].livenessKind; + current.pidSource = runtimeMetadata?.[1].pidSource; + current.runtimeDiagnostic = runtimeMetadata?.[1].runtimeDiagnostic; + current.runtimeDiagnosticSeverity = runtimeMetadata?.[1].runtimeDiagnosticSeverity; current.sources = { ...(current.sources ?? {}), - processAlive: runtimeAlive || undefined, + processAlive: observedRuntimeAlive || undefined, configRegistered: matchedConfigNames.length > 0 || undefined, configDrift: heartbeatMessage != null && matchedConfigNames.length === 0 @@ -15050,13 +15231,14 @@ export class TeamProvisioningService { run.processKilled = true; run.cancelRequested = true; killTeamProcess(run.child); - if (this.hasSecondaryRuntimeRuns(teamName)) { - await this.stopMixedSecondaryRuntimeLanes(teamName); - } + const stopSecondaryRuntimeLanes = this.hasSecondaryRuntimeRuns(teamName) + ? this.stopMixedSecondaryRuntimeLanes(teamName) + : null; const progress = updateProgress(run, 'disconnected', 'Team stopped by user'); run.onProgress(progress); this.cleanupRun(run); logger.info(`[${teamName}] Process stopped (SIGKILL)`); + await stopSecondaryRuntimeLanes; } private getShutdownTrackedTeamNames(): string[] { @@ -15448,8 +15630,8 @@ export class TeamProvisioningService { killTrackedCliProcesses('SIGKILL'); this.killTransientProbeProcessesForShutdown(); - await this.cancelPendingRuntimeAdapterLaunchesForShutdown(); const initialTracked = await this.stopTrackedTeamsForShutdown('Shutdown'); + await this.cancelPendingRuntimeAdapterLaunchesForShutdown(); // A create/launch may have been inside a per-team lock before it exposed a // run in provisioningRunByTeam. Wait briefly, then rescan to catch anything @@ -15584,7 +15766,15 @@ export class TeamProvisioningService { ); return true; } - this.setMemberSpawnStatus(run, memberName, 'online', undefined, 'process'); + this.agentRuntimeSnapshotCache.delete(run.teamName); + this.liveTeamAgentRuntimeMetadataCache.delete(run.teamName); + this.setMemberSpawnStatus(run, memberName, 'waiting'); + this.appendMemberBootstrapDiagnostic( + run, + memberName, + 'already_running requires strong runtime verification' + ); + void this.reevaluateMemberLaunchStatus(run, memberName); return true; } diff --git a/src/main/services/team/TeamRuntimeLivenessResolver.ts b/src/main/services/team/TeamRuntimeLivenessResolver.ts index 410b7f73..804f9e75 100644 --- a/src/main/services/team/TeamRuntimeLivenessResolver.ts +++ b/src/main/services/team/TeamRuntimeLivenessResolver.ts @@ -128,6 +128,10 @@ function isVerifiedRuntimeProcess(params: { ); } +function isOpenCodeRuntimeProcess(command: string | undefined): boolean { + return (command ?? '').toLowerCase().includes('opencode'); +} + function hasPersistedEvidence(input: ResolveTeamMemberRuntimeLivenessInput): boolean { return Boolean( input.agentId?.trim() || @@ -186,18 +190,6 @@ export function resolveTeamMemberRuntimeLiveness( diagnostics.push('process table unavailable'); } - if (tracked?.bootstrapConfirmed === true || tracked?.launchState === 'confirmed_alive') { - return result({ - alive: true, - livenessKind: 'confirmed_bootstrap', - pidSource: 'runtime_bootstrap', - runtimeSessionId, - runtimeLastSeenAt: tracked.lastHeartbeatAt ?? tracked.updatedAt, - runtimeDiagnostic: 'bootstrap confirmed', - diagnostics: [...diagnostics, 'bootstrap confirmed'], - }); - } - if ( tracked?.launchState === 'runtime_pending_permission' || (tracked?.pendingPermissionRequestIds?.length ?? 0) > 0 @@ -236,15 +228,44 @@ export function resolveTeamMemberRuntimeLiveness( ? input.processRows.find((row) => row.pid === runtimePid) : undefined; if (runtimePidRow && input.providerId === 'opencode') { + const processCommand = sanitizeProcessCommandForDiagnostics(runtimePidRow.command); + if (isOpenCodeRuntimeProcess(runtimePidRow.command)) { + return result({ + alive: true, + livenessKind: 'runtime_process', + pidSource: 'opencode_bridge', + pid: runtimePidRow.pid, + runtimeSessionId, + processCommand, + runtimeDiagnostic: 'OpenCode runtime process detected', + diagnostics: [...diagnostics, 'matched OpenCode runtime pid and process identity'], + }); + } return result({ - alive: true, - livenessKind: 'runtime_process', + alive: false, + livenessKind: 'runtime_process_candidate', pidSource: 'opencode_bridge', pid: runtimePidRow.pid, runtimeSessionId, - processCommand: sanitizeProcessCommandForDiagnostics(runtimePidRow.command), - runtimeDiagnostic: 'OpenCode runtime process detected', - diagnostics: [...diagnostics, 'matched OpenCode runtime pid in process table'], + processCommand, + runtimeDiagnostic: 'OpenCode runtime pid is alive, but process identity is unverified', + runtimeDiagnosticSeverity: 'warning', + diagnostics: [ + ...diagnostics, + 'matched OpenCode runtime pid without OpenCode process identity', + ], + }); + } + + if (tracked?.bootstrapConfirmed === true || tracked?.launchState === 'confirmed_alive') { + return result({ + alive: true, + livenessKind: 'confirmed_bootstrap', + pidSource: 'runtime_bootstrap', + runtimeSessionId, + runtimeLastSeenAt: tracked.lastHeartbeatAt ?? tracked.updatedAt, + runtimeDiagnostic: 'bootstrap confirmed', + diagnostics: [...diagnostics, 'bootstrap confirmed'], }); } @@ -311,6 +332,18 @@ export function resolveTeamMemberRuntimeLiveness( } if (runtimePid && !runtimePidRow) { + if (!input.processTableAvailable) { + return result({ + alive: false, + livenessKind: 'registered_only', + pidSource: 'persisted_metadata', + pid: runtimePid, + runtimeSessionId, + runtimeDiagnostic: 'runtime pid could not be verified because process table is unavailable', + runtimeDiagnosticSeverity: 'warning', + diagnostics: [...diagnostics, 'runtime pid could not be verified'], + }); + } return result({ alive: false, livenessKind: 'stale_metadata', 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/progressPayload.ts b/src/main/services/team/progressPayload.ts index 1ab0ef41..e7ecb51c 100644 --- a/src/main/services/team/progressPayload.ts +++ b/src/main/services/team/progressPayload.ts @@ -18,6 +18,8 @@ export const PROGRESS_LOG_TAIL_LINES = 200; export const PROGRESS_OUTPUT_TAIL_PARTS = 20; export const PROGRESS_LAUNCH_DIAGNOSTICS_LIMIT = 20; const PROGRESS_LAUNCH_DIAGNOSTIC_TEXT_LIMIT = 500; +const SECRET_FLAG_PATTERN = + /(--(?:api-key|token|password|secret|authorization|auth-token)(?:=|\s+))("[^"]*"|'[^']*'|\S+)/gi; /** * Return the trailing `maxLines` of a line-buffered CLI log, joined with "\n" @@ -60,9 +62,10 @@ function boundDiagnosticText(value: string | undefined): string | undefined { if (!trimmed) { return undefined; } - return trimmed.length > PROGRESS_LAUNCH_DIAGNOSTIC_TEXT_LIMIT - ? `${trimmed.slice(0, PROGRESS_LAUNCH_DIAGNOSTIC_TEXT_LIMIT - 3).trimEnd()}...` - : trimmed; + const redacted = trimmed.replace(SECRET_FLAG_PATTERN, '$1[redacted]'); + return redacted.length > PROGRESS_LAUNCH_DIAGNOSTIC_TEXT_LIMIT + ? `${redacted.slice(0, PROGRESS_LAUNCH_DIAGNOSTIC_TEXT_LIMIT - 3).trimEnd()}...` + : redacted; } export function boundLaunchDiagnostics( diff --git a/src/main/services/team/runtime/OpenCodeTeamRuntimeAdapter.ts b/src/main/services/team/runtime/OpenCodeTeamRuntimeAdapter.ts index 0b093732..c13cb0d8 100644 --- a/src/main/services/team/runtime/OpenCodeTeamRuntimeAdapter.ts +++ b/src/main/services/team/runtime/OpenCodeTeamRuntimeAdapter.ts @@ -302,7 +302,7 @@ export class OpenCodeTeamRuntimeAdapter implements TeamLaunchRuntimeAdapter { providerId: this.providerId, launchState: member.launchState, agentToolAccepted: member.agentToolAccepted, - runtimeAlive: member.runtimeAlive, + runtimeAlive: member.bootstrapConfirmed === true, bootstrapConfirmed: member.bootstrapConfirmed, hardFailure: member.hardFailure, hardFailureReason: member.hardFailureReason, @@ -544,7 +544,6 @@ function mapBridgeMemberToRuntimeEvidence( diagnostics: string[] ): TeamRuntimeMemberLaunchEvidence { const confirmed = launchState === 'confirmed_alive'; - const createdOrBlocked = launchState === 'created' || launchState === 'permission_blocked'; const failed = launchState === 'failed'; const hasRuntimePid = typeof runtimePid === 'number' && Number.isFinite(runtimePid) && runtimePid > 0; @@ -552,14 +551,14 @@ function mapBridgeMemberToRuntimeEvidence( const livenessKind = confirmed ? 'confirmed_bootstrap' : pendingRuntimeObserved - ? 'runtime_process' + ? 'runtime_process_candidate' : launchState === 'permission_blocked' ? 'permission_blocked' : runtimeMaterialized || sessionId ? 'runtime_process_candidate' : 'registered_only'; const runtimeDiagnostic = pendingRuntimeObserved - ? 'OpenCode runtime process reported by bridge' + ? 'OpenCode runtime pid reported by bridge without local process verification' : launchState === 'permission_blocked' ? 'OpenCode runtime is waiting for permission approval' : runtimeMaterialized || sessionId @@ -575,8 +574,13 @@ function mapBridgeMemberToRuntimeEvidence( : launchState === 'permission_blocked' ? 'runtime_pending_permission' : 'runtime_pending_bootstrap', - agentToolAccepted: confirmed || createdOrBlocked || runtimeMaterialized, - runtimeAlive: confirmed || pendingRuntimeObserved, + agentToolAccepted: + confirmed || + pendingRuntimeObserved || + launchState === 'permission_blocked' || + runtimeMaterialized || + Boolean(sessionId), + runtimeAlive: confirmed, bootstrapConfirmed: confirmed, hardFailure: failed, hardFailureReason: failed ? 'OpenCode bridge reported member launch failure' : undefined, 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/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 ecd5d8db..fecba68a 100644 --- a/src/renderer/components/team/ProvisioningProgressBlock.tsx +++ b/src/renderer/components/team/ProvisioningProgressBlock.tsx @@ -167,6 +167,9 @@ export const ProvisioningProgressBlock = ({ 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(() => { @@ -298,7 +301,7 @@ export const ProvisioningProgressBlock = ({ errorIndex={errorStepIndex} /> - {launchDiagnostics && launchDiagnostics.length > 0 ? ( + {visibleLaunchDiagnostics.length > 0 ? (
+ {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 5a12e674..09e07a21 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; @@ -192,20 +193,34 @@ function areMemberRuntimeEntriesEquivalent( if (left.size !== right.size) return false; for (const [key, leftEntry] of left) { const rightEntry = right.get(key); + const leftDiagnostics = leftEntry.diagnostics ?? []; + const rightDiagnostics = rightEntry?.diagnostics ?? []; if ( leftEntry.memberName !== rightEntry?.memberName || leftEntry.alive !== rightEntry?.alive || leftEntry.restartable !== rightEntry?.restartable || leftEntry.backendType !== rightEntry?.backendType || + leftEntry.providerId !== rightEntry?.providerId || + leftEntry.providerBackendId !== rightEntry?.providerBackendId || + leftEntry.laneId !== rightEntry?.laneId || + leftEntry.laneKind !== rightEntry?.laneKind || leftEntry.pid !== rightEntry?.pid || leftEntry.runtimeModel !== rightEntry?.runtimeModel || leftEntry.rssBytes !== rightEntry?.rssBytes || leftEntry.livenessKind !== rightEntry?.livenessKind || leftEntry.pidSource !== rightEntry?.pidSource || + leftEntry.processCommand !== rightEntry?.processCommand || + leftEntry.paneId !== rightEntry?.paneId || + leftEntry.panePid !== rightEntry?.panePid || leftEntry.paneCurrentCommand !== rightEntry?.paneCurrentCommand || + leftEntry.runtimePid !== rightEntry?.runtimePid || + leftEntry.runtimeSessionId !== rightEntry?.runtimeSessionId || leftEntry.runtimeDiagnostic !== rightEntry?.runtimeDiagnostic || leftEntry.runtimeDiagnosticSeverity !== rightEntry?.runtimeDiagnosticSeverity || - leftEntry.runtimeLastSeenAt !== rightEntry?.runtimeLastSeenAt + leftEntry.runtimeLastSeenAt !== rightEntry?.runtimeLastSeenAt || + leftEntry.historicalBootstrapConfirmed !== rightEntry?.historicalBootstrapConfirmed || + leftDiagnostics.length !== rightDiagnostics.length || + !leftDiagnostics.every((value, index) => value === rightDiagnostics[index]) ) { return false; } @@ -224,6 +239,7 @@ function areMemberListPropsEqual( arePendingRepliesEquivalent(prev.pendingRepliesByMember, next.pendingRepliesByMember) && areMemberSpawnStatusesEquivalent(prev.memberSpawnStatuses, next.memberSpawnStatuses) && areMemberRuntimeEntriesEquivalent(prev.memberRuntimeEntries, next.memberRuntimeEntries) && + prev.runtimeRunId === next.runtimeRunId && prev.isLaunchSettling === next.isLaunchSettling && prev.isTeamAlive === next.isTeamAlive && prev.isTeamProvisioning === next.isTeamProvisioning && @@ -239,6 +255,7 @@ export const MemberList = memo(function MemberList({ pendingRepliesByMember, memberSpawnStatuses, memberRuntimeEntries, + runtimeRunId, isLaunchSettling, isTeamAlive, isTeamProvisioning, @@ -342,7 +359,9 @@ export const MemberList = memo(function MemberList({ isRemoved ? undefined : runtimeEntry )} runtimeEntry={isRemoved ? undefined : runtimeEntry} + runtimeRunId={isRemoved ? undefined : runtimeRunId} spawnStatus={isRemoved ? undefined : spawnEntry?.status} + spawnEntry={isRemoved ? undefined : spawnEntry} spawnError={isRemoved ? undefined : (spawnEntry?.error ?? spawnEntry?.hardFailureReason)} spawnLivenessSource={isRemoved ? undefined : spawnEntry?.livenessSource} spawnLaunchState={isRemoved ? undefined : spawnEntry?.launchState} 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 1fd55d64..0268548c 100644 --- a/src/renderer/components/team/provisioningSteps.ts +++ b/src/renderer/components/team/provisioningSteps.ts @@ -63,6 +63,10 @@ function isFailedSpawnEntry(entry: MemberSpawnStatusEntry | undefined): boolean return entry?.launchState === 'failed_to_start' || entry?.status === 'error'; } +function isStrongRuntimeProcessSpawnEntry(entry: MemberSpawnStatusEntry): boolean { + return entry.runtimeAlive === true && entry.livenessKind === 'runtime_process'; +} + function shouldPreferSnapshotEntryOverLive( liveEntry: MemberSpawnStatusEntry | undefined, snapshotEntry: MemberSpawnStatusEntry | undefined, @@ -127,11 +131,12 @@ function summarizeLiveLaunchJoinMilestones(params: { heartbeatConfirmedCount += 1; continue; } - if ( - entry.launchState === 'runtime_pending_bootstrap' || - entry.launchState === 'runtime_pending_permission' - ) { - if (entry.runtimeAlive === true && entry.livenessKind !== 'shell_only') { + if (entry.launchState === 'runtime_pending_permission') { + pendingSpawnCount += 1; + continue; + } + if (entry.launchState === 'runtime_pending_bootstrap') { + if (isStrongRuntimeProcessSpawnEntry(entry)) { processOnlyAliveCount += 1; } else { pendingSpawnCount += 1; @@ -196,15 +201,12 @@ export function getLaunchJoinMilestonesFromMembers({ }); if (snapshotSummary) { + const snapshotProcessOnlyAliveCount = snapshotSummary.runtimeProcessPendingCount ?? 0; const snapshotMilestones = { expectedTeammateCount, heartbeatConfirmedCount: snapshotSummary.confirmedCount, - processOnlyAliveCount: - snapshotSummary.runtimeProcessPendingCount ?? snapshotSummary.runtimeAlivePendingCount, - pendingSpawnCount: Math.max( - 0, - snapshotSummary.pendingCount - snapshotSummary.runtimeAlivePendingCount - ), + processOnlyAliveCount: snapshotProcessOnlyAliveCount, + pendingSpawnCount: Math.max(0, snapshotSummary.pendingCount - snapshotProcessOnlyAliveCount), failedSpawnCount: snapshotSummary.failedCount, }; 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 96ab3dac..030ac9b2 100644 --- a/src/renderer/store/slices/teamSlice.ts +++ b/src/renderer/store/slices/teamSlice.ts @@ -810,20 +810,34 @@ function areTeamAgentRuntimeEntriesEqual( ): boolean { if (left === right) return true; if (!left || !right) return left === right; + const leftDiagnostics = left.diagnostics ?? []; + const rightDiagnostics = right.diagnostics ?? []; return ( left.memberName === right.memberName && left.alive === right.alive && left.restartable === right.restartable && left.backendType === right.backendType && + left.providerId === right.providerId && + left.providerBackendId === right.providerBackendId && + left.laneId === right.laneId && + left.laneKind === right.laneKind && left.pid === right.pid && left.runtimeModel === right.runtimeModel && left.rssBytes === right.rssBytes && left.livenessKind === right.livenessKind && left.pidSource === right.pidSource && + left.processCommand === right.processCommand && + left.paneId === right.paneId && + left.panePid === right.panePid && left.paneCurrentCommand === right.paneCurrentCommand && + left.runtimePid === right.runtimePid && + left.runtimeSessionId === right.runtimeSessionId && left.runtimeDiagnostic === right.runtimeDiagnostic && left.runtimeDiagnosticSeverity === right.runtimeDiagnosticSeverity && - left.runtimeLastSeenAt === right.runtimeLastSeenAt + left.runtimeLastSeenAt === right.runtimeLastSeenAt && + left.historicalBootstrapConfirmed === right.historicalBootstrapConfirmed && + leftDiagnostics.length === rightDiagnostics.length && + leftDiagnostics.every((value, index) => value === rightDiagnostics[index]) ); } diff --git a/src/renderer/utils/memberHelpers.ts b/src/renderer/utils/memberHelpers.ts index e7c67c96..c21db4e4 100644 --- a/src/renderer/utils/memberHelpers.ts +++ b/src/renderer/utils/memberHelpers.ts @@ -656,12 +656,6 @@ export function buildMemberLaunchPresentation({ runtimeEntry?.livenessKind === 'not_found' ) { launchVisualState = 'stale_runtime'; - } else if ( - spawnLaunchState === 'runtime_pending_bootstrap' && - (runtimeEntry?.livenessKind === 'runtime_process' || - (spawnStatus === 'online' && spawnRuntimeAlive === true)) - ) { - launchVisualState = 'runtime_pending'; } else if ( isLaunchStillStarting( spawnStatus, @@ -671,6 +665,12 @@ export function buildMemberLaunchPresentation({ ) ) { launchVisualState = spawnStatus === 'spawning' ? 'spawning' : 'waiting'; + } else if ( + spawnLaunchState === 'runtime_pending_bootstrap' && + (runtimeEntry?.livenessKind === 'runtime_process' || + (spawnStatus === 'online' && spawnRuntimeAlive === true)) + ) { + launchVisualState = 'runtime_pending'; } else if ( isLaunchSettling && spawnStatus === 'online' && @@ -681,15 +681,19 @@ export function buildMemberLaunchPresentation({ } const launchStatusLabel = getMemberLaunchStatusLabel(launchVisualState); - const displayPresenceLabel = + const shouldShowLaunchStatusAsPresence = launchVisualState === 'permission_pending' || launchVisualState === 'runtime_pending' || launchVisualState === 'shell_only' || launchVisualState === 'runtime_candidate' || launchVisualState === 'registered_only' || - launchVisualState === 'stale_runtime' - ? (launchStatusLabel ?? presenceLabel) - : presenceLabel; + launchVisualState === 'stale_runtime'; + const displayPresenceLabel = + runtimeAdvisoryTone === 'error' && runtimeAdvisoryLabel + ? runtimeAdvisoryLabel + : shouldShowLaunchStatusAsPresence + ? (launchStatusLabel ?? presenceLabel) + : presenceLabel; const spawnBadgeLabel = spawnStatus && spawnStatus !== 'online' ? spawnStatus === 'waiting' || spawnStatus === 'spawning' 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/teamProvisioningPresentation.ts b/src/renderer/utils/teamProvisioningPresentation.ts index 6d7d6e54..5bc78b84 100644 --- a/src/renderer/utils/teamProvisioningPresentation.ts +++ b/src/renderer/utils/teamProvisioningPresentation.ts @@ -32,6 +32,17 @@ interface FailedSpawnDetail { 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; @@ -126,25 +137,130 @@ function buildAwaitingPermissionPhrase(count: number): string { : `${count} teammates awaiting permission approval`; } -function buildPendingDiagnosticPhrase( - summary: MemberSpawnStatusesSnapshot['summary'] | undefined, - fallbackJoiningPhrase: string -): string { +function getMemberNamesFromSpawnSources(params: { + memberSpawnStatuses: MemberSpawnStatusCollection; + memberSpawnSnapshotStatuses?: MemberSpawnStatusesSnapshot['statuses']; +}): string[] { + const names = new Set(); + if (params.memberSpawnStatuses instanceof Map) { + for (const name of params.memberSpawnStatuses.keys()) { + names.add(name); + } + } else if (params.memberSpawnStatuses) { + for (const name of Object.keys(params.memberSpawnStatuses)) { + names.add(name); + } + } + for (const name of Object.keys(params.memberSpawnSnapshotStatuses ?? {})) { + names.add(name); + } + return [...names].sort((left, right) => left.localeCompare(right)); +} + +function getPendingDiagnosticNameGroups(params: { + memberSpawnStatuses: MemberSpawnStatusCollection; + memberSpawnSnapshotStatuses?: MemberSpawnStatusesSnapshot['statuses']; + memberSpawnSnapshotUpdatedAt?: string; +}): PendingDiagnosticNameGroups { + const groups: PendingDiagnosticNameGroups = { + shellOnly: [], + runtimeProcess: [], + runtimeCandidate: [], + permission: [], + noRuntime: [], + }; + + for (const name of getMemberNamesFromSpawnSources(params)) { + const liveEntry = + params.memberSpawnStatuses instanceof Map + ? params.memberSpawnStatuses.get(name) + : params.memberSpawnStatuses?.[name]; + const snapshotEntry = params.memberSpawnSnapshotStatuses?.[name]; + const entry = getPreferredSpawnEntry({ + liveEntry, + snapshotEntry, + snapshotUpdatedAt: params.memberSpawnSnapshotUpdatedAt, + }); + if (!entry || entry.launchState === 'confirmed_alive' || isFailedSpawnEntry(entry)) { + continue; + } + if ( + entry.launchState === 'runtime_pending_permission' || + (entry.pendingPermissionRequestIds?.length ?? 0) > 0 + ) { + groups.permission.push(name); + continue; + } + if (entry.livenessKind === 'shell_only') { + groups.shellOnly.push(name); + } else if (entry.livenessKind === 'runtime_process') { + groups.runtimeProcess.push(name); + } else if (entry.livenessKind === 'runtime_process_candidate') { + groups.runtimeCandidate.push(name); + } else if ( + entry.livenessKind === 'not_found' || + entry.livenessKind === 'stale_metadata' || + entry.livenessKind === 'registered_only' + ) { + groups.noRuntime.push(name); + } + } + + return groups; +} + +function formatNamedPendingDiagnostic(label: string, names: readonly string[]): string | null { + if (names.length === 0) { + return null; + } + const listedNames = names.slice(0, MAX_PENDING_DIAGNOSTIC_NAMES).join(', '); + const remainingCount = names.length - Math.min(names.length, MAX_PENDING_DIAGNOSTIC_NAMES); + return `${label}: ${listedNames}${remainingCount > 0 ? `, +${remainingCount} more` : ''}`; +} + +function formatCountPendingDiagnostic(count: number | undefined, label: string): string | null { + return count && count > 0 ? `${count} ${label}` : null; +} + +function buildPendingDiagnosticPhrase({ + summary, + memberSpawnStatuses, + memberSpawnSnapshotStatuses, + memberSpawnSnapshotUpdatedAt, + fallbackJoiningPhrase, +}: { + summary: MemberSpawnStatusesSnapshot['summary'] | undefined; + memberSpawnStatuses: MemberSpawnStatusCollection; + memberSpawnSnapshotStatuses?: MemberSpawnStatusesSnapshot['statuses']; + memberSpawnSnapshotUpdatedAt?: string; + fallbackJoiningPhrase: string; +}): string { + const groups = getPendingDiagnosticNameGroups({ + memberSpawnStatuses, + memberSpawnSnapshotStatuses, + memberSpawnSnapshotUpdatedAt, + }); + const namedParts = [ + formatNamedPendingDiagnostic('Shell-only', groups.shellOnly), + formatNamedPendingDiagnostic('Waiting for bootstrap', groups.runtimeProcess), + formatNamedPendingDiagnostic('Process candidates', groups.runtimeCandidate), + formatNamedPendingDiagnostic('Awaiting permission', groups.permission), + formatNamedPendingDiagnostic('No runtime found', groups.noRuntime), + ].filter(Boolean); + if (namedParts.length > 0) { + return namedParts.join(', '); + } if (!summary) { return fallbackJoiningPhrase; } - const parts = [ - summary.shellOnlyPendingCount ? `${summary.shellOnlyPendingCount} shell-only` : '', - summary.runtimeProcessPendingCount - ? `${summary.runtimeProcessPendingCount} waiting for bootstrap` - : '', - summary.runtimeCandidatePendingCount - ? `${summary.runtimeCandidatePendingCount} process candidates` - : '', - summary.permissionPendingCount ? `${summary.permissionPendingCount} awaiting permission` : '', - summary.noRuntimePendingCount ? `${summary.noRuntimePendingCount} no runtime found` : '', + const countParts = [ + formatCountPendingDiagnostic(summary.shellOnlyPendingCount, 'shell-only'), + formatCountPendingDiagnostic(summary.runtimeProcessPendingCount, 'waiting for bootstrap'), + formatCountPendingDiagnostic(summary.runtimeCandidatePendingCount, 'process candidates'), + formatCountPendingDiagnostic(summary.permissionPendingCount, 'awaiting permission'), + formatCountPendingDiagnostic(summary.noRuntimePendingCount, 'no runtime found'), ].filter(Boolean); - return parts.length > 0 ? parts.join(', ') : fallbackJoiningPhrase; + return countParts.length > 0 ? countParts.join(', ') : fallbackJoiningPhrase; } const ACTIVE_PROVISIONING_STATES = new Set([ @@ -415,7 +531,13 @@ export function buildTeamProvisioningPresentation({ permissionBlockedCount === remainingJoinCount; const pendingDetailPhrase = pendingMembersAwaitApproval ? buildAwaitingPermissionPhrase(permissionBlockedCount) - : buildPendingDiagnosticPhrase(memberSpawnSnapshot?.summary, joiningPhrase); + : buildPendingDiagnosticPhrase({ + summary: memberSpawnSnapshot?.summary, + memberSpawnStatuses, + memberSpawnSnapshotStatuses: memberSpawnSnapshot?.statuses, + memberSpawnSnapshotUpdatedAt: memberSpawnSnapshot?.updatedAt, + fallbackJoiningPhrase: joiningPhrase, + }); const readyCompactDetail = failedSpawnCount > 0 ? (failedSpawnCompactDetail ?? @@ -492,7 +614,13 @@ export function buildTeamProvisioningPresentation({ permissionBlockedCount > 0 && permissionBlockedCount === remainingJoinCount ? buildAwaitingPermissionPhrase(permissionBlockedCount) - : buildPendingDiagnosticPhrase(memberSpawnSnapshot?.summary, activeJoiningPhrase); + : buildPendingDiagnosticPhrase({ + summary: memberSpawnSnapshot?.summary, + memberSpawnStatuses, + memberSpawnSnapshotStatuses: memberSpawnSnapshot?.statuses, + memberSpawnSnapshotUpdatedAt: memberSpawnSnapshot?.updatedAt, + fallbackJoiningPhrase: activeJoiningPhrase, + }); return { progress, isActive: true, diff --git a/src/shared/types/team.ts b/src/shared/types/team.ts index c10e0aef..66b4cde3 100644 --- a/src/shared/types/team.ts +++ b/src/shared/types/team.ts @@ -1043,6 +1043,8 @@ export interface TeamAgentRuntimeEntry { runtimeSessionId?: string; runtimeLeaseExpiresAt?: string; runtimeLastSeenAt?: string; + /** True when a previous/persisted launch confirmed bootstrap, separate from current live liveness. */ + historicalBootstrapConfirmed?: boolean; runtimeDiagnostic?: string; runtimeDiagnosticSeverity?: TeamAgentRuntimeDiagnosticSeverity; diagnostics?: string[]; 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/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/OpenCodeTeamRuntimeAdapter.test.ts b/test/main/services/team/OpenCodeTeamRuntimeAdapter.test.ts index 8f334b95..3b6c9b0b 100644 --- a/test/main/services/team/OpenCodeTeamRuntimeAdapter.test.ts +++ b/test/main/services/team/OpenCodeTeamRuntimeAdapter.test.ts @@ -311,7 +311,7 @@ describe('OpenCodeTeamRuntimeAdapter', () => { alice: { providerId: 'opencode', launchState: 'runtime_pending_bootstrap', - runtimeAlive: true, + runtimeAlive: false, bootstrapConfirmed: false, }, }, @@ -501,8 +501,9 @@ describe('OpenCodeTeamRuntimeAdapter', () => { providerId: 'opencode', launchState: 'runtime_pending_permission', pendingPermissionRequestIds: ['perm-1', 'perm-2'], - runtimeAlive: true, + runtimeAlive: false, agentToolAccepted: true, + livenessKind: 'permission_blocked', bootstrapConfirmed: false, hardFailure: false, }, @@ -517,6 +518,116 @@ describe('OpenCodeTeamRuntimeAdapter', () => { }); }); + it('does not mark created bridge members without runtimePid as runtimeAlive', async () => { + const launchOpenCodeTeam = vi.fn( + async () => + ({ + runId: 'run-1', + teamLaunchState: 'launching', + members: { + alice: { + sessionId: 'oc-session-1', + launchState: 'created', + model: 'openai/gpt-5.4-mini', + evidence: [], + }, + }, + warnings: [], + diagnostics: [], + }) satisfies OpenCodeLaunchTeamCommandData + ); + const adapter = new OpenCodeTeamRuntimeAdapter( + bridgePort(readiness({ state: 'ready', launchAllowed: true }), { + launchOpenCodeTeam, + }), + { launchMode: 'dogfood' } + ); + + const result = await adapter.launch(launchInput()); + + expect(result.members.alice).toMatchObject({ + launchState: 'runtime_pending_bootstrap', + agentToolAccepted: true, + runtimeAlive: false, + livenessKind: 'runtime_process_candidate', + runtimeDiagnostic: 'OpenCode session exists without verified runtime pid', + }); + }); + + it('keeps created bridge runtimePid provisional until local process verification', async () => { + const launchOpenCodeTeam = vi.fn( + async () => + ({ + runId: 'run-1', + teamLaunchState: 'launching', + members: { + alice: { + sessionId: 'oc-session-1', + launchState: 'created', + runtimePid: 123, + model: 'openai/gpt-5.4-mini', + evidence: [], + }, + }, + warnings: [], + diagnostics: [], + }) satisfies OpenCodeLaunchTeamCommandData + ); + const adapter = new OpenCodeTeamRuntimeAdapter( + bridgePort(readiness({ state: 'ready', launchAllowed: true }), { + launchOpenCodeTeam, + }), + { launchMode: 'dogfood' } + ); + + const result = await adapter.launch(launchInput()); + + expect(result.members.alice).toMatchObject({ + launchState: 'runtime_pending_bootstrap', + agentToolAccepted: true, + runtimeAlive: false, + livenessKind: 'runtime_process_candidate', + runtimePid: 123, + runtimeDiagnostic: 'OpenCode runtime pid reported by bridge without local process verification', + }); + }); + + it('treats materialized bridge members without session or pid as accepted but not alive', async () => { + const launchOpenCodeTeam = vi.fn( + async () => + ({ + runId: 'run-1', + teamLaunchState: 'launching', + members: { + alice: { + sessionId: '', + launchState: 'created', + model: 'openai/gpt-5.4-mini', + evidence: [], + }, + }, + warnings: [], + diagnostics: [], + }) satisfies OpenCodeLaunchTeamCommandData + ); + const adapter = new OpenCodeTeamRuntimeAdapter( + bridgePort(readiness({ state: 'ready', launchAllowed: true }), { + launchOpenCodeTeam, + }), + { launchMode: 'dogfood' } + ); + + const result = await adapter.launch(launchInput()); + + expect(result.members.alice).toMatchObject({ + launchState: 'runtime_pending_bootstrap', + agentToolAccepted: true, + runtimeAlive: false, + livenessKind: 'runtime_process_candidate', + runtimeDiagnostic: 'OpenCode session exists without verified runtime pid', + }); + }); + it('keeps missing bridge members in bootstrap pending even when another member blocks on permission', async () => { const launchOpenCodeTeam = vi.fn( async () => 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 e5aeae7b..c0baffbb 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 () => { @@ -1486,7 +1506,47 @@ describe('TeamDataService', () => { await service.requestReview('my-team', 'task-1'); expect(requestReviewMock).toHaveBeenCalledWith('task-1', { - from: 'user', + from: 'lead', + leadSessionId: 'lead-1', + }); + }); + + it('resolves the canonical lead instead of matching tech-lead role text', async () => { + const requestReviewMock = vi.fn(); + + const service = new TeamDataService( + { + listTeams: vi.fn(), + getConfig: vi.fn(async () => ({ + name: 'My team', + members: [ + { name: 'alice', role: 'tech lead' }, + { name: 'team-lead', agentType: 'team-lead', role: 'lead' }, + ], + leadSessionId: 'lead-1', + })), + } as never, + {} as never, + {} as never, + {} as never, + {} as never, + {} as never, + {} as never, + {} as never, + {} as never, + {} as never, + () => + ({ + review: { + requestReview: requestReviewMock, + }, + }) as never + ); + + await service.requestReview('my-team', 'task-1'); + + expect(requestReviewMock).toHaveBeenCalledWith('task-1', { + from: 'team-lead', leadSessionId: 'lead-1', }); }); @@ -1511,8 +1571,15 @@ describe('TeamDataService', () => { subject: 'Legacy review task', status: 'completed', owner: 'bob', - reviewState: 'review', - historyEvents: [], + reviewState: 'none', + historyEvents: [ + { + id: 'evt-created', + type: 'task_created', + status: 'completed', + timestamp: '2026-03-01T09:00:00.000Z', + }, + ], }, ]), } as never, @@ -1550,6 +1617,129 @@ describe('TeamDataService', () => { }); }); + it('does not leak stale reviewer after review is reset to pending', async () => { + const service = new TeamDataService( + { + listTeams: vi.fn(), + getConfig: vi.fn(async () => ({ + name: 'My team', + members: [ + { name: 'lead', role: 'team lead' }, + { name: 'bob', role: 'developer' }, + { name: 'carol', role: 'reviewer' }, + ], + })), + } as never, + { + getTasks: vi.fn(async () => [ + { + id: 'task-reopened', + subject: 'Reopened task', + status: 'pending', + owner: 'bob', + reviewState: 'none', + historyEvents: [ + { + id: 'evt-review', + type: 'review_requested', + from: 'none', + to: 'review', + reviewer: 'carol', + timestamp: '2026-03-01T10:00:00.000Z', + }, + { + id: 'evt-pending', + type: 'status_changed', + from: 'completed', + to: 'pending', + timestamp: '2026-03-01T10:05:00.000Z', + }, + ], + }, + ]), + } as never, + { + listInboxNames: vi.fn(async () => []), + getMessages: vi.fn(async () => []), + } as never, + {} as never, + {} as never, + { + resolveMembers: vi.fn(() => []), + } as never, + { + getState: vi.fn(async () => ({ teamName: 'my-team', reviewers: [], tasks: {} })), + } as never + ); + + const data = await service.getTeamData('my-team'); + + expect(data.tasks[0]).toMatchObject({ + id: 'task-reopened', + reviewState: 'none', + reviewer: null, + }); + }); + + it('applies kanban overlay review state in global task projections', async () => { + const service = new TeamDataService( + { + listTeams: vi.fn(async () => [ + { + teamName: 'my-team', + displayName: 'My team', + projectPath: '/repo', + }, + ]), + } as never, + { + getAllTasks: vi.fn(async () => [ + { + id: 'task-global-review', + teamName: 'my-team', + subject: 'Global review task', + status: 'completed', + owner: 'bob', + reviewState: 'none', + historyEvents: [ + { + id: 'evt-created', + type: 'task_created', + status: 'completed', + timestamp: '2026-03-01T09:00:00.000Z', + }, + ], + }, + ]), + } as never, + {} as never, + {} as never, + {} as never, + {} as never, + { + getState: vi.fn(async () => ({ + teamName: 'my-team', + reviewers: [], + tasks: { + 'task-global-review': { + column: 'review', + reviewer: 'carol', + movedAt: '2026-03-01T10:00:00.000Z', + }, + }, + })), + } as never + ); + + const tasks = await service.getAllTasks(); + + expect(tasks[0]).toMatchObject({ + id: 'task-global-review', + reviewState: 'review', + kanbanColumn: 'review', + }); + }); + it('propagates leadSessionId for kanban-driven review transitions', async () => { const requestReviewMock = vi.fn(); const approveReviewMock = vi.fn(); @@ -1585,20 +1775,23 @@ describe('TeamDataService', () => { await service.updateKanban('my-team', 'task-1', { op: 'set_column', column: 'review' }); await service.updateKanban('my-team', 'task-1', { op: 'set_column', column: 'approved' }); - await service.updateKanban('my-team', 'task-1', { op: 'request_changes', comment: 'Needs fixes' }); + await service.updateKanban('my-team', 'task-1', { + op: 'request_changes', + comment: 'Needs fixes', + }); expect(requestReviewMock).toHaveBeenCalledWith('task-1', { - from: 'user', + from: 'lead', leadSessionId: 'lead-2', }); expect(approveReviewMock).toHaveBeenCalledWith('task-1', { - from: 'user', + from: 'lead', suppressTaskComment: true, 'notify-owner': true, leadSessionId: 'lead-2', }); expect(requestChangesMock).toHaveBeenCalledWith('task-1', { - from: 'user', + from: 'lead', comment: 'Needs fixes', leadSessionId: 'lead-2', }); @@ -1615,10 +1808,12 @@ describe('TeamDataService', () => { ensureFile: vi.fn(async () => { journalExists = true; }), - withEntries: vi.fn(async (_teamName: string, fn: (entries: unknown[]) => Promise<{ result: unknown }>) => { - const outcome = await fn(journalEntries); - return outcome.result; - }), + withEntries: vi.fn( + async (_teamName: string, fn: (entries: unknown[]) => Promise<{ result: unknown }>) => { + const outcome = await fn(journalEntries); + return outcome.result; + } + ), }; try { @@ -1707,10 +1902,12 @@ describe('TeamDataService', () => { const journal = { exists: vi.fn(async () => true), ensureFile: vi.fn(async () => undefined), - withEntries: vi.fn(async (_teamName: string, fn: (entries: unknown[]) => Promise<{ result: unknown }>) => { - const outcome = await fn(journalEntries); - return outcome.result; - }), + withEntries: vi.fn( + async (_teamName: string, fn: (entries: unknown[]) => Promise<{ result: unknown }>) => { + const outcome = await fn(journalEntries); + return outcome.result; + } + ), }; try { @@ -1776,10 +1973,9 @@ describe('TeamDataService', () => { messageId: 'task-comment-forward:my-team:task-1:comment-1', }) ); - const firstSendRequest = (inboxWriter.sendMessage as unknown as { mock: { calls: unknown[][] } }) - .mock.calls[0]?.[1] as - | { text?: string } - | undefined; + const firstSendRequest = ( + inboxWriter.sendMessage as unknown as { mock: { calls: unknown[][] } } + ).mock.calls[0]?.[1] as { text?: string } | undefined; expect(String(firstSendRequest?.text ?? '')).not.toContain(''); const sentEntry = journalEntries.find((entry) => entry.key === 'task-1:comment-1'); expect(sentEntry).toMatchObject({ @@ -1803,10 +1999,12 @@ describe('TeamDataService', () => { ensureFile: vi.fn(async () => { journalExists = true; }), - withEntries: vi.fn(async (_teamName: string, fn: (entries: unknown[]) => Promise<{ result: unknown }>) => { - const outcome = await fn(journalEntries); - return outcome.result; - }), + withEntries: vi.fn( + async (_teamName: string, fn: (entries: unknown[]) => Promise<{ result: unknown }>) => { + const outcome = await fn(journalEntries); + return outcome.result; + } + ), }; try { @@ -1902,10 +2100,12 @@ describe('TeamDataService', () => { const journal = { exists: vi.fn(async () => true), ensureFile: vi.fn(async () => undefined), - withEntries: vi.fn(async (_teamName: string, fn: (entries: unknown[]) => Promise<{ result: unknown }>) => { - const outcome = await fn(journalEntries); - return outcome.result; - }), + withEntries: vi.fn( + async (_teamName: string, fn: (entries: unknown[]) => Promise<{ result: unknown }>) => { + const outcome = await fn(journalEntries); + return outcome.result; + } + ), }; try { @@ -1983,10 +2183,12 @@ describe('TeamDataService', () => { const journal = { exists: vi.fn(async () => true), ensureFile: vi.fn(async () => undefined), - withEntries: vi.fn(async (_teamName: string, fn: (entries: unknown[]) => Promise<{ result: unknown }>) => { - const outcome = await fn(journalEntries); - return outcome.result; - }), + withEntries: vi.fn( + async (_teamName: string, fn: (entries: unknown[]) => Promise<{ result: unknown }>) => { + const outcome = await fn(journalEntries); + return outcome.result; + } + ), }; try { @@ -2081,15 +2283,20 @@ describe('TeamDataService', () => { }, ]; const inboxWriter = { - sendMessage: vi.fn(async () => ({ deliveredToInbox: true, messageId: 'task-comment-forward:my-team:task-1:comment-1' })), + sendMessage: vi.fn(async () => ({ + deliveredToInbox: true, + messageId: 'task-comment-forward:my-team:task-1:comment-1', + })), }; const journal = { exists: vi.fn(async () => true), ensureFile: vi.fn(async () => undefined), - withEntries: vi.fn(async (_teamName: string, fn: (entries: unknown[]) => Promise<{ result: unknown }>) => { - const outcome = await fn(journalEntries); - return outcome.result; - }), + withEntries: vi.fn( + async (_teamName: string, fn: (entries: unknown[]) => Promise<{ result: unknown }>) => { + const outcome = await fn(journalEntries); + return outcome.result; + } + ), }; try { @@ -2183,10 +2390,12 @@ describe('TeamDataService', () => { const journal = { exists: vi.fn(async () => true), ensureFile: vi.fn(async () => undefined), - withEntries: vi.fn(async (_teamName: string, fn: (entries: unknown[]) => Promise<{ result: unknown }>) => { - const outcome = await fn(journalEntries); - return outcome.result; - }), + withEntries: vi.fn( + async (_teamName: string, fn: (entries: unknown[]) => Promise<{ result: unknown }>) => { + const outcome = await fn(journalEntries); + return outcome.result; + } + ), }; try { @@ -2283,10 +2492,12 @@ describe('TeamDataService', () => { const journal = { exists: vi.fn(async () => true), ensureFile: vi.fn(async () => undefined), - withEntries: vi.fn(async (_teamName: string, fn: (entries: unknown[]) => Promise<{ result: unknown }>) => { - const outcome = await fn(journalEntries); - return outcome.result; - }), + withEntries: vi.fn( + async (_teamName: string, fn: (entries: unknown[]) => Promise<{ result: unknown }>) => { + const outcome = await fn(journalEntries); + return outcome.result; + } + ), }; try { @@ -2368,10 +2579,12 @@ describe('TeamDataService', () => { const journal = { exists: vi.fn(async () => true), ensureFile: vi.fn(async () => undefined), - withEntries: vi.fn(async (_teamName: string, fn: (entries: unknown[]) => Promise<{ result: unknown }>) => { - const outcome = await fn(journalEntries); - return outcome.result; - }), + withEntries: vi.fn( + async (_teamName: string, fn: (entries: unknown[]) => Promise<{ result: unknown }>) => { + const outcome = await fn(journalEntries); + return outcome.result; + } + ), }; try { @@ -2611,15 +2824,19 @@ describe('TeamDataService', () => { const initGate = new Promise((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 { @@ -3420,7 +3637,9 @@ describe('TeamDataService', () => { }); const feed = await service.getMessageFeed('my-team'); - const linked = feed.messages.find((message) => message.messageId === 'passive-user-summary-old-1'); + const linked = feed.messages.find( + (message) => message.messageId === 'passive-user-summary-old-1' + ); expect(linked?.relayOfMessageId).toBeUndefined(); }); @@ -3988,10 +4207,7 @@ describe('TeamDataService', () => { ), ]); - const firstSpy = vi.spyOn( - firstService as never, - 'extractLeadAssistantTextsFromJsonl' as never - ); + const firstSpy = vi.spyOn(firstService as never, 'extractLeadAssistantTextsFromJsonl' as never); const secondSpy = vi.spyOn( secondService as never, 'extractLeadAssistantTextsFromJsonl' as never @@ -4106,7 +4322,9 @@ describe('TeamDataService', () => { const service = createResolverBackedService(); const page = await service.getMessagesPage(fixture.teamName, { limit: 20 }); - const leadSessionMessages = page.messages.filter((message) => message.source === 'lead_session'); + const leadSessionMessages = page.messages.filter( + (message) => message.source === 'lead_session' + ); expect( leadSessionMessages.some((message) => @@ -4187,12 +4405,7 @@ describe('TeamDataService', () => { await flushMicrotasks(); expect(order).toEqual( - expect.arrayContaining([ - 'inboxNames:start', - 'meta:start', - 'kanban:start', - 'tasks:start', - ]) + expect.arrayContaining(['inboxNames:start', 'meta:start', 'kanban:start', 'tasks:start']) ); expect(order).not.toContain('processes:start'); expect(order).not.toContain('leadTexts:start'); @@ -4453,7 +4666,11 @@ describe('TeamDataService', () => { const feed = await harness.service.getMessageFeed('my-team'); - expect(feed.messages.map((message) => message.messageId)).toEqual(['sent-1', 'lead-1', 'inbox-1']); + expect(feed.messages.map((message) => message.messageId)).toEqual([ + 'sent-1', + 'lead-1', + 'inbox-1', + ]); }); it('preserves assembled messages and resolver inputs when inbox messages fail', async () => { @@ -4571,10 +4788,12 @@ describe('TeamDataService', () => { }, }); - vi.spyOn(harness.service as never, 'extractLeadSessionTexts' as never).mockImplementation(() => { - order.push('leadTexts:start'); - throw new Error('lead sync fail'); - }); + vi.spyOn(harness.service as never, 'extractLeadSessionTexts' as never).mockImplementation( + () => { + order.push('leadTexts:start'); + throw new Error('lead sync fail'); + } + ); const pending = harness.service.getTeamData('my-team'); await flushMicrotasks(); @@ -4665,7 +4884,16 @@ describe('TeamDataService', () => { }); describe('getMessagesPage', () => { - function createPaginationService(messages: Array<{ from: string; text: string; timestamp: string; messageId?: string; source?: string; leadSessionId?: string }>) { + function createPaginationService( + messages: Array<{ + from: string; + text: string; + timestamp: string; + messageId?: string; + source?: string; + leadSessionId?: string; + }> + ) { return new TeamDataService( { listTeams: vi.fn(), @@ -4678,17 +4906,17 @@ describe('TeamDataService', () => { { getTasks: vi.fn(async () => []) } as never, { listInboxNames: vi.fn(async () => []), - getMessages: vi.fn(async () => - messages.map((m) => ({ ...m, read: true })) - ), + getMessages: vi.fn(async () => messages.map((m) => ({ ...m, read: true }))), } as never, {} as never, {} as never, { resolveMembers: vi.fn(() => []) } as never, - { getState: vi.fn(async () => ({ teamName: 'my-team', reviewers: [], tasks: {} })) } as never, + { + getState: vi.fn(async () => ({ teamName: 'my-team', reviewers: [], tasks: {} })), + } as never, {} as never, {} as never, - { readMessages: vi.fn(async () => []) } as never, + { readMessages: vi.fn(async () => []) } as never ); } @@ -4788,7 +5016,9 @@ describe('TeamDataService', () => { expect(page1.messages[0]?.messageId).toMatch(/^inbox-/); expect(page1.nextCursor).toContain(page1.messages[0]!.messageId!); expect(page2.messages.every((message) => Boolean(message.messageId))).toBe(true); - expect(new Set([...page1.messages, ...page2.messages].map((message) => message.messageId)).size).toBe(3); + expect( + new Set([...page1.messages, ...page2.messages].map((message) => message.messageId)).size + ).toBe(3); }); it('dedups newest-page live overlay against durable lead thoughts that already paged off the first page', async () => { @@ -4872,7 +5102,10 @@ describe('TeamDataService', () => { cursor: page1.nextCursor!, }); - expect(page2.messages.map((message) => message.messageId)).toEqual(['durable-2', 'durable-1']); + expect(page2.messages.map((message) => message.messageId)).toEqual([ + 'durable-2', + 'durable-1', + ]); }); }); }); diff --git a/test/main/services/team/TeamLaunchStateEvaluator.test.ts b/test/main/services/team/TeamLaunchStateEvaluator.test.ts index d6042194..dff9760f 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,12 +55,13 @@ describe('TeamLaunchStateEvaluator', () => { }); expect(statuses.bob).toMatchObject({ launchState: 'runtime_pending_permission', - status: 'online', + status: 'waiting', + runtimeAlive: false, pendingPermissionRequestIds: ['req-1'], }); }); - it('counts persisted members in launch summary even when expectedMembers is stale', () => { + it('does not count weak persisted runtimeAlive without strong liveness evidence', () => { const summary = summarizePersistedLaunchMembers(['alice'], { alice: { launchState: 'runtime_pending_bootstrap', @@ -75,7 +77,7 @@ describe('TeamLaunchStateEvaluator', () => { confirmedCount: 0, pendingCount: 2, failedCount: 0, - runtimeAlivePendingCount: 1, + runtimeAlivePendingCount: 0, shellOnlyPendingCount: 0, runtimeProcessPendingCount: 0, runtimeCandidatePendingCount: 0, @@ -83,4 +85,78 @@ describe('TeamLaunchStateEvaluator', () => { permissionPendingCount: 1, }); }); + + it('counts registered-only persisted liveness as no-runtime pending', () => { + const summary = summarizePersistedLaunchMembers(['alice'], { + alice: { + launchState: 'runtime_pending_bootstrap', + runtimeAlive: false, + livenessKind: 'registered_only', + }, + } as any); + + expect(summary).toMatchObject({ + pendingCount: 1, + runtimeAlivePendingCount: 0, + noRuntimePendingCount: 1, + }); + }); + + it('preserves persisted runtimeAlive only with strong liveness evidence', () => { + const summary = summarizePersistedLaunchMembers(['alice', 'bob', 'cara'], { + alice: { + launchState: 'runtime_pending_bootstrap', + runtimeAlive: true, + livenessKind: 'runtime_process', + }, + bob: { + launchState: 'runtime_pending_bootstrap', + runtimeAlive: true, + bootstrapConfirmed: true, + }, + cara: { + launchState: 'runtime_pending_bootstrap', + runtimeAlive: true, + livenessKind: 'runtime_process_candidate', + }, + } as any); + + expect(summary).toMatchObject({ + pendingCount: 3, + runtimeAlivePendingCount: 2, + runtimeCandidatePendingCount: 1, + }); + }); + + it('normalizes stale persisted runtimeAlive to false without strong liveness evidence', () => { + const snapshot = normalizePersistedLaunchSnapshot('demo', { + version: 2, + teamName: 'demo', + updatedAt: '2026-04-23T00:00:00.000Z', + launchPhase: 'active', + expectedMembers: ['alice'], + members: { + alice: { + name: 'alice', + launchState: 'runtime_pending_bootstrap', + agentToolAccepted: true, + runtimeAlive: true, + bootstrapConfirmed: false, + hardFailure: false, + livenessKind: 'runtime_process_candidate', + sources: { + processAlive: true, + }, + lastEvaluatedAt: '2026-04-23T00:00:00.000Z', + }, + }, + }); + + expect(snapshot?.members.alice).toMatchObject({ + launchState: 'runtime_pending_bootstrap', + runtimeAlive: false, + livenessKind: 'runtime_process_candidate', + }); + expect(snapshot?.members.alice.sources?.processAlive).toBeUndefined(); + }); }); diff --git a/test/main/services/team/TeamMemberLivenessMode.test.ts b/test/main/services/team/TeamMemberLivenessMode.test.ts deleted file mode 100644 index de03b83b..00000000 --- a/test/main/services/team/TeamMemberLivenessMode.test.ts +++ /dev/null @@ -1,28 +0,0 @@ -import { describe, expect, it } from 'vitest'; - -import { - CLAUDE_TEAM_MEMBER_LIVENESS_MODE_ENV, - resolveTeamMemberLivenessModeFromEnv, -} from '@main/services/team/TeamMemberLivenessMode'; - -describe('resolveTeamMemberLivenessModeFromEnv', () => { - it('defaults to diagnostics', () => { - expect(resolveTeamMemberLivenessModeFromEnv({})).toBe('diagnostics'); - }); - - it('enables strict mode explicitly', () => { - expect( - resolveTeamMemberLivenessModeFromEnv({ - [CLAUDE_TEAM_MEMBER_LIVENESS_MODE_ENV]: 'strict', - }) - ).toBe('strict'); - }); - - it('falls back to diagnostics for unknown values', () => { - expect( - resolveTeamMemberLivenessModeFromEnv({ - [CLAUDE_TEAM_MEMBER_LIVENESS_MODE_ENV]: 'yes', - }) - ).toBe('diagnostics'); - }); -}); diff --git a/test/main/services/team/TeamProvisioningService.test.ts b/test/main/services/team/TeamProvisioningService.test.ts index 69cb20bf..554cc524 100644 --- a/test/main/services/team/TeamProvisioningService.test.ts +++ b/test/main/services/team/TeamProvisioningService.test.ts @@ -752,6 +752,71 @@ describe('TeamProvisioningService', () => { }); }); + it('keeps RSS visible for bootstrap-confirmed Anthropic teammates with a verified process', async () => { + const svc = new TeamProvisioningService(); + (svc as any).configReader = { + getConfig: vi.fn(async () => ({ + members: [ + { name: 'team-lead', agentType: 'team-lead' }, + { name: 'alice', providerId: 'anthropic', model: 'claude-sonnet-4-6' }, + ], + })), + }; + (svc as any).readPersistedRuntimeMembers = vi.fn(() => [ + { + name: 'alice', + agentId: 'alice@nice-team', + backendType: 'tmux', + }, + ]); + const run = createMemberSpawnRun({ + teamName: 'nice-team', + expectedMembers: ['alice'], + memberSpawnStatuses: new Map([ + [ + 'alice', + createMemberSpawnStatusEntry({ + status: 'online', + launchState: 'confirmed_alive', + runtimeAlive: true, + bootstrapConfirmed: true, + lastHeartbeatAt: '2026-04-24T12:00:00.000Z', + }), + ], + ]), + }); + run.child = { pid: 111 }; + run.request = { model: 'claude-opus-4-6' }; + run.processKilled = false; + run.cancelRequested = false; + (svc as any).aliveRunByTeam.set('nice-team', run.runId); + (svc as any).runs.set(run.runId, run); + vi.mocked(listRuntimeProcessesForCurrentTmuxPlatform).mockResolvedValueOnce([ + { + pid: 333, + ppid: 1, + command: + '/Users/belief/.bun/bin/bun cli.js --agent-id alice@nice-team --agent-name alice --team-name nice-team --model claude-sonnet-4-6', + }, + ]); + vi.mocked(pidusage).mockResolvedValueOnce({ + '111': createPidusageStat(111, 123_000_000), + '333': createPidusageStat(333, 456_000_000), + } as any); + + const snapshot = await svc.getTeamAgentRuntimeSnapshot('nice-team'); + + expect(pidusage).toHaveBeenCalledWith([111, 333], { maxage: 0 }); + expect(snapshot.members.alice).toMatchObject({ + alive: true, + providerId: 'anthropic', + pid: 333, + pidSource: 'agent_process_table', + rssBytes: 456_000_000, + runtimeModel: 'claude-sonnet-4-6', + }); + }); + it('prefers the newest matching agent pid when multiple processes match the same teammate', async () => { const svc = new TeamProvisioningService(); (svc as any).configReader = { @@ -836,8 +901,8 @@ describe('TeamProvisioningService', () => { expect(snapshot.members.alice).toBeUndefined(); }); - it('keeps pure OpenCode launch members alive from confirmed launch snapshot while runtime adapter is tracked', async () => { - const teamName = 'pure-opencode-runtime-team'; + it('keeps historical bootstrap separate from current runtime liveness', async () => { + const teamName = 'pure-opencode-runtime-team-strict'; const projectPath = '/Users/test/project'; writeLaunchConfig(teamName, projectPath, 'lead-session', ['alice']); writeLaunchState(teamName, 'lead-session', { @@ -864,12 +929,102 @@ describe('TeamProvisioningService', () => { const snapshot = await svc.getTeamAgentRuntimeSnapshot(teamName); expect(snapshot.members.alice).toMatchObject({ - alive: true, + alive: false, + historicalBootstrapConfirmed: true, providerId: 'opencode', runtimeModel: 'opencode/big-pickle', }); }); + it('does not treat a reused OpenCode runtime pid as live', async () => { + const teamName = 'pure-opencode-reused-pid-team'; + const projectPath = '/Users/test/project'; + writeLaunchConfig(teamName, projectPath, 'lead-session', ['alice']); + writeLaunchState(teamName, 'lead-session', { + alice: { + providerId: 'opencode', + model: 'opencode/big-pickle', + launchState: 'runtime_pending_bootstrap', + agentToolAccepted: true, + runtimeAlive: true, + bootstrapConfirmed: false, + hardFailure: false, + hardFailureReason: undefined, + runtimePid: 333, + runtimeSessionId: 'session-alice', + }, + }); + vi.mocked(listRuntimeProcessesForCurrentTmuxPlatform).mockResolvedValueOnce([ + { pid: 333, ppid: 1, command: 'node unrelated-worker.js' }, + ]); + vi.mocked(pidusage).mockResolvedValueOnce({ + '333': createPidusageStat(333, 456_000_000), + } as any); + + const svc = new TeamProvisioningService(); + (svc as any).runtimeAdapterRunByTeam.set(teamName, { + runId: 'opencode-runtime-run', + providerId: 'opencode', + cwd: projectPath, + }); + (svc as any).aliveRunByTeam.set(teamName, 'opencode-runtime-run'); + + const snapshot = await svc.getTeamAgentRuntimeSnapshot(teamName); + + expect(snapshot.members.alice).toMatchObject({ + alive: false, + livenessKind: 'runtime_process_candidate', + pidSource: 'opencode_bridge', + runtimeDiagnostic: 'OpenCode runtime pid is alive, but process identity is unverified', + pid: 333, + providerId: 'opencode', + }); + }); + + it('does not carry stale persisted runtimeAlive through launch-state reconcile', async () => { + const teamName = 'persisted-stale-runtime-status-team'; + const projectPath = '/Users/test/project'; + const acceptedAt = new Date(Date.now() - 120_000).toISOString(); + writeLaunchConfig(teamName, projectPath, 'lead-session', ['alice']); + writeLaunchState(teamName, 'lead-session', { + alice: { + providerId: 'codex', + model: 'gpt-5.4', + launchState: 'runtime_pending_bootstrap', + agentToolAccepted: true, + runtimeAlive: true, + bootstrapConfirmed: false, + hardFailure: false, + hardFailureReason: undefined, + firstSpawnAcceptedAt: acceptedAt, + runtimePid: 333, + livenessKind: 'runtime_process', + pidSource: 'agent_process_table', + }, + }); + + const svc = new TeamProvisioningService(); + + const result = await svc.getMemberSpawnStatuses(teamName); + const persisted = JSON.parse(fs.readFileSync(getTeamLaunchStatePath(teamName), 'utf8')); + + expect(result.statuses.alice).toMatchObject({ + status: 'error', + launchState: 'failed_to_start', + runtimeAlive: false, + livenessSource: undefined, + livenessKind: 'stale_metadata', + hardFailure: true, + hardFailureReason: 'Teammate did not join within the launch grace window.', + }); + expect(result.summary).toMatchObject({ + failedCount: 1, + runtimeAlivePendingCount: 0, + }); + expect(persisted.members.alice.runtimeAlive).toBe(false); + expect(persisted.members.alice.sources?.processAlive).toBeUndefined(); + }); + it('excludes removed meta members from live runtime metadata resolution', async () => { const svc = new TeamProvisioningService(); (svc as any).configReader = { @@ -1140,6 +1295,9 @@ describe('TeamProvisioningService', () => { ]; (svc as any).aliveRunByTeam.set('runtime-team', 'run-1'); (svc as any).runs.set('run-1', run); + vi.mocked(listRuntimeProcessesForCurrentTmuxPlatform).mockResolvedValue([ + { pid: 333, ppid: 1, command: 'opencode runtime host' }, + ]); vi.mocked(pidusage).mockReset(); vi.mocked(pidusage).mockImplementation( async (target: number | string | Array) => { @@ -1216,6 +1374,9 @@ describe('TeamProvisioningService', () => { ), }; vi.mocked(pidusage).mockReset(); + vi.mocked(listRuntimeProcessesForCurrentTmuxPlatform).mockResolvedValue([ + { pid: 333, ppid: 1, command: 'opencode runtime host' }, + ]); vi.mocked(pidusage).mockImplementation( async (target: number | string | Array) => { if (Array.isArray(target)) { @@ -2238,6 +2399,7 @@ describe('TeamProvisioningService', () => { pendingCount: 1, failedCount: 0, runtimeAlivePendingCount: 1, + runtimeProcessPendingCount: 1, }, { version: 2, @@ -2314,6 +2476,37 @@ describe('TeamProvisioningService', () => { ).toBe('Finishing launch — 1 teammate awaiting permission approval'); }); + it('counts registered-only liveness as no-runtime pending in launch summaries', () => { + const svc = new TeamProvisioningService(); + const run = createMemberSpawnRun({ + expectedMembers: ['alice'], + memberSpawnStatuses: new Map([ + [ + 'alice', + createMemberSpawnStatusEntry({ + status: 'waiting', + launchState: 'runtime_pending_bootstrap', + agentToolAccepted: true, + runtimeAlive: false, + bootstrapConfirmed: false, + livenessKind: 'registered_only', + runtimeDiagnostic: 'registered runtime metadata without live process', + }), + ], + ]), + }); + + const launchSummary = (svc as any).getMemberLaunchSummary(run); + + expect(launchSummary).toMatchObject({ + pendingCount: 1, + noRuntimePendingCount: 1, + }); + expect( + (svc as any).buildPendingBootstrapStatusMessage('Finishing launch', run, launchSummary) + ).toContain('1 no runtime found'); + }); + it('trusts persisted snapshot permission state for pure teams when live run statuses are absent', () => { const svc = new TeamProvisioningService(); const run = createMemberSpawnRun({ @@ -2330,6 +2523,7 @@ describe('TeamProvisioningService', () => { pendingCount: 1, failedCount: 0, runtimeAlivePendingCount: 1, + runtimeProcessPendingCount: 1, }, { version: 2, @@ -2383,6 +2577,7 @@ describe('TeamProvisioningService', () => { pendingCount: 1, failedCount: 0, runtimeAlivePendingCount: 1, + runtimeProcessPendingCount: 1, }, { version: 2, @@ -2403,6 +2598,7 @@ describe('TeamProvisioningService', () => { runtimeAlive: true, bootstrapConfirmed: false, hardFailure: false, + livenessKind: 'runtime_process', lastEvaluatedAt: '2026-04-22T12:00:00.000Z', }, }, @@ -2411,6 +2607,7 @@ describe('TeamProvisioningService', () => { pendingCount: 1, failedCount: 0, runtimeAlivePendingCount: 1, + runtimeProcessPendingCount: 1, }, teamLaunchState: 'partial_pending', } @@ -2420,6 +2617,24 @@ describe('TeamProvisioningService', () => { expect(message).not.toContain('/0'); }); + it('does not use legacy runtimeAlivePendingCount as online launch copy evidence', () => { + const svc = new TeamProvisioningService(); + const run = createMemberSpawnRun({ + teamName: 'pure-team', + expectedMembers: ['alice'], + memberSpawnStatuses: new Map(), + }); + + const message = (svc as any).buildAggregatePendingLaunchMessage('Finishing launch', run, { + confirmedCount: 0, + pendingCount: 1, + failedCount: 0, + runtimeAlivePendingCount: 1, + }); + + expect(message).toBe('Finishing launch — teammates are still starting'); + }); + it('uses the union of persisted expected members and persisted member entries for pending launch copy', () => { const svc = new TeamProvisioningService(); const run = createMemberSpawnRun({ @@ -2987,6 +3202,82 @@ describe('TeamProvisioningService', () => { }); }); + it('persists sanitized runtime tool metadata diagnostics on OpenCode liveness updates', async () => { + const svc = new TeamProvisioningService(); + const previousSnapshot = { + version: 2 as const, + teamName: 'mixed-team', + updatedAt: '2026-04-22T12:00:00.000Z', + launchPhase: 'active' as const, + expectedMembers: ['bob'], + members: { + bob: { + name: 'bob', + providerId: 'opencode' as const, + laneId: 'secondary:opencode:bob', + laneKind: 'secondary' as const, + laneOwnerProviderId: 'opencode' as const, + launchState: 'runtime_pending_bootstrap' as const, + agentToolAccepted: true, + runtimeAlive: true, + bootstrapConfirmed: false, + hardFailure: false, + lastEvaluatedAt: '2026-04-22T12:00:00.000Z', + diagnostics: ['existing diagnostic'], + }, + }, + summary: { + confirmedCount: 0, + pendingCount: 1, + failedCount: 0, + runtimeAlivePendingCount: 1, + }, + teamLaunchState: 'partial_pending' as const, + }; + const write = vi.fn(async () => {}); + + (svc as any).launchStateStore = { + read: vi.fn(async () => previousSnapshot), + write, + }; + + await (svc as any).updateOpenCodeRuntimeMemberLiveness({ + teamName: 'mixed-team', + runId: 'run-member-spawn-1', + memberName: 'bob', + runtimeSessionId: 'session-bob', + observedAt: '2026-04-22T12:05:00.000Z', + diagnostics: ['native heartbeat'], + metadata: { + runtimePid: 4321, + processCommand: 'opencode runtime --token super-secret --safe ok', + runtimeVersion: '1.2.3', + hostPid: 987, + cwd: '/tmp/project', + }, + reason: 'OpenCode runtime heartbeat accepted', + }); + + expect(write).toHaveBeenCalledTimes(1); + const writtenSnapshot = ( + write.mock.calls[0] as unknown as [string, Record] | 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 = { @@ -4356,6 +4647,107 @@ describe('TeamProvisioningService', () => { }); expect(run.pendingMemberRestarts.has('bob')).toBe(false); }); + + it('does not let stale runtimeAlive bypass launch timeout when live metadata is weak', async () => { + const svc = new TeamProvisioningService(); + const run = createMemberSpawnRun({ + teamName: 'codex-team', + expectedMembers: ['bob'], + memberSpawnStatuses: new Map([ + [ + 'bob', + createMemberSpawnStatusEntry({ + status: 'online', + launchState: 'runtime_pending_bootstrap', + agentToolAccepted: true, + runtimeAlive: true, + livenessSource: 'process', + bootstrapConfirmed: false, + firstSpawnAcceptedAt: new Date(Date.now() - 120_000).toISOString(), + }), + ], + ]), + }); + (svc as any).refreshMemberSpawnStatusesFromLeadInbox = vi.fn(async () => {}); + (svc as any).maybeAuditMemberSpawnStatuses = vi.fn(async () => {}); + (svc as any).getLiveTeamAgentRuntimeMetadata = vi.fn( + async () => + new Map([ + [ + 'bob', + { + alive: false, + livenessKind: 'shell_only', + runtimeDiagnostic: 'tmux pane foreground command is zsh', + runtimeDiagnosticSeverity: 'warning', + }, + ], + ]) + ); + + await (svc as any).reevaluateMemberLaunchStatus(run, 'bob'); + + expect(run.memberSpawnStatuses.get('bob')).toMatchObject({ + status: 'error', + launchState: 'failed_to_start', + runtimeAlive: false, + livenessSource: undefined, + bootstrapConfirmed: false, + livenessKind: 'shell_only', + runtimeDiagnostic: 'tmux pane foreground command is zsh', + error: 'tmux pane foreground command is zsh', + }); + }); + + it('keeps verified runtime pending with a warning after the bootstrap stall window', async () => { + const svc = new TeamProvisioningService(); + const run = createMemberSpawnRun({ + teamName: 'codex-team', + expectedMembers: ['bob'], + memberSpawnStatuses: new Map([ + [ + 'bob', + createMemberSpawnStatusEntry({ + status: 'waiting', + launchState: 'runtime_pending_bootstrap', + agentToolAccepted: true, + runtimeAlive: false, + bootstrapConfirmed: false, + firstSpawnAcceptedAt: new Date(Date.now() - 6 * 60_000).toISOString(), + }), + ], + ]), + }); + (svc as any).refreshMemberSpawnStatusesFromLeadInbox = vi.fn(async () => {}); + (svc as any).maybeAuditMemberSpawnStatuses = vi.fn(async () => {}); + (svc as any).getLiveTeamAgentRuntimeMetadata = vi.fn( + async () => + new Map([ + [ + 'bob', + { + alive: true, + livenessKind: 'runtime_process', + runtimeDiagnostic: 'verified runtime process detected', + }, + ], + ]) + ); + + await (svc as any).reevaluateMemberLaunchStatus(run, 'bob'); + + expect(run.memberSpawnStatuses.get('bob')).toMatchObject({ + status: 'online', + launchState: 'runtime_pending_bootstrap', + runtimeAlive: true, + bootstrapConfirmed: false, + livenessSource: 'process', + livenessKind: 'runtime_process', + runtimeDiagnostic: 'Runtime process is alive, but no bootstrap check-in after 5 min.', + runtimeDiagnosticSeverity: 'warning', + hardFailure: false, + }); + }); }); it('removes generated MCP config when createTeam spawn fails synchronously', async () => { @@ -5753,7 +6145,19 @@ describe('TeamProvisioningService', () => { ); const svc = new TeamProvisioningService(); - (svc as any).getLiveTeamAgentNames = vi.fn(() => new Set(['alice'])); + (svc as any).getLiveTeamAgentRuntimeMetadata = vi.fn( + async () => + new Map([ + [ + 'alice', + { + alive: true, + livenessKind: 'runtime_process', + runtimeDiagnostic: 'verified runtime process detected', + }, + ], + ]) + ); const result = await svc.getMemberSpawnStatuses(teamName); @@ -6098,14 +6502,12 @@ describe('TeamProvisioningService', () => { ); const svc = new TeamProvisioningService(); - (svc as any).getLiveTeamAgentNames = vi.fn(() => new Set(['jack'])); const result = await svc.getMemberSpawnStatuses(teamName); expect(result.statuses.jack).toMatchObject({ status: 'error', launchState: 'failed_to_start', - runtimeAlive: true, }); expect(result.statuses.jack?.error).toContain('requested model is not available'); expect(result.statuses.jack?.hardFailureReason).toContain('requested model is not available'); @@ -6449,7 +6851,7 @@ describe('TeamProvisioningService', () => { expect(run.memberSpawnStatuses.get('alice')).toBe(existingEntry); }); - it('treats duplicate_skipped already_running as process-confirmed online', () => { + it('keeps duplicate_skipped already_running pending without strong evidence', () => { const run = createMemberSpawnRun(); run.activeToolCalls.set('tool-agent-1', { memberName: 'alice', @@ -6477,10 +6879,9 @@ describe('TeamProvisioningService', () => { ); expect(run.memberSpawnStatuses.get('alice')).toMatchObject({ - status: 'online', + status: 'waiting', launchState: 'runtime_pending_bootstrap', - runtimeAlive: true, - livenessSource: 'process', + runtimeAlive: false, hardFailure: false, }); }); @@ -6629,6 +7030,7 @@ describe('TeamProvisioningService', () => { { alive: true, model: 'gpt-5.2', + livenessKind: 'runtime_process', }, ], ]) @@ -6666,6 +7068,7 @@ describe('TeamProvisioningService', () => { { alive: true, model: 'gpt-5.2', + livenessKind: 'runtime_process', }, ], ]) @@ -6693,6 +7096,130 @@ describe('TeamProvisioningService', () => { }); }); + it('downgrades stale process liveness to pending when live metadata is weak', async () => { + const svc = new TeamProvisioningService(); + (svc as any).getLiveTeamAgentRuntimeMetadata = vi.fn( + async () => + new Map([ + [ + 'bob', + { + alive: false, + livenessKind: 'runtime_process_candidate', + runtimeDiagnostic: 'OpenCode runtime pid is alive, but process identity is unverified', + runtimeDiagnosticSeverity: 'warning', + }, + ], + ]) + ); + + const result = await (svc as any).attachLiveRuntimeMetadataToStatuses('beacon-desk-4', { + bob: createMemberSpawnStatusEntry({ + status: 'online', + launchState: 'runtime_pending_bootstrap', + agentToolAccepted: true, + runtimeAlive: true, + livenessSource: 'process', + bootstrapConfirmed: false, + hardFailure: false, + }), + }); + + expect(result.bob).toMatchObject({ + status: 'waiting', + launchState: 'runtime_pending_bootstrap', + runtimeAlive: false, + livenessSource: undefined, + livenessKind: 'runtime_process_candidate', + runtimeDiagnostic: 'OpenCode runtime pid is alive, but process identity is unverified', + runtimeDiagnosticSeverity: 'warning', + }); + }); + + it('keeps process table diagnostics visible when live metadata has no primary diagnostic', async () => { + const svc = new TeamProvisioningService(); + (svc as any).getLiveTeamAgentRuntimeMetadata = vi.fn( + async () => + new Map([ + [ + 'bob', + { + alive: false, + livenessKind: 'not_found', + runtimeDiagnosticSeverity: 'warning', + diagnostics: ['process table is unavailable'], + }, + ], + ]) + ); + + const result = await (svc as any).attachLiveRuntimeMetadataToStatuses('beacon-desk-4', { + bob: createMemberSpawnStatusEntry({ + status: 'waiting', + launchState: 'runtime_pending_bootstrap', + agentToolAccepted: true, + runtimeAlive: false, + bootstrapConfirmed: false, + hardFailure: false, + }), + }); + + expect(result.bob).toMatchObject({ + launchState: 'runtime_pending_bootstrap', + runtimeAlive: false, + livenessKind: 'not_found', + runtimeDiagnostic: 'process table unavailable', + runtimeDiagnosticSeverity: 'warning', + }); + }); + + it('classifies process table unavailable launch diagnostics with natural wording', () => { + const svc = new TeamProvisioningService(); + const onProgress = vi.fn(); + const run = createMemberSpawnRun({ + expectedMembers: ['bob'], + memberSpawnStatuses: new Map([ + [ + 'bob', + createMemberSpawnStatusEntry({ + status: 'waiting', + launchState: 'runtime_pending_bootstrap', + livenessKind: 'shell_only', + runtimeDiagnostic: 'tmux pane foreground command is zsh; process table is unavailable', + }), + ], + ]), + }); + run.isLaunch = true; + run.progress = { + runId: run.runId, + teamName: run.teamName, + status: 'running', + updatedAt: '2026-04-22T12:00:00.000Z', + }; + run.onProgress = onProgress; + + (svc as any).setMemberSpawnStatus(run, 'bob', 'online', undefined, 'process'); + + expect(run.progress.launchDiagnostics).toEqual( + expect.arrayContaining([ + expect.objectContaining({ + memberName: 'bob', + code: 'process_table_unavailable', + severity: 'warning', + detail: 'tmux pane foreground command is zsh; process table is unavailable', + }), + ]) + ); + expect(onProgress).toHaveBeenCalledWith( + expect.objectContaining({ + launchDiagnostics: expect.arrayContaining([ + expect.objectContaining({ code: 'process_table_unavailable' }), + ]), + }) + ); + }); + it('does not clear an explicit restart failure just because the old runtime is still alive', async () => { const svc = new TeamProvisioningService(); (svc as any).getLiveTeamAgentRuntimeMetadata = vi.fn( @@ -6803,7 +7330,6 @@ describe('TeamProvisioningService', () => { expect(result.bob).toMatchObject({ status: 'error', launchState: 'failed_to_start', - runtimeAlive: true, hardFailure: true, hardFailureReason: 'Teammate did not join within the launch grace window.', error: 'Teammate did not join within the launch grace window.', @@ -7022,7 +7548,7 @@ describe('TeamProvisioningService', () => { }); }); - it('treats suffixed live runtime names as alive during persisted launch reconcile', async () => { + it('keeps suffixed weak runtime metadata pending during persisted launch reconcile', async () => { const teamName = 'suffixed-live-runtime-team'; const leadSessionId = 'lead-session'; writeLaunchConfig(teamName, '/Users/test/proj', leadSessionId, ['alice']); @@ -7039,14 +7565,26 @@ describe('TeamProvisioningService', () => { }); const svc = new TeamProvisioningService(); - (svc as any).getLiveTeamAgentNames = vi.fn(async () => new Set(['alice-2'])); + (svc as any).getLiveTeamAgentRuntimeMetadata = vi.fn( + async () => + new Map([ + [ + 'alice-2', + { + alive: false, + livenessKind: 'registered_only', + runtimeDiagnostic: 'registered runtime metadata without live process', + }, + ], + ]) + ); const result = await svc.getMemberSpawnStatuses(teamName); expect(result.statuses.alice).toMatchObject({ - status: 'online', + status: 'waiting', launchState: 'runtime_pending_bootstrap', - runtimeAlive: true, + runtimeAlive: false, }); }); @@ -7105,7 +7643,7 @@ describe('TeamProvisioningService', () => { 2 ) ); - (svc as any).getLiveTeamAgentNames = vi.fn(async () => new Set()); + (svc as any).getLiveTeamAgentRuntimeMetadata = vi.fn(async () => new Map()); const result = await (svc as any).reconcilePersistedLaunchState(teamName); 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/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/members/MemberCard.test.ts b/test/renderer/components/team/members/MemberCard.test.ts index 3ce27e9b..b97dc859 100644 --- a/test/renderer/components/team/members/MemberCard.test.ts +++ b/test/renderer/components/team/members/MemberCard.test.ts @@ -228,7 +228,7 @@ describe('MemberCard starting-state visuals', () => { }); expect(host.textContent).not.toContain('online'); - expect(host.querySelector('[aria-label="connecting"]')).not.toBeNull(); + expect(host.querySelector('[aria-label="waiting for bootstrap"]')).not.toBeNull(); await act(async () => { root.unmount(); @@ -302,7 +302,7 @@ describe('MemberCard starting-state visuals', () => { }); }); - it('shows a connecting badge while runtime bootstrap is still pending after the process comes online', async () => { + it('shows a waiting-for-bootstrap badge while runtime bootstrap is still pending after the process comes online', async () => { vi.stubGlobal('IS_REACT_ACT_ENVIRONMENT', true); const host = document.createElement('div'); document.body.appendChild(host); @@ -324,9 +324,9 @@ describe('MemberCard starting-state visuals', () => { await Promise.resolve(); }); - expect(host.textContent).toContain('connecting'); + expect(host.textContent).toContain('waiting for bootstrap'); expect(host.textContent).not.toContain('ready'); - expect(host.querySelector('[aria-label="connecting"]')).not.toBeNull(); + expect(host.querySelector('[aria-label="waiting for bootstrap"]')).not.toBeNull(); await act(async () => { root.unmount(); @@ -430,4 +430,156 @@ describe('MemberCard starting-state visuals', () => { await Promise.resolve(); }); }); + + it('labels shared OpenCode host memory instead of member-owned runtime memory', async () => { + vi.stubGlobal('IS_REACT_ACT_ENVIRONMENT', true); + const host = document.createElement('div'); + document.body.appendChild(host); + const root = createRoot(host); + + await act(async () => { + root.render( + React.createElement(MemberCard, { + member, + memberColor: 'blue', + runtimeSummary: 'minimax · via OpenCode · 183.9 MB', + runtimeEntry: { + memberName: 'alice', + alive: true, + restartable: false, + providerId: 'opencode', + pid: 333, + pidSource: 'opencode_bridge', + rssBytes: 183.9 * 1024 * 1024, + updatedAt: '2026-04-24T12:00:00.000Z', + }, + isTeamAlive: true, + isTeamProvisioning: false, + }) + ); + await Promise.resolve(); + }); + + expect(host.querySelector('[title="RSS source: shared OpenCode host"]')).not.toBeNull(); + + await act(async () => { + root.unmount(); + await Promise.resolve(); + }); + }); + + it('copies bounded launch diagnostics only for launch errors', async () => { + vi.stubGlobal('IS_REACT_ACT_ENVIRONMENT', true); + const writeText = vi.fn().mockResolvedValue(undefined); + Object.defineProperty(navigator, 'clipboard', { + configurable: true, + value: { writeText }, + }); + const host = document.createElement('div'); + document.body.appendChild(host); + const root = createRoot(host); + + await act(async () => { + root.render( + React.createElement(MemberCard, { + member, + memberColor: 'blue', + runtimeRunId: 'run-42', + isTeamAlive: true, + isTeamProvisioning: false, + spawnStatus: 'waiting', + spawnLaunchState: 'runtime_pending_bootstrap', + spawnRuntimeAlive: false, + spawnEntry: { + status: 'waiting', + launchState: 'runtime_pending_bootstrap', + runtimeAlive: false, + bootstrapConfirmed: false, + hardFailure: false, + agentToolAccepted: true, + livenessKind: 'shell_only', + runtimeDiagnostic: 'tmux pane foreground command is zsh', + runtimeDiagnosticSeverity: 'warning', + updatedAt: '2026-04-24T12:00:00.000Z', + }, + runtimeEntry: { + memberName: 'alice', + alive: false, + restartable: true, + pid: 26676, + pidSource: 'tmux_pane', + paneCurrentCommand: 'zsh', + processCommand: 'node runtime --token super-secret', + updatedAt: '2026-04-24T12:00:01.000Z', + }, + }) + ); + await Promise.resolve(); + }); + + expect(host.querySelector('[aria-label="Copy diagnostics"]')).toBeNull(); + + await act(async () => { + root.render( + React.createElement(MemberCard, { + member, + memberColor: 'blue', + runtimeRunId: 'run-42', + isTeamAlive: true, + isTeamProvisioning: false, + spawnStatus: 'error', + spawnLaunchState: 'failed_to_start', + spawnRuntimeAlive: false, + spawnError: 'spawn failed', + spawnEntry: { + status: 'error', + launchState: 'failed_to_start', + runtimeAlive: false, + bootstrapConfirmed: false, + hardFailure: true, + hardFailureReason: 'spawn failed', + agentToolAccepted: false, + livenessKind: 'not_found', + runtimeDiagnostic: 'spawn failed', + runtimeDiagnosticSeverity: 'error', + updatedAt: '2026-04-24T12:00:00.000Z', + }, + runtimeEntry: { + memberName: 'alice', + alive: false, + restartable: true, + pid: 26676, + pidSource: 'tmux_pane', + paneCurrentCommand: 'zsh', + processCommand: 'node runtime --token super-secret', + updatedAt: '2026-04-24T12:00:01.000Z', + }, + }) + ); + await Promise.resolve(); + }); + + const button = host.querySelector('[aria-label="Copy diagnostics"]') as HTMLButtonElement; + expect(button).not.toBeNull(); + + await act(async () => { + button.click(); + await Promise.resolve(); + }); + + expect(writeText).toHaveBeenCalledTimes(1); + const payload = JSON.parse(writeText.mock.calls[0][0] as string) as { + runId?: string; + livenessKind?: string; + processCommand?: string; + }; + expect(payload.runId).toBe('run-42'); + expect(payload.livenessKind).toBe('not_found'); + expect(payload.processCommand).toContain('--token [redacted]'); + + await act(async () => { + root.unmount(); + await Promise.resolve(); + }); + }); }); 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/provisioningSteps.test.ts b/test/renderer/components/team/provisioningSteps.test.ts new file mode 100644 index 00000000..69689fb8 --- /dev/null +++ b/test/renderer/components/team/provisioningSteps.test.ts @@ -0,0 +1,107 @@ +import { describe, expect, it } from 'vitest'; + +import { getLaunchJoinMilestonesFromMembers } from '@renderer/components/team/provisioningSteps'; + +const members = [{ name: 'alice' }, { name: 'bob' }, { name: 'tom' }, { name: 'jane' }]; + +describe('getLaunchJoinMilestonesFromMembers', () => { + it('does not count shell-only liveness as process alive', () => { + const milestones = getLaunchJoinMilestonesFromMembers({ + members, + memberSpawnStatuses: { + alice: { + status: 'online', + launchState: 'runtime_pending_bootstrap', + runtimeAlive: true, + livenessSource: 'process', + livenessKind: 'shell_only', + updatedAt: '2026-04-24T12:00:00.000Z', + }, + bob: { + status: 'online', + launchState: 'runtime_pending_bootstrap', + runtimeAlive: true, + livenessSource: 'process', + livenessKind: 'runtime_process', + updatedAt: '2026-04-24T12:00:00.000Z', + }, + tom: { + status: 'online', + launchState: 'runtime_pending_bootstrap', + runtimeAlive: true, + livenessSource: 'process', + livenessKind: 'runtime_process_candidate', + updatedAt: '2026-04-24T12:00:00.000Z', + }, + jane: { + status: 'online', + launchState: 'runtime_pending_bootstrap', + runtimeAlive: true, + livenessSource: 'process', + updatedAt: '2026-04-24T12:00:00.000Z', + }, + }, + }); + + expect(milestones.processOnlyAliveCount).toBe(1); + expect(milestones.pendingSpawnCount).toBe(3); + }); + + it('does not count missing liveness kind as process alive', () => { + const milestones = getLaunchJoinMilestonesFromMembers({ + members, + memberSpawnStatuses: { + alice: { + status: 'online', + launchState: 'runtime_pending_bootstrap', + runtimeAlive: true, + livenessSource: 'process', + updatedAt: '2026-04-24T12:00:00.000Z', + }, + }, + }); + + expect(milestones.processOnlyAliveCount).toBe(0); + expect(milestones.pendingSpawnCount).toBe(4); + }); + + it('uses runtimeProcessPendingCount instead of legacy runtimeAlivePendingCount for snapshot pending math', () => { + const milestones = getLaunchJoinMilestonesFromMembers({ + members, + memberSpawnSnapshot: { + expectedMembers: ['alice', 'bob', 'tom', 'jane'], + summary: { + confirmedCount: 0, + pendingCount: 4, + failedCount: 0, + runtimeAlivePendingCount: 3, + runtimeProcessPendingCount: 1, + shellOnlyPendingCount: 1, + runtimeCandidatePendingCount: 1, + permissionPendingCount: 1, + }, + }, + }); + + expect(milestones.processOnlyAliveCount).toBe(1); + expect(milestones.pendingSpawnCount).toBe(3); + }); + + it('does not trust legacy runtimeAlivePendingCount without runtime process count', () => { + const milestones = getLaunchJoinMilestonesFromMembers({ + members, + memberSpawnSnapshot: { + expectedMembers: ['alice', 'bob', 'tom', 'jane'], + summary: { + confirmedCount: 0, + pendingCount: 4, + failedCount: 0, + runtimeAlivePendingCount: 3, + }, + }, + }); + + expect(milestones.processOnlyAliveCount).toBe(0); + expect(milestones.pendingSpawnCount).toBe(4); + }); +}); 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 ebdccc37..04313f64 100644 --- a/test/renderer/store/teamSlice.test.ts +++ b/test/renderer/store/teamSlice.test.ts @@ -103,9 +103,7 @@ function createSliceStore() { })); } -function createTeamSnapshot( - overrides: Record = {} -): { +function createTeamSnapshot(overrides: Record = {}): { teamName: string; config: { name: string; members?: unknown[]; projectPath?: string }; tasks: unknown[]; @@ -227,7 +225,9 @@ 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); @@ -300,13 +300,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 }, @@ -441,9 +443,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({ @@ -465,9 +467,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 }, @@ -559,13 +561,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'); @@ -804,13 +808,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); }); @@ -1012,9 +1010,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']); }); @@ -2402,6 +2400,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({ @@ -2431,7 +2529,9 @@ 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'); @@ -2502,7 +2602,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: [ @@ -2549,78 +2649,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..dcdd7901 100644 --- a/test/renderer/utils/teamProvisioningPresentation.test.ts +++ b/test/renderer/utils/teamProvisioningPresentation.test.ts @@ -687,6 +687,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'],