diff --git a/src/main/services/team/TeamRuntimeLivenessResolver.ts b/src/main/services/team/TeamRuntimeLivenessResolver.ts index b1d7fa80..f9c93e54 100644 --- a/src/main/services/team/TeamRuntimeLivenessResolver.ts +++ b/src/main/services/team/TeamRuntimeLivenessResolver.ts @@ -23,6 +23,10 @@ export interface ResolveTeamMemberRuntimeLivenessInput { pane?: TmuxPaneRuntimeInfo; processRows: readonly RuntimeProcessTableRow[]; processTableAvailable: boolean; + targetedProcess?: { + pid: number; + command: string; + }; nowIso: string; } @@ -165,12 +169,24 @@ function collectDescendants( function isVerifiedRuntimeProcess(params: { row: RuntimeProcessTableRow; teamName: string; + memberName?: string; agentId?: string; + allowAgentNameFallback?: boolean; }): boolean { - return ( - commandArgEquals(params.row.command, '--team-name', params.teamName) && - commandArgEquals(params.row.command, '--agent-id', params.agentId) - ); + if (!commandArgEquals(params.row.command, '--team-name', params.teamName)) { + return false; + } + if (commandArgEquals(params.row.command, '--agent-id', params.agentId)) { + return true; + } + if (!params.allowAgentNameFallback) { + return false; + } + const expectedAgentId = params.agentId?.trim(); + if (expectedAgentId && extractCliArgValues(params.row.command, '--agent-id').length > 0) { + return false; + } + return commandArgEquals(params.row.command, '--agent-name', params.memberName); } function isOpenCodeRuntimeProcess(command: string | undefined): boolean { @@ -253,7 +269,13 @@ export function resolveTeamMemberRuntimeLiveness( const verifiedProcess = input.processRows .filter((row) => - isVerifiedRuntimeProcess({ row, teamName: input.teamName, agentId: input.agentId }) + isVerifiedRuntimeProcess({ + row, + teamName: input.teamName, + memberName: input.memberName, + agentId: input.agentId, + allowAgentNameFallback: input.providerId !== 'opencode' && input.backendType === 'process', + }) ) .sort((left, right) => right.pid - left.pid)[0]; if (verifiedProcess) { @@ -274,6 +296,35 @@ export function resolveTeamMemberRuntimeLiveness( typeof runtimePid === 'number' && runtimePid > 0 ? input.processRows.find((row) => row.pid === runtimePid) : undefined; + const targetedProcess = + typeof runtimePid === 'number' && + runtimePid > 0 && + input.targetedProcess?.pid === runtimePid && + isVerifiedRuntimeProcess({ + row: { + pid: input.targetedProcess.pid, + ppid: 0, + command: input.targetedProcess.command, + }, + teamName: input.teamName, + memberName: input.memberName, + agentId: input.agentId, + allowAgentNameFallback: input.providerId !== 'opencode' && input.backendType === 'process', + }) + ? input.targetedProcess + : undefined; + if (targetedProcess) { + return result({ + alive: true, + livenessKind: 'runtime_process', + pidSource: 'agent_process_table', + pid: targetedProcess.pid, + runtimeSessionId, + processCommand: sanitizeProcessCommandForDiagnostics(targetedProcess.command), + runtimeDiagnostic: 'verified runtime process detected by targeted pid check', + diagnostics: [...diagnostics, 'matched targeted process by pid and team/member identity'], + }); + } if (runtimePidRow && input.providerId === 'opencode') { const processCommand = sanitizeProcessCommandForDiagnostics(runtimePidRow.command); if (isOpenCodeRuntimeProcess(runtimePidRow.command)) { @@ -351,7 +402,14 @@ export function resolveTeamMemberRuntimeLiveness( const descendants = collectDescendants(input.processRows, pane.panePid); const verifiedDescendant = descendants .filter((row) => - isVerifiedRuntimeProcess({ row, teamName: input.teamName, agentId: input.agentId }) + isVerifiedRuntimeProcess({ + row, + teamName: input.teamName, + memberName: input.memberName, + agentId: input.agentId, + allowAgentNameFallback: + input.providerId !== 'opencode' && input.backendType === 'process', + }) ) .sort((left, right) => right.pid - left.pid)[0]; if (verifiedDescendant) { diff --git a/test/main/services/team/TeamRuntimeLivenessResolver.test.ts b/test/main/services/team/TeamRuntimeLivenessResolver.test.ts index 9c60fea0..02fccad5 100644 --- a/test/main/services/team/TeamRuntimeLivenessResolver.test.ts +++ b/test/main/services/team/TeamRuntimeLivenessResolver.test.ts @@ -1,9 +1,8 @@ -import { describe, expect, it } from 'vitest'; - import { resolveTeamMemberRuntimeLiveness, sanitizeProcessCommandForDiagnostics, } from '@main/services/team/TeamRuntimeLivenessResolver'; +import { describe, expect, it } from 'vitest'; const NOW = '2026-04-24T12:00:00.000Z'; @@ -50,6 +49,120 @@ describe('resolveTeamMemberRuntimeLiveness', () => { expect(result.pid).toBe(222); }); + it('promotes a verified team and agent-name process when agent id metadata is missing', () => { + const result = resolveTeamMemberRuntimeLiveness({ + teamName: 'demo', + memberName: 'alice', + backendType: 'process', + persistedRuntimePid: 222, + processRows: [ + { + pid: 222, + ppid: 1, + command: 'node runtime --team-name demo --agent-name 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('does not let matching agent name override a mismatched command agent id', () => { + const result = resolveTeamMemberRuntimeLiveness({ + teamName: 'demo', + memberName: 'alice', + agentId: 'alice@demo', + backendType: 'process', + persistedRuntimePid: 222, + processRows: [ + { + pid: 222, + ppid: 1, + command: 'node runtime --team-name demo --agent-id other@demo --agent-name alice', + }, + ], + processTableAvailable: true, + nowIso: NOW, + }); + + expect(result.alive).toBe(false); + expect(result.livenessKind).toBe('registered_only'); + }); + + it('does not use agent-name fallback for OpenCode runtime rows', () => { + const result = resolveTeamMemberRuntimeLiveness({ + teamName: 'demo', + memberName: 'alice', + providerId: 'opencode', + backendType: 'process', + persistedRuntimePid: 222, + processRows: [ + { + pid: 222, + ppid: 1, + command: 'opencode runtime --team-name demo --agent-name alice', + }, + ], + processTableAvailable: true, + nowIso: NOW, + }); + + expect(result.alive).toBe(false); + expect(result.livenessKind).toBe('runtime_process_candidate'); + expect(result.pidSource).toBe('opencode_bridge'); + }); + + it('uses targeted pid verification when the full process table missed a live direct process', () => { + const result = resolveTeamMemberRuntimeLiveness({ + teamName: 'demo', + memberName: 'alice', + agentId: 'alice@demo', + backendType: 'process', + persistedRuntimePid: 222, + processRows: [], + processTableAvailable: true, + targetedProcess: { + pid: 222, + command: 'node runtime --agent-id alice@demo --agent-name alice --team-name demo', + }, + 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); + expect(result.runtimeDiagnostic).toBe( + 'verified runtime process detected by targeted pid check' + ); + }); + + it('does not trust targeted pid verification with mismatched team identity', () => { + const result = resolveTeamMemberRuntimeLiveness({ + teamName: 'demo', + memberName: 'alice', + agentId: 'alice@demo', + backendType: 'process', + persistedRuntimePid: 222, + processRows: [], + processTableAvailable: true, + targetedProcess: { + pid: 222, + command: 'node runtime --agent-id alice@other --agent-name alice --team-name other', + }, + nowIso: NOW, + }); + + expect(result.alive).toBe(false); + expect(result.livenessKind).toBe('stale_metadata'); + expect(result.pidSource).toBe('persisted_metadata'); + }); + it('keeps a verified process pid visible after bootstrap is confirmed', () => { const result = resolveTeamMemberRuntimeLiveness({ teamName: 'demo',